mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 04:42:00 +00:00
Role-based bottom-tab navigation (dispatcher dashboard, admin workspace), shared components (StatusChip, PriorityChip, ProposalCard, LineItemRow, FilePickerButton, EmptyState), dispatcher screens (dashboard, new proposal with camera/document picker and offline drafts, proposal detail), admin screens (dashboard with stats, proposal queue, workspace with approve/ regenerate/PDF, line item editor), login, and settings.
261 lines
7 KiB
TypeScript
261 lines
7 KiB
TypeScript
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,
|
|
},
|
|
});
|