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