Add mobile navigation, components, and all screens

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.
This commit is contained in:
Adam Moussa 2026-05-17 15:09:43 -04:00
parent 47c3b8e5eb
commit 9aef2a3891
17 changed files with 2469 additions and 0 deletions

View file

@ -0,0 +1,42 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Text, Icon } from 'react-native-paper';
interface Props {
icon: string;
title: string;
message: string;
}
export function EmptyState({ icon, title, message }: Props) {
return (
<View style={styles.container}>
<Icon source={icon} size={64} color="#BDBDBD" />
<Text variant="titleMedium" style={styles.title}>
{title}
</Text>
<Text variant="bodyMedium" style={styles.message}>
{message}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
title: {
marginTop: 16,
fontWeight: '600',
color: '#424242',
},
message: {
marginTop: 8,
textAlign: 'center',
color: '#757575',
},
});

View file

@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { View, StyleSheet, Alert } from 'react-native';
import { Button, Text, ProgressBar } from 'react-native-paper';
import { launchCamera } from 'react-native-image-picker';
import DocumentPicker from 'react-native-document-picker';
import axios from 'axios';
interface Props {
onFileSelected: (fileName: string, s3Key: string) => void;
getUploadUrl: (
fileName: string,
) => Promise<{ uploadUrl: string; s3Key: string }>;
}
export function FilePickerButton({ onFileSelected, getUploadUrl }: Props) {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [fileName, setFileName] = useState<string | null>(null);
const uploadFile = async (uri: string, name: string, type: string) => {
setUploading(true);
setProgress(0);
setFileName(name);
try {
const { uploadUrl, s3Key } = await getUploadUrl(name);
const file = await fetch(uri);
const blob = await file.blob();
await axios.put(uploadUrl, blob, {
headers: { 'Content-Type': type },
onUploadProgress: (e) => {
if (e.total) setProgress(e.loaded / e.total);
},
});
onFileSelected(name, s3Key);
} catch {
Alert.alert(
'Upload Failed',
'Could not upload the file. Please try again.',
);
setFileName(null);
} finally {
setUploading(false);
}
};
const pickDocument = async () => {
try {
const result = await DocumentPicker.pickSingle({
type: [DocumentPicker.types.pdf],
});
if (result.uri && result.name) {
await uploadFile(
result.uri,
result.name,
result.type || 'application/pdf',
);
}
} catch (err) {
if (!DocumentPicker.isCancel(err)) {
Alert.alert('Error', 'Could not pick document.');
}
}
};
const takePhoto = async () => {
const result = await launchCamera({ mediaType: 'photo', quality: 0.8 });
if (result.assets?.[0]?.uri) {
const asset = result.assets[0];
await uploadFile(
asset.uri!,
asset.fileName || 'vendor-proposal.jpg',
asset.type || 'image/jpeg',
);
}
};
return (
<View style={styles.container}>
<View style={styles.buttons}>
<Button
mode="outlined"
icon="file-pdf-box"
onPress={pickDocument}
disabled={uploading}
style={styles.button}
>
Choose PDF
</Button>
<Button
mode="outlined"
icon="camera"
onPress={takePhoto}
disabled={uploading}
style={styles.button}
>
Camera
</Button>
</View>
{uploading && <ProgressBar progress={progress} style={styles.progress} />}
{fileName && !uploading && (
<Text variant="bodySmall" style={styles.fileName}>
{fileName}
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: 8,
},
buttons: {
flexDirection: 'row',
gap: 12,
},
button: {
flex: 1,
},
progress: {
marginTop: 4,
},
fileName: {
color: '#2E7D32',
},
});

View file

@ -0,0 +1,95 @@
import React from 'react';
import { View, StyleSheet, Pressable } from 'react-native';
import { Text, Chip } from 'react-native-paper';
import type { LineItem } from '../lib/api/lineItems';
interface Props {
item: LineItem;
index: number;
onPress?: () => void;
}
const sourceColors: Record<string, string> = {
AI: '#1565C0',
Vendor: '#E65100',
Manual: '#2E7D32',
Historical: '#7B1FA2',
};
export function LineItemRow({ item, index, onPress }: Props) {
const Wrapper = onPress ? Pressable : View;
return (
<Wrapper style={styles.row} onPress={onPress}>
<View style={styles.header}>
<Text variant="bodySmall" style={styles.index}>
#{index + 1}
</Text>
<Chip
compact
mode="flat"
style={{
backgroundColor:
(sourceColors[item.source] || '#757575') + '20',
}}
textStyle={{ fontSize: 10, color: sourceColors[item.source] }}
>
{item.source}
</Chip>
</View>
<Text variant="bodyMedium" style={styles.description}>
{item.description}
</Text>
<View style={styles.pricing}>
<Text variant="bodySmall" style={styles.qty}>
{item.quantity} {item.unit}
</Text>
{item.unitPrice != null && (
<Text variant="bodySmall" style={styles.unitPrice}>
@ ${item.unitPrice.toFixed(2)}
</Text>
)}
<Text variant="titleSmall" style={styles.total}>
${item.totalPrice.toLocaleString()}
</Text>
</View>
</Wrapper>
);
}
const styles = StyleSheet.create({
row: {
paddingVertical: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#F0F0F0',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
index: {
color: '#9E9E9E',
fontWeight: '600',
},
description: {
marginBottom: 6,
},
pricing: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
qty: {
color: '#757575',
},
unitPrice: {
color: '#757575',
},
total: {
marginLeft: 'auto',
color: '#2E7D32',
fontWeight: '600',
},
});

View file

@ -0,0 +1,21 @@
import React from 'react';
import { Chip } from 'react-native-paper';
import { priorityColors } from '../theme';
interface Props {
priority: string;
}
export function PriorityChip({ priority }: Props) {
const color = priorityColors[priority] || '#757575';
return (
<Chip
mode="outlined"
compact
style={{ borderColor: color }}
textStyle={{ color, fontSize: 12 }}
>
{priority}
</Chip>
);
}

View file

@ -0,0 +1,80 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Card, Text } from 'react-native-paper';
import { StatusChip } from './StatusChip';
import { PriorityChip } from './PriorityChip';
import type { ProposalListItem } from '../lib/api/proposals';
interface Props {
proposal: ProposalListItem;
onPress: () => void;
}
export function ProposalCard({ proposal, onPress }: Props) {
return (
<Card style={styles.card} onPress={onPress} mode="elevated">
<Card.Content>
<View style={styles.header}>
<Text variant="titleMedium" style={styles.number}>
{proposal.proposalNumber}
</Text>
<StatusChip status={proposal.status} />
</View>
<Text variant="bodyLarge" style={styles.customer}>
{proposal.customerName}
</Text>
<View style={styles.meta}>
<Text variant="bodySmall" style={styles.metaText}>
WO# {proposal.workOrderNumber}
</Text>
<PriorityChip priority={proposal.priority} />
</View>
{proposal.totalBidAmount > 0 && (
<Text variant="titleSmall" style={styles.amount}>
${proposal.totalBidAmount.toLocaleString()}
</Text>
)}
<Text variant="bodySmall" style={styles.date}>
{new Date(proposal.submittedAt).toLocaleDateString()}
</Text>
</Card.Content>
</Card>
);
}
const styles = StyleSheet.create({
card: {
marginHorizontal: 16,
marginVertical: 6,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
number: {
fontWeight: '700',
},
customer: {
marginBottom: 8,
},
meta: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
metaText: {
color: '#757575',
},
amount: {
color: '#2E7D32',
fontWeight: '600',
marginTop: 4,
},
date: {
color: '#9E9E9E',
marginTop: 4,
},
});

View file

@ -0,0 +1,29 @@
import React from 'react';
import { Chip } from 'react-native-paper';
import { statusColors } from '../theme';
interface Props {
status: string;
}
const statusLabels: Record<string, string> = {
Draft: 'Draft',
InReview: 'In Review',
Approved: 'Approved',
Sent: 'Sent',
Revised: 'Revised',
};
export function StatusChip({ status }: Props) {
const color = statusColors[status] || '#9E9E9E';
return (
<Chip
mode="flat"
compact
style={{ backgroundColor: color + '20' }}
textStyle={{ color, fontSize: 12, fontWeight: '600' }}
>
{statusLabels[status] || status}
</Chip>
);
}

View file

@ -0,0 +1,160 @@
import React from 'react';
import { ActivityIndicator, View, StyleSheet } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import { useSelector } from 'react-redux';
import {
selectIsAuthenticated,
selectAuthLoading,
selectUser,
} from '../store/slices/authSlice';
import type {
RootStackParamList,
MainTabParamList,
ProposalsStackParamList,
} from './types';
import { LoginScreen } from '../screens/auth/LoginScreen';
import { DashboardScreen } from '../screens/dispatcher/DashboardScreen';
import { AdminDashboardScreen } from '../screens/admin/AdminDashboardScreen';
import { NewProposalScreen } from '../screens/dispatcher/NewProposalScreen';
import { ProposalDetailScreen } from '../screens/dispatcher/ProposalDetailScreen';
import { ProposalQueueScreen } from '../screens/admin/ProposalQueueScreen';
import { ProposalWorkspaceScreen } from '../screens/admin/ProposalWorkspaceScreen';
import { LineItemEditScreen } from '../screens/admin/LineItemEditScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
const RootStack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<MainTabParamList>();
const ProposalsStack = createNativeStackNavigator<ProposalsStackParamList>();
function ProposalsNavigator() {
const user = useSelector(selectUser);
const isAdmin = user?.role === 'Admin' || user?.role === 'SysAdmin';
return (
<ProposalsStack.Navigator screenOptions={{ headerShown: true }}>
{isAdmin ? (
<>
<ProposalsStack.Screen
name="ProposalList"
component={ProposalQueueScreen}
options={{ title: 'Proposal Queue' }}
/>
<ProposalsStack.Screen
name="ProposalWorkspace"
component={ProposalWorkspaceScreen}
options={{ title: 'Proposal' }}
/>
<ProposalsStack.Screen
name="LineItemEdit"
component={LineItemEditScreen}
options={{ title: 'Edit Line Item' }}
/>
</>
) : (
<>
<ProposalsStack.Screen
name="ProposalList"
component={ProposalQueueScreen}
options={{ title: 'My Proposals' }}
/>
<ProposalsStack.Screen
name="NewProposal"
component={NewProposalScreen}
options={{ title: 'New Proposal' }}
/>
<ProposalsStack.Screen
name="ProposalDetail"
component={ProposalDetailScreen}
options={{ title: 'Proposal' }}
/>
</>
)}
</ProposalsStack.Navigator>
);
}
function DashboardRouter() {
const user = useSelector(selectUser);
const isAdmin = user?.role === 'Admin' || user?.role === 'SysAdmin';
return isAdmin ? <AdminDashboardScreen /> : <DashboardScreen />;
}
function MainTabs() {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
headerShown: route.name === 'DashboardTab',
tabBarIcon: ({ color, size }) => {
const icons: Record<string, string> = {
DashboardTab: 'view-dashboard',
ProposalsTab: 'file-document-outline',
SettingsTab: 'cog-outline',
};
return (
<Icon
name={icons[route.name] || 'circle'}
size={size}
color={color}
/>
);
},
tabBarActiveTintColor: '#1565C0',
tabBarInactiveTintColor: '#757575',
})}
>
<Tab.Screen
name="DashboardTab"
component={DashboardRouter}
options={{ title: 'Dashboard' }}
/>
<Tab.Screen
name="ProposalsTab"
component={ProposalsNavigator}
options={{ headerShown: false, title: 'Proposals' }}
/>
<Tab.Screen
name="SettingsTab"
component={SettingsScreen}
options={{ title: 'Settings' }}
/>
</Tab.Navigator>
);
}
export function RootNavigator() {
const isAuthenticated = useSelector(selectIsAuthenticated);
const loading = useSelector(selectAuthLoading);
if (loading) {
return (
<View style={styles.loading}>
<ActivityIndicator size="large" color="#1565C0" />
</View>
);
}
return (
<NavigationContainer>
<RootStack.Navigator screenOptions={{ headerShown: false }}>
{isAuthenticated ? (
<RootStack.Screen name="Main" component={MainTabs} />
) : (
<RootStack.Screen name="Auth" component={LoginScreen} />
)}
</RootStack.Navigator>
</NavigationContainer>
);
}
const styles = StyleSheet.create({
loading: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#FAFAFA',
},
});

View file

@ -0,0 +1,51 @@
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import type { CompositeScreenProps } from '@react-navigation/native';
export type RootStackParamList = {
Auth: undefined;
Main: undefined;
};
export type AuthStackParamList = {
Login: undefined;
};
export type MainTabParamList = {
DashboardTab: undefined;
ProposalsTab: undefined;
SettingsTab: undefined;
};
export type ProposalsStackParamList = {
ProposalList: undefined;
NewProposal: undefined;
ProposalDetail: { proposalId: string };
ProposalWorkspace: { proposalId: string };
LineItemEdit: {
proposalId: string;
lineItemId?: string;
description?: string;
quantity?: number;
unit?: string;
unitPrice?: number | null;
totalPrice?: number;
pricingMode?: string;
source?: string;
};
};
export type RootScreenProps<T extends keyof RootStackParamList> =
NativeStackScreenProps<RootStackParamList, T>;
export type AuthScreenProps<T extends keyof AuthStackParamList> =
NativeStackScreenProps<AuthStackParamList, T>;
export type MainTabScreenProps<T extends keyof MainTabParamList> =
BottomTabScreenProps<MainTabParamList, T>;
export type ProposalsScreenProps<T extends keyof ProposalsStackParamList> =
CompositeScreenProps<
NativeStackScreenProps<ProposalsStackParamList, T>,
BottomTabScreenProps<MainTabParamList>
>;

View file

@ -0,0 +1,92 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Card, Text, Button, Divider, List } from 'react-native-paper';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAuth } from '../hooks/useAuth';
export function SettingsScreen() {
const { user, logout } = useAuth();
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<View style={styles.content}>
<Card style={styles.card} mode="elevated">
<Card.Content>
<Text variant="titleLarge" style={styles.name}>
{user?.displayName}
</Text>
<Text variant="bodyMedium" style={styles.email}>
{user?.email}
</Text>
<View style={styles.roleBadge}>
<Text variant="labelMedium" style={styles.roleText}>
{user?.role}
</Text>
</View>
</Card.Content>
</Card>
<Card style={styles.card} mode="elevated">
<List.Item
title="About"
description="Sea Haven Proposal System v0.1.0"
left={(props) => <List.Icon {...props} icon="information-outline" />}
/>
<Divider />
<List.Item
title="Help & Support"
description="Contact IT for assistance"
left={(props) => <List.Icon {...props} icon="help-circle-outline" />}
/>
</Card>
<Button
mode="outlined"
icon="logout"
onPress={logout}
textColor="#D32F2F"
style={styles.logoutButton}
>
Sign Out
</Button>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FAFAFA',
},
content: {
flex: 1,
padding: 16,
},
card: {
marginBottom: 16,
},
name: {
fontWeight: '700',
marginBottom: 4,
},
email: {
color: '#757575',
marginBottom: 8,
},
roleBadge: {
alignSelf: 'flex-start',
backgroundColor: '#E3F2FD',
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 12,
},
roleText: {
color: '#1565C0',
fontWeight: '600',
},
logoutButton: {
marginTop: 'auto',
borderColor: '#D32F2F',
},
});

View file

@ -0,0 +1,191 @@
import React from 'react';
import { View, StyleSheet, ScrollView, RefreshControl } from 'react-native';
import { Card, Text } from 'react-native-paper';
import { useQuery } from '@tanstack/react-query';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAuth } from '../../hooks/useAuth';
import { adminApi } from '../../lib/api/admin';
import { proposalsApi } from '../../lib/api/proposals';
import { ProposalCard } from '../../components/ProposalCard';
import { EmptyState } from '../../components/EmptyState';
import { QUERY_KEYS } from '../../constants/queryKeys';
import type { ProposalsStackParamList } from '../../navigation/types';
type Nav = NativeStackNavigationProp<ProposalsStackParamList>;
export function AdminDashboardScreen() {
const { user } = useAuth();
const navigation = useNavigation<Nav>();
const dashboardQuery = useQuery({
queryKey: [QUERY_KEYS.dashboard],
queryFn: () => adminApi.getDashboard(),
});
const pendingQuery = useQuery({
queryKey: [QUERY_KEYS.proposals, 'pending'],
queryFn: () => proposalsApi.getAll({ status: 'InReview', pageSize: 5 }),
});
const stats = dashboardQuery.data;
const pending = pendingQuery.data?.items ?? [];
const refreshing =
dashboardQuery.isRefetching || pendingQuery.isRefetching;
const onRefresh = () => {
dashboardQuery.refetch();
pendingQuery.refetch();
};
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
<Text variant="headlineSmall" style={styles.welcome}>
Admin Dashboard
</Text>
<Text variant="bodyMedium" style={styles.greeting}>
Welcome back, {user?.displayName?.split(' ')[0]}
</Text>
{stats && (
<View style={styles.statsGrid}>
<Card style={styles.statCard} mode="elevated">
<Card.Content style={styles.statContent}>
<Text
variant="headlineMedium"
style={[styles.statValue, { color: '#E65100' }]}
>
{stats.pendingCount}
</Text>
<Text variant="bodySmall" style={styles.statLabel}>
Pending Review
</Text>
</Card.Content>
</Card>
<Card style={styles.statCard} mode="elevated">
<Card.Content style={styles.statContent}>
<Text
variant="headlineMedium"
style={[styles.statValue, { color: '#2E7D32' }]}
>
{stats.approvedThisWeek}
</Text>
<Text variant="bodySmall" style={styles.statLabel}>
Approved This Week
</Text>
</Card.Content>
</Card>
<Card style={styles.statCard} mode="elevated">
<Card.Content style={styles.statContent}>
<Text
variant="headlineMedium"
style={[styles.statValue, { color: '#1565C0' }]}
>
{stats.avgTurnaroundHours > 0
? `${Math.round(stats.avgTurnaroundHours)}h`
: '--'}
</Text>
<Text variant="bodySmall" style={styles.statLabel}>
Avg Turnaround
</Text>
</Card.Content>
</Card>
<Card style={styles.statCard} mode="elevated">
<Card.Content style={styles.statContent}>
<Text
variant="headlineMedium"
style={[styles.statValue, { color: '#7B1FA2' }]}
>
{stats.totalProposals}
</Text>
<Text variant="bodySmall" style={styles.statLabel}>
Total Proposals
</Text>
</Card.Content>
</Card>
</View>
)}
<Text variant="titleMedium" style={styles.sectionTitle}>
Pending Review
</Text>
{pending.length === 0 ? (
<EmptyState
icon="check-circle-outline"
title="All clear"
message="No proposals waiting for review"
/>
) : (
pending.map((p) => (
<ProposalCard
key={p.id}
proposal={p}
onPress={() =>
navigation.navigate('ProposalWorkspace', {
proposalId: p.id,
})
}
/>
))
)}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FAFAFA',
},
scroll: {
paddingBottom: 24,
},
welcome: {
fontWeight: '700',
paddingHorizontal: 16,
paddingTop: 16,
},
greeting: {
color: '#757575',
paddingHorizontal: 16,
paddingBottom: 12,
},
statsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 12,
gap: 8,
marginBottom: 16,
},
statCard: {
width: '47%',
flexGrow: 1,
},
statContent: {
alignItems: 'center',
paddingVertical: 12,
},
statValue: {
fontWeight: '700',
},
statLabel: {
color: '#757575',
marginTop: 2,
textAlign: 'center',
},
sectionTitle: {
fontWeight: '600',
paddingHorizontal: 16,
paddingVertical: 8,
},
});

View file

@ -0,0 +1,257 @@
import React, { useState } from 'react';
import {
View,
StyleSheet,
ScrollView,
Alert,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import {
TextInput,
Button,
Text,
SegmentedButtons,
Snackbar,
} from 'react-native-paper';
import { useRoute, useNavigation } from '@react-navigation/native';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { lineItemsApi, type UpdateLineItemEntry } from '../../lib/api/lineItems';
import { QUERY_KEYS } from '../../constants/queryKeys';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { ProposalsStackParamList } from '../../navigation/types';
type RouteProps = NativeStackScreenProps<
ProposalsStackParamList,
'LineItemEdit'
>['route'];
export function LineItemEditScreen() {
const route = useRoute<RouteProps>();
const navigation = useNavigation();
const qc = useQueryClient();
const {
proposalId,
lineItemId,
description: initDesc = '',
quantity: initQty = 1,
unit: initUnit = 'each',
unitPrice: initUnitPrice = null,
totalPrice: initTotal = 0,
pricingMode: initMode = 'TotalPrice',
source: initSource = 'Manual',
} = route.params;
const [description, setDescription] = useState(initDesc);
const [quantity, setQuantity] = useState(String(initQty));
const [unit, setUnit] = useState(initUnit);
const [unitPrice, setUnitPrice] = useState(
initUnitPrice != null ? String(initUnitPrice) : '',
);
const [totalPrice, setTotalPrice] = useState(String(initTotal));
const [pricingMode, setPricingMode] = useState(initMode);
const [snackbar, setSnackbar] = useState('');
const saveMutation = useMutation({
mutationFn: async () => {
const entry: UpdateLineItemEntry = {
id: lineItemId || null,
description: description.trim(),
quantity: parseFloat(quantity) || 1,
unit: unit.trim() || 'each',
unitPrice: unitPrice ? parseFloat(unitPrice) : null,
totalPrice: parseFloat(totalPrice) || 0,
pricingMode: pricingMode as UpdateLineItemEntry['pricingMode'],
sortOrder: 0,
source: initSource as UpdateLineItemEntry['source'],
};
const existing = await lineItemsApi.getAll(proposalId);
const updated = lineItemId
? existing.map((li) =>
li.id === lineItemId
? { ...entry, id: li.id, sortOrder: li.sortOrder }
: {
id: li.id,
description: li.description,
quantity: li.quantity,
unit: li.unit,
unitPrice: li.unitPrice,
totalPrice: li.totalPrice,
pricingMode: li.pricingMode,
sortOrder: li.sortOrder,
source: li.source,
},
)
: [
...existing.map((li) => ({
id: li.id,
description: li.description,
quantity: li.quantity,
unit: li.unit,
unitPrice: li.unitPrice,
totalPrice: li.totalPrice,
pricingMode: li.pricingMode,
sortOrder: li.sortOrder,
source: li.source,
})),
{ ...entry, sortOrder: existing.length },
];
return lineItemsApi.bulkUpdate(proposalId, updated);
},
onSuccess: () => {
qc.invalidateQueries({
queryKey: [QUERY_KEYS.proposalLineItems, proposalId],
});
setSnackbar('Line item saved');
setTimeout(() => navigation.goBack(), 800);
},
onError: (err: Error) => Alert.alert('Error', err.message),
});
const handleAutoCalc = () => {
const qty = parseFloat(quantity) || 0;
const up = parseFloat(unitPrice) || 0;
if (pricingMode === 'UnitPrice' && qty > 0 && up > 0) {
setTotalPrice((qty * up).toFixed(2));
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={styles.scroll}>
<TextInput
label="Description"
value={description}
onChangeText={setDescription}
mode="outlined"
multiline
numberOfLines={3}
style={styles.input}
/>
<View style={styles.row}>
<TextInput
label="Quantity"
value={quantity}
onChangeText={setQuantity}
onBlur={handleAutoCalc}
mode="outlined"
keyboardType="decimal-pad"
style={[styles.input, styles.half]}
/>
<TextInput
label="Unit"
value={unit}
onChangeText={setUnit}
mode="outlined"
placeholder="each, sq ft, hours..."
style={[styles.input, styles.half]}
/>
</View>
<Text variant="labelLarge" style={styles.label}>
Pricing Mode
</Text>
<SegmentedButtons
value={pricingMode}
onValueChange={setPricingMode}
buttons={[
{ value: 'UnitPrice', label: 'Unit Price' },
{ value: 'TotalPrice', label: 'Total' },
{ value: 'Both', label: 'Both' },
]}
style={styles.segmented}
/>
{(pricingMode === 'UnitPrice' || pricingMode === 'Both') && (
<TextInput
label="Unit Price ($)"
value={unitPrice}
onChangeText={setUnitPrice}
onBlur={handleAutoCalc}
mode="outlined"
keyboardType="decimal-pad"
style={styles.input}
/>
)}
<TextInput
label="Total Price ($)"
value={totalPrice}
onChangeText={setTotalPrice}
mode="outlined"
keyboardType="decimal-pad"
style={styles.input}
/>
<View style={styles.actions}>
<Button
mode="outlined"
onPress={() => navigation.goBack()}
style={styles.actionButton}
>
Cancel
</Button>
<Button
mode="contained"
onPress={() => saveMutation.mutate()}
loading={saveMutation.isPending}
disabled={!description.trim() || saveMutation.isPending}
style={styles.actionButton}
>
Save
</Button>
</View>
</ScrollView>
<Snackbar
visible={!!snackbar}
onDismiss={() => setSnackbar('')}
duration={2000}
>
{snackbar}
</Snackbar>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FAFAFA',
},
scroll: {
padding: 16,
paddingBottom: 40,
},
input: {
marginBottom: 12,
},
row: {
flexDirection: 'row',
gap: 12,
},
half: {
flex: 1,
},
label: {
marginBottom: 8,
color: '#424242',
},
segmented: {
marginBottom: 16,
},
actions: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
},
actionButton: {
flex: 1,
},
});

View file

@ -0,0 +1,153 @@
import React, { useState } from 'react';
import {
View,
StyleSheet,
FlatList,
RefreshControl,
} from 'react-native';
import { Searchbar, Chip, FAB } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSelector } from 'react-redux';
import { selectUser } from '../../store/slices/authSlice';
import { usePaginatedList } from '../../hooks/usePaginatedList';
import { proposalsApi, type ProposalListItem } from '../../lib/api/proposals';
import { ProposalCard } from '../../components/ProposalCard';
import { EmptyState } from '../../components/EmptyState';
import { PROPOSAL_STATUSES } from '../../constants';
import type { ProposalsStackParamList } from '../../navigation/types';
type Nav = NativeStackNavigationProp<ProposalsStackParamList>;
export function ProposalQueueScreen() {
const navigation = useNavigation<Nav>();
const user = useSelector(selectUser);
const isAdmin = user?.role === 'Admin' || user?.role === 'SysAdmin';
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const {
rows,
search,
setSearch,
loading,
reload,
} = usePaginatedList<ProposalListItem>(
(params) =>
proposalsApi.getAll({
search: params.search as string | undefined,
page: params.page as number | undefined,
pageSize: params.pageSize as number | undefined,
status: (params.status as string) || undefined,
}),
{ status: statusFilter },
);
const handlePress = (proposal: ProposalListItem) => {
if (isAdmin) {
navigation.navigate('ProposalWorkspace', { proposalId: proposal.id });
} else {
navigation.navigate('ProposalDetail', { proposalId: proposal.id });
}
};
return (
<View style={styles.container}>
<Searchbar
placeholder="Search proposals..."
onChangeText={setSearch}
value={search}
style={styles.search}
/>
<View style={styles.filters}>
<Chip
selected={!statusFilter}
onPress={() => setStatusFilter(null)}
compact
style={styles.chip}
>
All
</Chip>
{PROPOSAL_STATUSES.map((s) => (
<Chip
key={s}
selected={statusFilter === s}
onPress={() =>
setStatusFilter(statusFilter === s ? null : s)
}
compact
style={styles.chip}
>
{s === 'InReview' ? 'In Review' : s}
</Chip>
))}
</View>
<FlatList
data={rows}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<ProposalCard proposal={item} onPress={() => handlePress(item)} />
)}
refreshControl={
<RefreshControl refreshing={loading} onRefresh={reload} />
}
contentContainerStyle={rows.length === 0 ? styles.empty : styles.list}
ListEmptyComponent={
!loading ? (
<EmptyState
icon="file-search-outline"
title="No proposals found"
message={
search
? 'Try a different search term'
: 'No proposals match the current filter'
}
/>
) : null
}
/>
{!isAdmin && (
<FAB
icon="plus"
style={styles.fab}
onPress={() => navigation.navigate('NewProposal')}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FAFAFA',
},
search: {
margin: 12,
elevation: 1,
},
filters: {
flexDirection: 'row',
paddingHorizontal: 12,
paddingBottom: 8,
gap: 6,
flexWrap: 'wrap',
},
chip: {
marginBottom: 4,
},
list: {
paddingBottom: 80,
},
empty: {
flex: 1,
},
fab: {
position: 'absolute',
right: 16,
bottom: 16,
backgroundColor: '#1565C0',
},
});

View file

@ -0,0 +1,371 @@
import React, { useState } from 'react';
import {
View,
StyleSheet,
ScrollView,
RefreshControl,
Alert,
} from 'react-native';
import {
Card,
Text,
Button,
Divider,
Snackbar,
TextInput,
Portal,
Dialog,
} from 'react-native-paper';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useRoute, useNavigation } from '@react-navigation/native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Share from 'react-native-share';
import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
import { StatusChip } from '../../components/StatusChip';
import { PriorityChip } from '../../components/PriorityChip';
import { LineItemRow } from '../../components/LineItemRow';
import { proposalsApi } from '../../lib/api/proposals';
import { lineItemsApi } from '../../lib/api/lineItems';
import { adminApi } from '../../lib/api/admin';
import { QUERY_KEYS } from '../../constants/queryKeys';
import type { ProposalsStackParamList } from '../../navigation/types';
type RouteProps = NativeStackScreenProps<
ProposalsStackParamList,
'ProposalWorkspace'
>['route'];
export function ProposalWorkspaceScreen() {
const route = useRoute<RouteProps>();
const navigation = useNavigation();
const qc = useQueryClient();
const { proposalId } = route.params;
const [snackbar, setSnackbar] = useState('');
const [approveDialogVisible, setApproveDialogVisible] = useState(false);
const [scopeInput, setScopeInput] = useState('');
const [showScopeEdit, setShowScopeEdit] = useState(false);
const proposalQuery = useQuery({
queryKey: [QUERY_KEYS.proposal, proposalId],
queryFn: () => proposalsApi.getById(proposalId),
});
const lineItemsQuery = useQuery({
queryKey: [QUERY_KEYS.proposalLineItems, proposalId],
queryFn: () => lineItemsApi.getAll(proposalId),
});
const proposal = proposalQuery.data;
const lineItems = lineItemsQuery.data ?? [];
const total = lineItems.reduce((sum, li) => sum + li.totalPrice, 0);
const invalidate = () => {
qc.invalidateQueries({ queryKey: [QUERY_KEYS.proposal, proposalId] });
qc.invalidateQueries({
queryKey: [QUERY_KEYS.proposalLineItems, proposalId],
});
};
const approveMutation = useMutation({
mutationFn: () => adminApi.approveProposal(proposalId),
onSuccess: () => {
ReactNativeHapticFeedback.trigger('notificationSuccess');
setSnackbar('Proposal approved');
invalidate();
},
onError: (err: Error) => Alert.alert('Error', err.message),
});
const sendMutation = useMutation({
mutationFn: () => adminApi.sendProposal(proposalId),
onSuccess: () => {
setSnackbar('Marked as sent');
invalidate();
},
onError: (err: Error) => Alert.alert('Error', err.message),
});
const regenerateMutation = useMutation({
mutationFn: async () => {
if (scopeInput.trim()) {
await adminApi.updateProposal(proposalId, {
refinedScope: scopeInput.trim(),
});
}
await adminApi.generateSuggestions(proposalId);
},
onSuccess: () => {
setShowScopeEdit(false);
setSnackbar('AI suggestions regenerating...');
setTimeout(invalidate, 5000);
},
onError: (err: Error) => Alert.alert('Error', err.message),
});
const handleDownloadPdf = async () => {
const result = await adminApi.getPdf(proposalId);
if (result?.downloadUrl) {
await Share.open({ url: result.downloadUrl, type: 'application/pdf' });
} else {
Alert.alert('PDF not ready', 'The PDF has not been generated yet.');
}
};
const refreshing =
proposalQuery.isRefetching || lineItemsQuery.isRefetching;
if (!proposal) return null;
const isLocked =
proposal.status === 'Approved' || proposal.status === 'Sent';
return (
<View style={styles.container}>
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => {
proposalQuery.refetch();
lineItemsQuery.refetch();
}}
/>
}
>
<Card style={styles.card} mode="elevated">
<Card.Content>
<View style={styles.headerRow}>
<Text variant="headlineSmall" style={styles.number}>
{proposal.proposalNumber}
</Text>
<StatusChip status={proposal.status} />
</View>
<Text variant="bodyLarge">{proposal.customerName}</Text>
<View style={styles.metaRow}>
<Text variant="bodySmall" style={styles.meta}>
WO# {proposal.workOrderNumber}
</Text>
<PriorityChip priority={proposal.priority} />
</View>
</Card.Content>
</Card>
<Card style={styles.card} mode="elevated">
<Card.Content>
<View style={styles.sectionHeader}>
<Text variant="titleMedium" style={styles.sectionTitle}>
Scope of Work
</Text>
{!isLocked && (
<Button
compact
onPress={() => {
setScopeInput(
proposal.refinedScope || proposal.scopeOfWork,
);
setShowScopeEdit(!showScopeEdit);
}}
>
Edit
</Button>
)}
</View>
{showScopeEdit ? (
<TextInput
value={scopeInput}
onChangeText={setScopeInput}
mode="outlined"
multiline
numberOfLines={4}
style={styles.scopeInput}
/>
) : (
<Text variant="bodyMedium">
{proposal.refinedScope || proposal.scopeOfWork}
</Text>
)}
</Card.Content>
</Card>
<Card style={styles.card} mode="elevated">
<Card.Content style={styles.lineItemsHeader}>
<Text variant="titleMedium" style={styles.sectionTitle}>
Line Items ({lineItems.length})
</Text>
<Text variant="titleMedium" style={styles.totalBadge}>
${total.toLocaleString()}
</Text>
</Card.Content>
<Divider />
{lineItems.map((item, i) => (
<LineItemRow
key={item.id}
item={item}
index={i}
onPress={
!isLocked
? () =>
navigation.navigate('LineItemEdit' as never, {
proposalId,
lineItemId: item.id,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unitPrice: item.unitPrice,
totalPrice: item.totalPrice,
pricingMode: item.pricingMode,
source: item.source,
} as never)
: undefined
}
/>
))}
</Card>
<View style={styles.actions}>
{!isLocked && (
<>
<Button
mode="outlined"
icon="robot"
onPress={() => regenerateMutation.mutate()}
loading={regenerateMutation.isPending}
disabled={regenerateMutation.isPending}
>
{showScopeEdit ? 'Regenerate with Edits' : 'Regenerate AI'}
</Button>
<Button
mode="contained"
icon="check-circle"
onPress={() => setApproveDialogVisible(true)}
disabled={lineItems.length === 0 || total <= 0}
style={styles.approveButton}
>
Approve
</Button>
</>
)}
{proposal.status === 'Approved' && (
<>
<Button mode="contained" icon="download" onPress={handleDownloadPdf}>
Download PDF
</Button>
<Button
mode="outlined"
icon="send"
onPress={() => sendMutation.mutate()}
loading={sendMutation.isPending}
>
Mark as Sent
</Button>
</>
)}
</View>
<View style={styles.bottomPadding} />
</ScrollView>
<Portal>
<Dialog
visible={approveDialogVisible}
onDismiss={() => setApproveDialogVisible(false)}
>
<Dialog.Title>Approve Proposal</Dialog.Title>
<Dialog.Content>
<Text variant="bodyMedium">
Approve {proposal.proposalNumber} for ${total.toLocaleString()}{' '}
with {lineItems.length} line items?
</Text>
</Dialog.Content>
<Dialog.Actions>
<Button onPress={() => setApproveDialogVisible(false)}>
Cancel
</Button>
<Button
onPress={() => {
setApproveDialogVisible(false);
approveMutation.mutate();
}}
>
Approve
</Button>
</Dialog.Actions>
</Dialog>
</Portal>
<Snackbar
visible={!!snackbar}
onDismiss={() => setSnackbar('')}
duration={3000}
>
{snackbar}
</Snackbar>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FAFAFA',
},
scroll: {
paddingBottom: 40,
},
card: {
marginHorizontal: 16,
marginTop: 12,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
number: {
fontWeight: '700',
},
metaRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 8,
},
meta: {
color: '#757575',
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
sectionTitle: {
fontWeight: '600',
marginBottom: 8,
},
scopeInput: {
marginTop: 8,
},
lineItemsHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
totalBadge: {
color: '#2E7D32',
fontWeight: '700',
},
actions: {
paddingHorizontal: 16,
paddingTop: 16,
gap: 12,
},
approveButton: {
backgroundColor: '#2E7D32',
},
bottomPadding: {
height: 24,
},
});

View file

@ -0,0 +1,115 @@
import React, { useState } from 'react';
import { View, StyleSheet } from 'react-native';
import { Button, Text, Surface } from 'react-native-paper';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useDispatch } from 'react-redux';
import { authApi } from '../../lib/api/auth';
import { setUser, setError } from '../../store/slices/authSlice';
export function LoginScreen() {
const dispatch = useDispatch();
const [loading, setLoading] = useState(false);
const handleLogin = async () => {
setLoading(true);
try {
const user = await authApi.login();
dispatch(setUser(user));
} catch (err) {
const message =
err instanceof Error ? err.message : 'Login failed. Please try again.';
dispatch(setError(message));
} finally {
setLoading(false);
}
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<Surface style={styles.logoContainer} elevation={0}>
<Text variant="headlineLarge" style={styles.brand}>
Sea Haven
</Text>
<Text variant="titleMedium" style={styles.brandSub}>
Industries
</Text>
</Surface>
<Text variant="headlineSmall" style={styles.title}>
Proposal System
</Text>
<Text variant="bodyLarge" style={styles.subtitle}>
Submit and manage proposals from anywhere
</Text>
<View style={styles.actions}>
<Button
mode="contained"
icon="google"
onPress={handleLogin}
loading={loading}
disabled={loading}
contentStyle={styles.buttonContent}
style={styles.button}
>
Sign in with Google
</Button>
</View>
<Text variant="bodySmall" style={styles.footer}>
Use your Sea Haven Workspace account
</Text>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FAFAFA',
},
content: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 32,
},
logoContainer: {
alignItems: 'center',
marginBottom: 32,
backgroundColor: 'transparent',
},
brand: {
fontWeight: '800',
color: '#1565C0',
},
brandSub: {
color: '#757575',
marginTop: -4,
},
title: {
fontWeight: '700',
color: '#212121',
marginBottom: 8,
},
subtitle: {
color: '#757575',
textAlign: 'center',
marginBottom: 48,
},
actions: {
width: '100%',
},
buttonContent: {
paddingVertical: 8,
},
button: {
borderRadius: 8,
},
footer: {
color: '#9E9E9E',
marginTop: 24,
},
});

View file

@ -0,0 +1,175 @@
import React from 'react';
import {
View,
StyleSheet,
ScrollView,
RefreshControl,
} from 'react-native';
import { Card, Text, FAB } from 'react-native-paper';
import { useQuery } from '@tanstack/react-query';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAuth } from '../../hooks/useAuth';
import { proposalsApi } from '../../lib/api/proposals';
import { ProposalCard } from '../../components/ProposalCard';
import { EmptyState } from '../../components/EmptyState';
import { QUERY_KEYS } from '../../constants/queryKeys';
import type { ProposalsStackParamList } from '../../navigation/types';
type Nav = NativeStackNavigationProp<ProposalsStackParamList>;
export function DashboardScreen() {
const { user } = useAuth();
const navigation = useNavigation<Nav>();
const statsQuery = useQuery({
queryKey: [QUERY_KEYS.dashboard, 'stats'],
queryFn: () => proposalsApi.getStats(),
});
const proposalsQuery = useQuery({
queryKey: [QUERY_KEYS.proposals, 'recent'],
queryFn: () => proposalsApi.getAll({ pageSize: 5 }),
});
const refreshing = statsQuery.isRefetching || proposalsQuery.isRefetching;
const onRefresh = () => {
statsQuery.refetch();
proposalsQuery.refetch();
};
const stats = statsQuery.data;
const proposals = proposalsQuery.data?.items ?? [];
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
<Text variant="headlineSmall" style={styles.welcome}>
Welcome, {user?.displayName?.split(' ')[0] || 'there'}
</Text>
{stats && (
<View style={styles.statsRow}>
<StatCard label="Total" value={stats.totalCount} color="#1565C0" />
<StatCard
label="In Review"
value={stats.inReviewCount}
color="#E65100"
/>
<StatCard
label="Approved"
value={stats.approvedCount}
color="#2E7D32"
/>
<StatCard label="Sent" value={stats.sentCount} color="#7B1FA2" />
</View>
)}
<Text variant="titleMedium" style={styles.sectionTitle}>
Recent Proposals
</Text>
{proposals.length === 0 && !proposalsQuery.isLoading ? (
<EmptyState
icon="file-document-outline"
title="No proposals yet"
message="Tap + to submit your first proposal"
/>
) : (
proposals.map((p) => (
<ProposalCard
key={p.id}
proposal={p}
onPress={() =>
navigation.navigate('ProposalDetail', { proposalId: p.id })
}
/>
))
)}
</ScrollView>
<FAB
icon="plus"
style={styles.fab}
onPress={() => navigation.navigate('NewProposal')}
/>
</SafeAreaView>
);
}
function StatCard({
label,
value,
color,
}: {
label: string;
value: number;
color: string;
}) {
return (
<Card style={styles.statCard} mode="elevated">
<Card.Content style={styles.statContent}>
<Text variant="headlineMedium" style={[styles.statValue, { color }]}>
{value}
</Text>
<Text variant="bodySmall" style={styles.statLabel}>
{label}
</Text>
</Card.Content>
</Card>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FAFAFA',
},
scroll: {
paddingBottom: 80,
},
welcome: {
fontWeight: '700',
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 12,
},
statsRow: {
flexDirection: 'row',
paddingHorizontal: 12,
gap: 8,
marginBottom: 16,
},
statCard: {
flex: 1,
},
statContent: {
alignItems: 'center',
paddingVertical: 8,
},
statValue: {
fontWeight: '700',
},
statLabel: {
color: '#757575',
marginTop: 2,
},
sectionTitle: {
fontWeight: '600',
paddingHorizontal: 16,
paddingVertical: 8,
},
fab: {
position: 'absolute',
right: 16,
bottom: 16,
backgroundColor: '#1565C0',
},
});

View file

@ -0,0 +1,261 @@
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,
},
});

View file

@ -0,0 +1,246 @@
import React from 'react';
import { View, StyleSheet, ScrollView, RefreshControl } from 'react-native';
import { Card, Text, Divider } from 'react-native-paper';
import { useQuery } from '@tanstack/react-query';
import { useRoute } from '@react-navigation/native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { StatusChip } from '../../components/StatusChip';
import { PriorityChip } from '../../components/PriorityChip';
import { LineItemRow } from '../../components/LineItemRow';
import { EmptyState } from '../../components/EmptyState';
import { proposalsApi } from '../../lib/api/proposals';
import { lineItemsApi } from '../../lib/api/lineItems';
import { QUERY_KEYS } from '../../constants/queryKeys';
import type { ProposalsStackParamList } from '../../navigation/types';
type RouteProps = NativeStackScreenProps<
ProposalsStackParamList,
'ProposalDetail'
>['route'];
export function ProposalDetailScreen() {
const route = useRoute<RouteProps>();
const { proposalId } = route.params;
const proposalQuery = useQuery({
queryKey: [QUERY_KEYS.proposal, proposalId],
queryFn: () => proposalsApi.getById(proposalId),
});
const lineItemsQuery = useQuery({
queryKey: [QUERY_KEYS.proposalLineItems, proposalId],
queryFn: () => lineItemsApi.getAll(proposalId),
});
const proposal = proposalQuery.data;
const lineItems = lineItemsQuery.data ?? [];
const refreshing =
proposalQuery.isRefetching || lineItemsQuery.isRefetching;
const onRefresh = () => {
proposalQuery.refetch();
lineItemsQuery.refetch();
};
if (!proposal && proposalQuery.isLoading) {
return (
<EmptyState
icon="loading"
title="Loading..."
message="Fetching proposal details"
/>
);
}
if (!proposal) {
return (
<EmptyState
icon="alert-circle-outline"
title="Not Found"
message="Could not load proposal"
/>
);
}
const total = lineItems.reduce((sum, li) => sum + li.totalPrice, 0);
return (
<ScrollView
style={styles.container}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
<Card style={styles.card} mode="elevated">
<Card.Content>
<View style={styles.headerRow}>
<Text variant="headlineSmall" style={styles.number}>
{proposal.proposalNumber}
</Text>
<StatusChip status={proposal.status} />
</View>
<View style={styles.metaRow}>
<Text variant="bodyMedium" style={styles.metaLabel}>
WO# {proposal.workOrderNumber}
</Text>
<PriorityChip priority={proposal.priority} />
</View>
</Card.Content>
</Card>
<Card style={styles.card} mode="elevated">
<Card.Content>
<Text variant="titleMedium" style={styles.sectionTitle}>
Customer
</Text>
<Text variant="bodyLarge">{proposal.customerName}</Text>
<Text variant="bodyMedium" style={styles.address}>
{proposal.customerAddress}
</Text>
</Card.Content>
</Card>
<Card style={styles.card} mode="elevated">
<Card.Content>
<Text variant="titleMedium" style={styles.sectionTitle}>
Scope of Work
</Text>
<Text variant="bodyMedium">
{proposal.refinedScope || proposal.scopeOfWork}
</Text>
</Card.Content>
</Card>
{lineItems.length > 0 && (
<Card style={styles.card} mode="elevated">
<Card.Content style={styles.lineItemsHeader}>
<Text variant="titleMedium" style={styles.sectionTitle}>
Line Items
</Text>
<Text variant="titleMedium" style={styles.totalBadge}>
${total.toLocaleString()}
</Text>
</Card.Content>
<Divider />
{lineItems.map((item, i) => (
<LineItemRow key={item.id} item={item} index={i} />
))}
</Card>
)}
<Card style={styles.card} mode="elevated">
<Card.Content>
<Text variant="titleMedium" style={styles.sectionTitle}>
Timeline
</Text>
<TimelineEntry
label="Submitted"
date={proposal.submittedAt}
name={proposal.submittedByName}
/>
{proposal.approvedAt && (
<TimelineEntry label="Approved" date={proposal.approvedAt} />
)}
{proposal.sentAt && (
<TimelineEntry label="Sent" date={proposal.sentAt} />
)}
</Card.Content>
</Card>
<View style={styles.bottomPadding} />
</ScrollView>
);
}
function TimelineEntry({
label,
date,
name,
}: {
label: string;
date: string;
name?: string | null;
}) {
return (
<View style={styles.timelineEntry}>
<View style={styles.timelineDot} />
<View>
<Text variant="bodyMedium" style={styles.timelineLabel}>
{label}
</Text>
<Text variant="bodySmall" style={styles.timelineDate}>
{new Date(date).toLocaleString()}
{name ? ` by ${name}` : ''}
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FAFAFA',
},
card: {
marginHorizontal: 16,
marginTop: 12,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
number: {
fontWeight: '700',
},
metaRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
metaLabel: {
color: '#757575',
},
sectionTitle: {
fontWeight: '600',
marginBottom: 8,
},
address: {
color: '#757575',
marginTop: 4,
},
lineItemsHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
totalBadge: {
color: '#2E7D32',
fontWeight: '700',
},
timelineEntry: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
marginBottom: 12,
},
timelineDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: '#1565C0',
marginTop: 5,
},
timelineLabel: {
fontWeight: '600',
},
timelineDate: {
color: '#757575',
},
bottomPadding: {
height: 24,
},
});