mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-04 12:32:02 +00:00
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.
246 lines
6.3 KiB
TypeScript
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,
|
|
},
|
|
});
|