proposal-system/mobile/src/screens/dispatcher/NewProposalScreen.tsx

262 lines
7 KiB
TypeScript
Raw Normal View History

import React, { useState, useCallback } from 'react';
import {
View,
StyleSheet,
ScrollView,
Alert,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import {
TextInput,
Button,
Text,
SegmentedButtons,
Snackbar,
} from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import NetInfo from '@react-native-community/netinfo';
import {
proposalsApi,
type CreateProposalRequest,
} from '../../lib/api/proposals';
import { FilePickerButton } from '../../components/FilePickerButton';
import {
useOfflineDraft,
queueOfflineSubmission,
} from '../../hooks/useOfflineDraft';
import { SERVICE_CATEGORIES, PRIORITIES } from '../../constants';
interface DraftForm extends Record<string, unknown> {
workOrderNumber: string;
customerName: string;
customerAddress: string;
scopeOfWork: string;
serviceCategory: string;
priority: string;
notes: string;
}
const emptyDraft: DraftForm = {
workOrderNumber: '',
customerName: '',
customerAddress: '',
scopeOfWork: '',
serviceCategory: 'General',
priority: 'Standard',
notes: '',
};
export function NewProposalScreen() {
const navigation = useNavigation();
const { draft, updateDraft, clearDraft, isLoaded } =
useOfflineDraft(emptyDraft);
const [submitting, setSubmitting] = useState(false);
const [proposalId, setProposalId] = useState<string | null>(null);
const [snackbar, setSnackbar] = useState('');
const validate = (): string | null => {
if (!draft.customerName.trim()) return 'Customer name is required';
if (!draft.customerAddress.trim()) return 'Customer address is required';
if (!draft.scopeOfWork.trim()) return 'Scope of work is required';
if (!draft.workOrderNumber.trim()) return 'Work order number is required';
return null;
};
const handleSubmit = useCallback(async () => {
const error = validate();
if (error) {
Alert.alert('Validation', error);
return;
}
setSubmitting(true);
try {
const payload: CreateProposalRequest = {
workOrderNumber: draft.workOrderNumber.trim(),
customerName: draft.customerName.trim(),
customerAddress: draft.customerAddress.trim(),
scopeOfWork: draft.scopeOfWork.trim(),
serviceCategory: draft.serviceCategory as CreateProposalRequest['serviceCategory'],
priority: draft.priority as CreateProposalRequest['priority'],
notes: draft.notes.trim() || undefined,
};
const netState = await NetInfo.fetch();
if (!netState.isConnected) {
await queueOfflineSubmission(payload);
await clearDraft();
Alert.alert(
'Saved Offline',
'Your proposal has been queued and will submit when you reconnect.',
[{ text: 'OK', onPress: () => navigation.goBack() }],
);
return;
}
const result = await proposalsApi.create(payload);
setProposalId(result.id);
await clearDraft();
setSnackbar(`Proposal ${result.proposalNumber} submitted!`);
setTimeout(() => navigation.goBack(), 1500);
} catch (err) {
Alert.alert(
'Error',
err instanceof Error ? err.message : 'Failed to submit proposal.',
);
} finally {
setSubmitting(false);
}
}, [draft, clearDraft, navigation]);
if (!isLoaded) return null;
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={styles.scroll}>
<TextInput
label="Work Order Number"
value={draft.workOrderNumber}
onChangeText={(v) => updateDraft({ workOrderNumber: v })}
mode="outlined"
style={styles.input}
/>
<TextInput
label="Customer Name"
value={draft.customerName}
onChangeText={(v) => updateDraft({ customerName: v })}
mode="outlined"
style={styles.input}
/>
<TextInput
label="Customer Address / Site Location"
value={draft.customerAddress}
onChangeText={(v) => updateDraft({ customerAddress: v })}
mode="outlined"
multiline
numberOfLines={2}
style={styles.input}
/>
<Text variant="labelLarge" style={styles.label}>
Service Category
</Text>
<SegmentedButtons
value={draft.serviceCategory}
onValueChange={(v) => updateDraft({ serviceCategory: v })}
buttons={SERVICE_CATEGORIES.map((c) => ({
value: c,
label: c,
}))}
style={styles.segmented}
density="small"
/>
<Text variant="labelLarge" style={styles.label}>
Priority
</Text>
<SegmentedButtons
value={draft.priority}
onValueChange={(v) => updateDraft({ priority: v })}
buttons={PRIORITIES.map((p) => ({ value: p, label: p }))}
style={styles.segmented}
/>
<TextInput
label="Scope of Work"
value={draft.scopeOfWork}
onChangeText={(v) => updateDraft({ scopeOfWork: v })}
mode="outlined"
multiline
numberOfLines={6}
placeholder="Describe the work to be done..."
style={styles.input}
/>
<Text variant="labelLarge" style={styles.label}>
Vendor Proposal (optional)
</Text>
{proposalId ? (
<FilePickerButton
onFileSelected={() => setSnackbar('Vendor PDF attached')}
getUploadUrl={(fileName) =>
proposalsApi.uploadAttachment(proposalId, fileName)
}
/>
) : (
<Text variant="bodySmall" style={styles.hint}>
Submit the proposal first, then attach vendor PDFs
</Text>
)}
<TextInput
label="Additional Notes"
value={draft.notes}
onChangeText={(v) => updateDraft({ notes: v })}
mode="outlined"
multiline
numberOfLines={3}
style={styles.input}
/>
<Button
mode="contained"
onPress={handleSubmit}
loading={submitting}
disabled={submitting}
contentStyle={styles.submitContent}
style={styles.submit}
>
Submit Proposal
</Button>
</ScrollView>
<Snackbar
visible={!!snackbar}
onDismiss={() => setSnackbar('')}
duration={3000}
>
{snackbar}
</Snackbar>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FAFAFA',
},
scroll: {
padding: 16,
paddingBottom: 40,
},
input: {
marginBottom: 12,
},
label: {
marginBottom: 8,
marginTop: 4,
color: '#424242',
},
segmented: {
marginBottom: 16,
},
hint: {
color: '#9E9E9E',
marginBottom: 12,
},
submit: {
marginTop: 16,
borderRadius: 8,
},
submitContent: {
paddingVertical: 8,
},
});