mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-04 16:02:03 +00:00
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:
parent
47c3b8e5eb
commit
9aef2a3891
17 changed files with 2469 additions and 0 deletions
42
mobile/src/components/EmptyState.tsx
Normal file
42
mobile/src/components/EmptyState.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
130
mobile/src/components/FilePickerButton.tsx
Normal file
130
mobile/src/components/FilePickerButton.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
95
mobile/src/components/LineItemRow.tsx
Normal file
95
mobile/src/components/LineItemRow.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
21
mobile/src/components/PriorityChip.tsx
Normal file
21
mobile/src/components/PriorityChip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
mobile/src/components/ProposalCard.tsx
Normal file
80
mobile/src/components/ProposalCard.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
29
mobile/src/components/StatusChip.tsx
Normal file
29
mobile/src/components/StatusChip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
mobile/src/navigation/RootNavigator.tsx
Normal file
160
mobile/src/navigation/RootNavigator.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
51
mobile/src/navigation/types.ts
Normal file
51
mobile/src/navigation/types.ts
Normal 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>
|
||||||
|
>;
|
||||||
92
mobile/src/screens/SettingsScreen.tsx
Normal file
92
mobile/src/screens/SettingsScreen.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
191
mobile/src/screens/admin/AdminDashboardScreen.tsx
Normal file
191
mobile/src/screens/admin/AdminDashboardScreen.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
257
mobile/src/screens/admin/LineItemEditScreen.tsx
Normal file
257
mobile/src/screens/admin/LineItemEditScreen.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
153
mobile/src/screens/admin/ProposalQueueScreen.tsx
Normal file
153
mobile/src/screens/admin/ProposalQueueScreen.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
371
mobile/src/screens/admin/ProposalWorkspaceScreen.tsx
Normal file
371
mobile/src/screens/admin/ProposalWorkspaceScreen.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
115
mobile/src/screens/auth/LoginScreen.tsx
Normal file
115
mobile/src/screens/auth/LoginScreen.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
175
mobile/src/screens/dispatcher/DashboardScreen.tsx
Normal file
175
mobile/src/screens/dispatcher/DashboardScreen.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
261
mobile/src/screens/dispatcher/NewProposalScreen.tsx
Normal file
261
mobile/src/screens/dispatcher/NewProposalScreen.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
246
mobile/src/screens/dispatcher/ProposalDetailScreen.tsx
Normal file
246
mobile/src/screens/dispatcher/ProposalDetailScreen.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue