mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 07:43:14 +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