From 9aef2a3891770d2ce97d2768062401d3354ef68b Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Sun, 17 May 2026 15:09:43 -0400 Subject: [PATCH] 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. --- mobile/src/components/EmptyState.tsx | 42 ++ mobile/src/components/FilePickerButton.tsx | 130 ++++++ mobile/src/components/LineItemRow.tsx | 95 +++++ mobile/src/components/PriorityChip.tsx | 21 + mobile/src/components/ProposalCard.tsx | 80 ++++ mobile/src/components/StatusChip.tsx | 29 ++ mobile/src/navigation/RootNavigator.tsx | 160 ++++++++ mobile/src/navigation/types.ts | 51 +++ mobile/src/screens/SettingsScreen.tsx | 92 +++++ .../screens/admin/AdminDashboardScreen.tsx | 191 +++++++++ .../src/screens/admin/LineItemEditScreen.tsx | 257 ++++++++++++ .../src/screens/admin/ProposalQueueScreen.tsx | 153 ++++++++ .../screens/admin/ProposalWorkspaceScreen.tsx | 371 ++++++++++++++++++ mobile/src/screens/auth/LoginScreen.tsx | 115 ++++++ .../screens/dispatcher/DashboardScreen.tsx | 175 +++++++++ .../screens/dispatcher/NewProposalScreen.tsx | 261 ++++++++++++ .../dispatcher/ProposalDetailScreen.tsx | 246 ++++++++++++ 17 files changed, 2469 insertions(+) create mode 100644 mobile/src/components/EmptyState.tsx create mode 100644 mobile/src/components/FilePickerButton.tsx create mode 100644 mobile/src/components/LineItemRow.tsx create mode 100644 mobile/src/components/PriorityChip.tsx create mode 100644 mobile/src/components/ProposalCard.tsx create mode 100644 mobile/src/components/StatusChip.tsx create mode 100644 mobile/src/navigation/RootNavigator.tsx create mode 100644 mobile/src/navigation/types.ts create mode 100644 mobile/src/screens/SettingsScreen.tsx create mode 100644 mobile/src/screens/admin/AdminDashboardScreen.tsx create mode 100644 mobile/src/screens/admin/LineItemEditScreen.tsx create mode 100644 mobile/src/screens/admin/ProposalQueueScreen.tsx create mode 100644 mobile/src/screens/admin/ProposalWorkspaceScreen.tsx create mode 100644 mobile/src/screens/auth/LoginScreen.tsx create mode 100644 mobile/src/screens/dispatcher/DashboardScreen.tsx create mode 100644 mobile/src/screens/dispatcher/NewProposalScreen.tsx create mode 100644 mobile/src/screens/dispatcher/ProposalDetailScreen.tsx diff --git a/mobile/src/components/EmptyState.tsx b/mobile/src/components/EmptyState.tsx new file mode 100644 index 0000000..2c7b6bd --- /dev/null +++ b/mobile/src/components/EmptyState.tsx @@ -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 ( + + + + {title} + + + {message} + + + ); +} + +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', + }, +}); diff --git a/mobile/src/components/FilePickerButton.tsx b/mobile/src/components/FilePickerButton.tsx new file mode 100644 index 0000000..fba696a --- /dev/null +++ b/mobile/src/components/FilePickerButton.tsx @@ -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(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 ( + + + + + + {uploading && } + {fileName && !uploading && ( + + {fileName} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + gap: 8, + }, + buttons: { + flexDirection: 'row', + gap: 12, + }, + button: { + flex: 1, + }, + progress: { + marginTop: 4, + }, + fileName: { + color: '#2E7D32', + }, +}); diff --git a/mobile/src/components/LineItemRow.tsx b/mobile/src/components/LineItemRow.tsx new file mode 100644 index 0000000..b919dfc --- /dev/null +++ b/mobile/src/components/LineItemRow.tsx @@ -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 = { + AI: '#1565C0', + Vendor: '#E65100', + Manual: '#2E7D32', + Historical: '#7B1FA2', +}; + +export function LineItemRow({ item, index, onPress }: Props) { + const Wrapper = onPress ? Pressable : View; + return ( + + + + #{index + 1} + + + {item.source} + + + + {item.description} + + + + {item.quantity} {item.unit} + + {item.unitPrice != null && ( + + @ ${item.unitPrice.toFixed(2)} + + )} + + ${item.totalPrice.toLocaleString()} + + + + ); +} + +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', + }, +}); diff --git a/mobile/src/components/PriorityChip.tsx b/mobile/src/components/PriorityChip.tsx new file mode 100644 index 0000000..6b0ec63 --- /dev/null +++ b/mobile/src/components/PriorityChip.tsx @@ -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 ( + + {priority} + + ); +} diff --git a/mobile/src/components/ProposalCard.tsx b/mobile/src/components/ProposalCard.tsx new file mode 100644 index 0000000..adf91a9 --- /dev/null +++ b/mobile/src/components/ProposalCard.tsx @@ -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 ( + + + + + {proposal.proposalNumber} + + + + + {proposal.customerName} + + + + WO# {proposal.workOrderNumber} + + + + {proposal.totalBidAmount > 0 && ( + + ${proposal.totalBidAmount.toLocaleString()} + + )} + + {new Date(proposal.submittedAt).toLocaleDateString()} + + + + ); +} + +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, + }, +}); diff --git a/mobile/src/components/StatusChip.tsx b/mobile/src/components/StatusChip.tsx new file mode 100644 index 0000000..e2316c4 --- /dev/null +++ b/mobile/src/components/StatusChip.tsx @@ -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 = { + Draft: 'Draft', + InReview: 'In Review', + Approved: 'Approved', + Sent: 'Sent', + Revised: 'Revised', +}; + +export function StatusChip({ status }: Props) { + const color = statusColors[status] || '#9E9E9E'; + return ( + + {statusLabels[status] || status} + + ); +} diff --git a/mobile/src/navigation/RootNavigator.tsx b/mobile/src/navigation/RootNavigator.tsx new file mode 100644 index 0000000..646f5f5 --- /dev/null +++ b/mobile/src/navigation/RootNavigator.tsx @@ -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(); +const Tab = createBottomTabNavigator(); +const ProposalsStack = createNativeStackNavigator(); + +function ProposalsNavigator() { + const user = useSelector(selectUser); + const isAdmin = user?.role === 'Admin' || user?.role === 'SysAdmin'; + + return ( + + {isAdmin ? ( + <> + + + + + ) : ( + <> + + + + + )} + + ); +} + +function DashboardRouter() { + const user = useSelector(selectUser); + const isAdmin = user?.role === 'Admin' || user?.role === 'SysAdmin'; + return isAdmin ? : ; +} + +function MainTabs() { + return ( + ({ + headerShown: route.name === 'DashboardTab', + tabBarIcon: ({ color, size }) => { + const icons: Record = { + DashboardTab: 'view-dashboard', + ProposalsTab: 'file-document-outline', + SettingsTab: 'cog-outline', + }; + return ( + + ); + }, + tabBarActiveTintColor: '#1565C0', + tabBarInactiveTintColor: '#757575', + })} + > + + + + + ); +} + +export function RootNavigator() { + const isAuthenticated = useSelector(selectIsAuthenticated); + const loading = useSelector(selectAuthLoading); + + if (loading) { + return ( + + + + ); + } + + return ( + + + {isAuthenticated ? ( + + ) : ( + + )} + + + ); +} + +const styles = StyleSheet.create({ + loading: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#FAFAFA', + }, +}); diff --git a/mobile/src/navigation/types.ts b/mobile/src/navigation/types.ts new file mode 100644 index 0000000..9d713ad --- /dev/null +++ b/mobile/src/navigation/types.ts @@ -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 = + NativeStackScreenProps; + +export type AuthScreenProps = + NativeStackScreenProps; + +export type MainTabScreenProps = + BottomTabScreenProps; + +export type ProposalsScreenProps = + CompositeScreenProps< + NativeStackScreenProps, + BottomTabScreenProps + >; diff --git a/mobile/src/screens/SettingsScreen.tsx b/mobile/src/screens/SettingsScreen.tsx new file mode 100644 index 0000000..eea9c95 --- /dev/null +++ b/mobile/src/screens/SettingsScreen.tsx @@ -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 ( + + + + + + {user?.displayName} + + + {user?.email} + + + + {user?.role} + + + + + + + } + /> + + } + /> + + + + + + ); +} + +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', + }, +}); diff --git a/mobile/src/screens/admin/AdminDashboardScreen.tsx b/mobile/src/screens/admin/AdminDashboardScreen.tsx new file mode 100644 index 0000000..71a6388 --- /dev/null +++ b/mobile/src/screens/admin/AdminDashboardScreen.tsx @@ -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; + +export function AdminDashboardScreen() { + const { user } = useAuth(); + const navigation = useNavigation