proposal-system/mobile/src/screens/dispatcher/ProposalDetailScreen.tsx
Adam Moussa 9aef2a3891 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.
2026-05-17 15:09:43 -04:00

246 lines
6.3 KiB
TypeScript

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,
},
});