@burdenoff/microfe-movethewheels 2026.1101.1 → 2026.1101.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,460 +1,546 @@
1
1
  /**
2
- * Delivery Capture Page — driver-facing electronic proof-of-delivery (ePOD)
3
- * capture flow (BOFF-4399).
2
+ * My Deliveries — the driver-facing mobile task list (BOFF-3991), built on
3
+ * the electronic proof-of-delivery capture core from BOFF-4399.
4
4
  *
5
- * Lists the calling driver's own delivery jobs still awaiting capture
6
- * (`myPendingEpodDeliveryJobs`, Type C self-list — no other driver's jobs are
7
- * ever visible here) and lets them capture: recipient name, signature
8
- * (drawn or typed), an optional delivery photo (uploaded via the platform
9
- * files primitive), device GPS if available, and notes. Submitting calls
10
- * `captureEpod`, which the backend owner-verifies against the assigned
11
- * driver and marks the job DELIVERED.
5
+ * Shows the calling driver's stops for a day (`myDeliveryDay`, a Type C
6
+ * self-list: no other driver's jobs are ever visible here) in driving
7
+ * sequence with progress, the next stop, turn-by-turn hand-off to the
8
+ * device's maps app (one stop, or the remaining stops as a route), a stop
9
+ * detail screen with recipient contact and delivery instructions, and the
10
+ * ePOD capture form. The layout is single-column and touch-sized so it works
11
+ * as the driver surface inside the Capacitor mobile build and on a phone
12
+ * browser.
12
13
  *
13
- * Deferred (see BOFF-4399 follow-up notes): barcode/RFID scan-based capture,
14
- * multi-language delivery instructions, and the full standalone mobile
15
- * driver app (BOFF-3991) — this page is the capture core, reachable from the
16
- * existing web MFE.
14
+ * Offline: the last loaded day stays on screen and a banner explains that
15
+ * capture is paused. Capture is deliberately not queued offline — it needs
16
+ * the live linked shipment status and an acknowledged server write
17
+ * (see EpodCaptureForm).
17
18
  */
18
- import { useEffect, useRef, useState } from 'react';
19
+ import { useEffect, useMemo, useRef, useState } from 'react';
19
20
  import {
21
+ AlertTriangle,
22
+ ArrowLeft,
23
+ CalendarDays,
20
24
  CheckCircle2,
21
- Loader2,
22
- MapPin,
23
- Package,
24
- PenLine,
25
- Truck,
26
- Type as TypeIcon,
27
- Upload,
28
25
  ChevronLeft,
29
26
  ChevronRight,
27
+ Clock,
28
+ Navigation,
29
+ Package,
30
+ Phone,
31
+ RefreshCw,
32
+ Route as RouteIcon,
33
+ Truck,
34
+ WifiOff,
30
35
  } from 'lucide-react';
31
36
  import { PageLayout } from '../components/PageLayout';
32
- import {
33
- Button,
34
- Card,
35
- CardContent,
36
- CardHeader,
37
- CardTitle,
38
- Input,
39
- Label,
40
- Textarea,
41
- } from '../components/ui';
42
- import { SignaturePad, type SignaturePadHandle } from '../components/SignaturePad';
43
- import { StatusBadge, PriorityBadge } from '../components/StatusBadge';
44
- import { mapOrderPriority, mapOrderStatus } from '../hooks/useOrders';
37
+ import { Badge, Button, Card, CardContent, Input, Progress } from '../components/ui';
38
+ import { PriorityBadge } from '../components/StatusBadge';
39
+ import { EpodCaptureForm } from '../components/EpodCaptureForm';
45
40
  import { FormError } from '../components/FormError';
46
41
  import { Notice, useNotice } from '../components/Notice';
42
+ import { mapOrderPriority } from '../hooks/useOrders';
43
+ import type { PendingEpodDeliveryJob } from '../hooks/useEpod';
44
+ import {
45
+ useMyDeliveryDay,
46
+ useOnlineStatus,
47
+ type DriverStop,
48
+ type DriverStopState,
49
+ } from '../hooks/useDriverDay';
47
50
  import {
48
- useCaptureEpod,
49
- useEpodPhotoUpload,
50
- useMyPendingEpodDeliveryJobs,
51
- type PendingEpodDeliveryJob,
52
- } from '../hooks/useEpod';
53
- import { formatAddress, formatDate } from '../utils/formatters';
54
- import type { SignatureType } from '../types';
55
- import { useShipmentStatusQuery } from '../hooks/useShipmentStatusAuthority';
56
- import { StatusAuthorityOwnedDeliveryDocument } from '../../generated/shipment-status-authority-operations';
57
- import type { StatusAuthorityOwnedDeliveryQuery } from '../../generated/shipment-status-authority-operations';
51
+ localDateString,
52
+ phoneHref,
53
+ routeNavigationUrl,
54
+ shiftLocalDate,
55
+ stopNavigationUrl,
56
+ } from '../utils/driverNavigation';
57
+ import { formatAddress, formatDate, formatTime } from '../utils/formatters';
58
58
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
59
59
 
60
- /** Resolves the device's current position, if the browser grants it. Never blocks submission. */
61
- function getCurrentPosition(): Promise<GeolocationPosition | null> {
62
- return new Promise((resolve) => {
63
- if (!navigator.geolocation) {
64
- resolve(null);
65
- return;
66
- }
67
- navigator.geolocation.getCurrentPosition(
68
- (position) => resolve(position),
69
- () => resolve(null),
70
- { enableHighAccuracy: true, timeout: 8000, maximumAge: 30000 }
71
- );
72
- });
73
- }
74
-
75
- function CaptureForm({
76
- job,
77
- onCaptured,
78
- onCancel,
79
- }: {
80
- job: PendingEpodDeliveryJob;
81
- onCaptured: (deliveryJobId: string) => void;
82
- onCancel: () => void;
83
- }) {
84
- const { captureEpod, isSubmitting, error: captureError, captureFeedback } = useCaptureEpod();
85
- const [statusRefresh, setStatusRefresh] = useState(0);
86
- const ownedStatus = useShipmentStatusQuery<StatusAuthorityOwnedDeliveryQuery>(
87
- StatusAuthorityOwnedDeliveryDocument,
88
- { deliveryJobId: job.id },
89
- statusRefresh
90
- );
91
- const [completionAttestation, setCompletionAttestation] = useState(false);
92
- const uploadedPhoto = useRef<{ file: File; id: string } | null>(null);
93
- const submissionLock = useRef(false);
94
- const active = useRef(true);
95
- useEffect(() => {
96
- active.current = true;
97
- return () => {
98
- active.current = false;
99
- };
100
- }, []);
101
- const [preparing, setPreparing] = useState(false);
102
- const [requiredStatusRefresh, setRequiredStatusRefresh] = useState(0);
103
- const { upload: uploadPhoto, isUploading, error: uploadError } = useEpodPhotoUpload();
104
- const signaturePadRef = useRef<SignaturePadHandle>(null);
105
-
106
- const [recipientName, setRecipientName] = useState('');
107
- const [signatureType, setSignatureType] = useState<SignatureType>('drawn');
108
- const [typedSignature, setTypedSignature] = useState('');
109
- const [photoFile, setPhotoFile] = useState<File | null>(null);
110
- const [notes, setNotes] = useState('');
111
- const [validationError, setValidationError] = useState<string | null>(null);
60
+ type StopFilter = 'all' | 'remaining' | 'completed';
112
61
 
113
- const handleSubmit = async () => {
114
- if (submissionLock.current) return;
115
- setValidationError(null);
116
- if (
117
- ownedStatus.loading ||
118
- ownedStatus.error ||
119
- !ownedStatus.data ||
120
- statusRefresh < requiredStatusRefresh ||
121
- !completionAttestation ||
122
- captureFeedback?.kind === 'UNKNOWN'
123
- ) {
124
- setValidationError(
125
- 'Review the current linked shipment status and confirm completion before capture. Do not resend an uncertain capture.'
126
- );
127
- return;
128
- }
62
+ type View = { kind: 'list' } | { kind: 'stop'; jobId: string } | { kind: 'capture'; jobId: string };
129
63
 
130
- const trimmedRecipient = recipientName.trim();
131
- if (!trimmedRecipient) {
132
- setValidationError('Recipient name is required.');
133
- return;
134
- }
64
+ const STATE_LABEL: Record<DriverStopState, string> = {
65
+ COMPLETED: 'Delivered',
66
+ EN_ROUTE: 'En route',
67
+ UPCOMING: 'Upcoming',
68
+ FAILED: 'Not delivered',
69
+ };
135
70
 
136
- const signatureData =
137
- signatureType === 'typed'
138
- ? typedSignature.trim()
139
- : (signaturePadRef.current?.toDataUrl() ?? null);
140
- if (!signatureData) {
141
- setValidationError(
142
- signatureType === 'typed'
143
- ? 'Type the recipient full name as their signature.'
144
- : 'A signature is required.'
145
- );
146
- return;
147
- }
71
+ const STATE_VARIANT: Record<DriverStopState, 'default' | 'secondary' | 'destructive' | 'outline'> =
72
+ {
73
+ COMPLETED: 'secondary',
74
+ EN_ROUTE: 'default',
75
+ UPCOMING: 'outline',
76
+ FAILED: 'destructive',
77
+ };
148
78
 
149
- submissionLock.current = true;
150
- setPreparing(true);
151
- try {
152
- let photoFileIds: string[] | undefined;
153
- if (photoFile) {
154
- if (uploadedPhoto.current?.file === photoFile) photoFileIds = [uploadedPhoto.current.id];
155
- else {
156
- const uploaded = await uploadPhoto(photoFile);
157
- uploadedPhoto.current = { file: photoFile, id: uploaded.fileId };
158
- photoFileIds = [uploaded.fileId];
159
- }
160
- }
79
+ function isDone(stop: DriverStop): boolean {
80
+ return stop.state === 'COMPLETED' || stop.state === 'FAILED';
81
+ }
161
82
 
162
- if (!active.current) return;
163
- const position = await getCurrentPosition();
164
- if (!active.current) return;
83
+ function toCaptureJob(stop: DriverStop): PendingEpodDeliveryJob {
84
+ return {
85
+ id: stop.jobId,
86
+ deliveryJobNumber: stop.deliveryJobNumber,
87
+ status: stop.status,
88
+ priority: stop.priority,
89
+ deliveryAddress: stop.deliveryAddress,
90
+ scheduledDelivery: stop.scheduledDelivery,
91
+ customerName: stop.customerName,
92
+ customerPhone: stop.customerPhone,
93
+ };
94
+ }
165
95
 
166
- const proof = await captureEpod({
167
- expectedShipmentId: ownedStatus.data.myEpodShipmentStatus?.id ?? null,
168
- expectedShipmentStatusRevision: ownedStatus.data.myEpodShipmentStatus?.statusRevision,
169
- completionAttestation,
170
- deliveryJobId: job.id,
171
- recipientName: trimmedRecipient,
172
- signatureType,
173
- signatureData,
174
- photoFileIds,
175
- latitude: position?.coords.latitude,
176
- longitude: position?.coords.longitude,
177
- locationAccuracy: position?.coords.accuracy,
178
- notes: notes.trim() || undefined,
179
- });
96
+ function openExternal(url: string | null) {
97
+ if (url) window.open(url, '_blank', 'noopener,noreferrer');
98
+ }
180
99
 
181
- if (!active.current) return;
182
- onCaptured(proof.deliveryJobId);
183
- } catch {
184
- setCompletionAttestation(false);
185
- setRequiredStatusRefresh(statusRefresh + 1);
186
- // captureError / uploadError already carry the message for display below.
187
- } finally {
188
- submissionLock.current = false;
189
- setPreparing(false);
190
- }
191
- };
100
+ function StopStateBadge({ state }: { state: DriverStopState }) {
101
+ return <Badge variant={STATE_VARIANT[state]}>{STATE_LABEL[state]}</Badge>;
102
+ }
192
103
 
193
- const submitting = isSubmitting || isUploading || preparing;
104
+ function SequenceMarker({ stop }: { stop: DriverStop }) {
105
+ const done = isDone(stop);
106
+ return (
107
+ <span
108
+ aria-hidden="true"
109
+ className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border text-sm font-semibold ${
110
+ stop.state === 'EN_ROUTE'
111
+ ? 'border-primary bg-primary text-primary-foreground'
112
+ : done
113
+ ? 'border-border bg-surface-secondary text-text-secondary'
114
+ : 'border-border bg-surface text-text-primary'
115
+ }`}
116
+ >
117
+ {stop.state === 'COMPLETED' ? <CheckCircle2 size={16} /> : stop.sequence}
118
+ </span>
119
+ );
120
+ }
194
121
 
122
+ function StopCard({
123
+ stop,
124
+ onOpen,
125
+ onCapture,
126
+ offline,
127
+ }: {
128
+ stop: DriverStop;
129
+ onOpen: () => void;
130
+ onCapture: () => void;
131
+ offline: boolean;
132
+ }) {
195
133
  return (
196
134
  <Card>
197
- <CardHeader>
198
- <CardTitle className="flex items-center gap-2">
199
- <Truck size={18} /> Capture delivery for {job.deliveryJobNumber}
200
- </CardTitle>
201
- </CardHeader>
202
- <CardContent className="space-y-4">
203
- <div className="text-sm text-text-secondary space-y-1">
204
- {job.customerName && <p>Recipient on file: {job.customerName}</p>}
205
- {job.deliveryAddress && <p>{formatAddress(job.deliveryAddress)}</p>}
206
- </div>
207
- <section aria-label="Linked shipment status" className="space-y-2">
208
- <p>
209
- Recorded shipment:{' '}
210
- {ownedStatus.data?.myEpodShipmentStatus
211
- ? `${ownedStatus.data.myEpodShipmentStatus.status.toLowerCase().replaceAll('_', ' ')} · revision ${ownedStatus.data.myEpodShipmentStatus.statusRevision}`
212
- : ownedStatus.data
213
- ? 'No linked shipment'
214
- : 'Loading current record'}
135
+ <CardContent className="flex items-start gap-3 py-4">
136
+ <SequenceMarker stop={stop} />
137
+ <button
138
+ type="button"
139
+ onClick={onOpen}
140
+ aria-label={`Stop ${stop.sequence}, ${stop.deliveryJobNumber}, open details`}
141
+ className="min-w-0 flex-1 text-left"
142
+ >
143
+ <div className="flex flex-wrap items-center gap-1.5">
144
+ <span className="font-medium">Stop {stop.sequence}</span>
145
+ <StopStateBadge state={stop.state} />
146
+ {stop.overdue && <Badge variant="destructive">Overdue</Badge>}
147
+ </div>
148
+ {stop.deliveryAddress && (
149
+ <p className="mt-1 text-sm text-text-secondary">
150
+ {formatAddress(stop.deliveryAddress)}
151
+ </p>
152
+ )}
153
+ <p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-text-secondary">
154
+ {stop.scheduledDelivery && (
155
+ <span className="flex items-center gap-1">
156
+ <Clock size={12} /> {formatTime(stop.scheduledDelivery)}
157
+ </span>
158
+ )}
159
+ <span className="flex items-center gap-1">
160
+ <Package size={12} /> {stop.deliveryJobNumber}
161
+ </span>
162
+ {stop.routeName && (
163
+ <span className="flex items-center gap-1">
164
+ <RouteIcon size={12} /> {stop.routeName}
165
+ </span>
166
+ )}
215
167
  </p>
216
- {ownedStatus.error && <p role="alert">{ownedStatus.error}</p>}
217
- <Button
218
- variant="outline"
219
- disabled={ownedStatus.loading || submitting}
220
- onClick={() => {
221
- setCompletionAttestation(false);
222
- setStatusRefresh((value) => value + 1);
223
- }}
224
- >
225
- Refresh linked status
168
+ </button>
169
+ {stop.awaitingCapture && (
170
+ <Button type="button" size="sm" onClick={onCapture} disabled={offline}>
171
+ Capture
226
172
  </Button>
227
- <label className="flex items-start gap-2">
228
- <input
229
- type="checkbox"
230
- checked={completionAttestation}
231
- disabled={
232
- ownedStatus.loading ||
233
- !!ownedStatus.error ||
234
- submitting ||
235
- statusRefresh < requiredStatusRefresh ||
236
- captureFeedback?.kind === 'UNKNOWN'
237
- }
238
- onChange={(event) => setCompletionAttestation(event.target.checked)}
239
- />
240
- I reviewed the recorded linked status and attest to this delivery completion.
241
- </label>
242
- <p>
243
- This records an assigned driver's attestation; it does not independently verify physical
244
- freight movement.
245
- </p>
246
- {captureFeedback && <p role="status">{captureFeedback.message}</p>}
247
- </section>
173
+ )}
174
+ </CardContent>
175
+ </Card>
176
+ );
177
+ }
248
178
 
249
- <div className="space-y-1.5">
250
- <Label htmlFor="epod-recipient-name">Recipient name</Label>
251
- <Input
252
- id="epod-recipient-name"
253
- value={recipientName}
254
- onChange={(event) => setRecipientName(event.target.value)}
255
- placeholder="Who received the delivery?"
256
- />
257
- </div>
179
+ function StopDetail({
180
+ stop,
181
+ onBack,
182
+ onCapture,
183
+ offline,
184
+ }: {
185
+ stop: DriverStop;
186
+ onBack: () => void;
187
+ onCapture: () => void;
188
+ offline: boolean;
189
+ }) {
190
+ const navigateUrl = stopNavigationUrl(stop.deliveryAddress);
191
+ const callUrl = phoneHref(stop.customerPhone);
192
+ return (
193
+ <div className="space-y-4">
194
+ <Button type="button" variant="ghost" size="sm" onClick={onBack}>
195
+ <ArrowLeft size={16} /> All stops
196
+ </Button>
197
+ <Card>
198
+ <CardContent className="space-y-4 py-5">
199
+ <div className="flex flex-wrap items-center gap-2">
200
+ <SequenceMarker stop={stop} />
201
+ <h2 className="text-lg font-semibold">Stop {stop.sequence}</h2>
202
+ <StopStateBadge state={stop.state} />
203
+ <PriorityBadge priority={mapOrderPriority(stop.priority)} size="sm" />
204
+ {stop.overdue && <Badge variant="destructive">Overdue</Badge>}
205
+ </div>
258
206
 
259
- <div className="space-y-1.5">
260
- <Label>Signature</Label>
261
- <div className="flex gap-2">
207
+ <dl className="grid gap-3 text-sm">
208
+ <div>
209
+ <dt className="text-text-secondary">Deliver to</dt>
210
+ <dd className="font-medium">
211
+ {stop.deliveryAddress ? formatAddress(stop.deliveryAddress) : 'No address on file'}
212
+ </dd>
213
+ </div>
214
+ {stop.customerName && (
215
+ <div>
216
+ <dt className="text-text-secondary">Recipient</dt>
217
+ <dd className="font-medium">{stop.customerName}</dd>
218
+ </div>
219
+ )}
220
+ <div>
221
+ <dt className="text-text-secondary">Order</dt>
222
+ <dd className="font-medium">{stop.deliveryJobNumber}</dd>
223
+ </div>
224
+ {stop.scheduledDelivery && (
225
+ <div>
226
+ <dt className="text-text-secondary">Scheduled</dt>
227
+ <dd className="font-medium">{formatDate(stop.scheduledDelivery, 'full')}</dd>
228
+ </div>
229
+ )}
230
+ {stop.routeName && (
231
+ <div>
232
+ <dt className="text-text-secondary">Route</dt>
233
+ <dd className="font-medium">{stop.routeName}</dd>
234
+ </div>
235
+ )}
236
+ {stop.specialInstructions && (
237
+ <div>
238
+ <dt className="text-text-secondary">Delivery instructions</dt>
239
+ <dd className="whitespace-pre-line">{stop.specialInstructions}</dd>
240
+ </div>
241
+ )}
242
+ {stop.deliveredAt && (
243
+ <div>
244
+ <dt className="text-text-secondary">Proof of delivery captured</dt>
245
+ <dd className="font-medium">{formatDate(stop.deliveredAt, 'full')}</dd>
246
+ </div>
247
+ )}
248
+ </dl>
249
+
250
+ <div className="grid gap-2 sm:grid-cols-2">
262
251
  <Button
263
252
  type="button"
264
- variant={signatureType === 'drawn' ? 'default' : 'outline'}
265
- size="sm"
266
- onClick={() => setSignatureType('drawn')}
253
+ variant="outline"
254
+ disabled={!navigateUrl}
255
+ onClick={() => openExternal(navigateUrl)}
267
256
  >
268
- <PenLine size={14} /> Draw
257
+ <Navigation size={16} /> Navigate
269
258
  </Button>
270
259
  <Button
271
260
  type="button"
272
- variant={signatureType === 'typed' ? 'default' : 'outline'}
273
- size="sm"
274
- onClick={() => setSignatureType('typed')}
261
+ variant="outline"
262
+ disabled={!callUrl}
263
+ onClick={() => {
264
+ if (callUrl) window.location.href = callUrl;
265
+ }}
275
266
  >
276
- <TypeIcon size={14} /> Type name instead
267
+ <Phone size={16} /> Call recipient
277
268
  </Button>
278
269
  </div>
279
- {signatureType === 'drawn' ? (
280
- <SignaturePad ref={signaturePadRef} />
281
- ) : (
282
- <Input
283
- value={typedSignature}
284
- onChange={(event) => setTypedSignature(event.target.value)}
285
- placeholder="Type full name as signature"
286
- />
287
- )}
288
- </div>
289
-
290
- <div className="space-y-1.5">
291
- <Label htmlFor="epod-photo">Delivery photo (optional)</Label>
292
- <div className="flex items-center gap-2">
293
- <input
294
- id="epod-photo"
295
- type="file"
296
- accept="image/*"
297
- capture="environment"
298
- onChange={(event) => setPhotoFile(event.target.files?.[0] ?? null)}
299
- className="text-sm"
300
- />
301
- {photoFile && <Upload size={14} className="text-text-secondary" />}
302
- </div>
303
- {photoFile && <p className="text-xs text-text-secondary">{photoFile.name}</p>}
304
- </div>
305
-
306
- <div className="space-y-1.5">
307
- <Label htmlFor="epod-notes">Notes (optional)</Label>
308
- <Textarea
309
- id="epod-notes"
310
- value={notes}
311
- onChange={(event) => setNotes(event.target.value)}
312
- placeholder="e.g. Left with front desk, gate code used, etc."
313
- rows={2}
314
- />
315
- </div>
316
-
317
- <p className="flex items-center gap-1.5 text-xs text-text-secondary">
318
- <MapPin size={12} /> Device GPS is captured automatically when permitted by the browser.
319
- </p>
320
270
 
321
- {validationError && <FormError message={validationError} />}
322
- {captureError && <FormError message={captureError.message} />}
323
- {uploadError && <FormError message={uploadError.message} />}
324
-
325
- <div className="flex gap-2 justify-end">
326
- <Button type="button" variant="outline" onClick={onCancel} disabled={submitting}>
327
- Cancel
328
- </Button>
329
- <Button
330
- type="button"
331
- onClick={() => void handleSubmit()}
332
- disabled={
333
- submitting ||
334
- ownedStatus.loading ||
335
- !!ownedStatus.error ||
336
- !ownedStatus.data ||
337
- statusRefresh < requiredStatusRefresh ||
338
- !completionAttestation ||
339
- captureFeedback?.kind === 'UNKNOWN'
340
- }
341
- >
342
- {submitting ? (
343
- <>
344
- <Loader2 size={14} className="animate-spin" /> Submitting…
345
- </>
346
- ) : (
347
- <>
348
- <CheckCircle2 size={14} /> Confirm delivery
349
- </>
350
- )}
351
- </Button>
352
- </div>
353
- </CardContent>
354
- </Card>
271
+ {stop.awaitingCapture ? (
272
+ <Button type="button" className="w-full" onClick={onCapture} disabled={offline}>
273
+ <CheckCircle2 size={16} /> Capture proof of delivery
274
+ </Button>
275
+ ) : stop.state === 'UPCOMING' ? (
276
+ <p className="text-sm text-text-secondary">
277
+ Proof of delivery opens once dispatch marks this stop out for delivery.
278
+ </p>
279
+ ) : null}
280
+ </CardContent>
281
+ </Card>
282
+ </div>
355
283
  );
356
284
  }
357
285
 
358
286
  export default function DeliveryCapturePage() {
359
287
  const { workspaceId, tenantId, currentUser, apiGatewayUrl } = useMoveTheWheels();
360
- const [pageIndex, setPageIndex] = useState(0);
361
- const pageSize = 20;
362
- const { jobs, hasMore, isLoading, error, refetch, retireAcknowledgedJob } =
363
- useMyPendingEpodDeliveryJobs({
364
- limit: pageSize,
365
- offset: pageIndex * pageSize,
366
- });
367
- const [selectedJob, setSelectedJob] = useState<PendingEpodDeliveryJob | null>(null);
288
+ const online = useOnlineStatus();
289
+ const offline = !online;
290
+ const [date, setDate] = useState(() => localDateString(new Date()));
291
+ const [filter, setFilter] = useState<StopFilter>('all');
292
+ const [view, setView] = useState<View>({ kind: 'list' });
293
+ const { day, isLoading, error, refetch, markCaptured } = useMyDeliveryDay(date);
368
294
  const { message, notify } = useNotice();
295
+ const today = localDateString(new Date());
296
+
297
+ // Coming back online: refresh so the list reflects anything dispatch changed.
298
+ // Only an offline -> online transition refetches; the hook already loads on
299
+ // mount and whenever the date or scope changes.
300
+ const wasOffline = useRef(false);
301
+ useEffect(() => {
302
+ if (!online) {
303
+ wasOffline.current = true;
304
+ } else if (wasOffline.current) {
305
+ wasOffline.current = false;
306
+ void refetch();
307
+ }
308
+ }, [online, refetch]);
309
+
310
+ const stops = useMemo(() => day?.stops ?? [], [day]);
311
+ const visibleStops = useMemo(
312
+ () =>
313
+ stops.filter((stop) =>
314
+ filter === 'all' ? true : filter === 'completed' ? isDone(stop) : !isDone(stop)
315
+ ),
316
+ [stops, filter]
317
+ );
318
+ const remainingRouteUrl = useMemo(
319
+ () =>
320
+ routeNavigationUrl(stops.filter((stop) => !isDone(stop)).map((stop) => stop.deliveryAddress)),
321
+ [stops]
322
+ );
323
+ const selected =
324
+ view.kind === 'list' ? null : (stops.find((stop) => stop.jobId === view.jobId) ?? null);
369
325
 
370
326
  const handleCaptured = (deliveryJobId: string) => {
371
- retireAcknowledgedJob(deliveryJobId);
327
+ markCaptured(deliveryJobId);
372
328
  notify('Proof of delivery captured — delivery marked complete.');
373
- setSelectedJob(null);
374
- setPageIndex(0);
375
- refetch();
329
+ setView({ kind: 'list' });
330
+ void refetch();
376
331
  };
377
332
 
333
+ const changeDate = (next: string) => {
334
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(next)) return;
335
+ setDate(next);
336
+ setView({ kind: 'list' });
337
+ };
338
+
339
+ const progress = day && day.totalStops > 0 ? (day.completedStops / day.totalStops) * 100 : 0;
340
+
378
341
  return (
379
342
  <PageLayout
380
343
  title="My Deliveries"
381
- subtitle="Capture proof of delivery for your assigned stops"
344
+ subtitle="Your stops for the day, in order — navigate, then capture proof of delivery"
382
345
  icon={<Truck size={24} />}
346
+ maxWidth="2xl"
383
347
  >
384
348
  <div className="space-y-4">
385
349
  <Notice message={message} />
386
350
 
387
- {selectedJob ? (
388
- <CaptureForm
389
- key={`${workspaceId}:${tenantId}:${currentUser?.id}:${apiGatewayUrl}:${selectedJob.id}`}
390
- job={selectedJob}
351
+ {offline && (
352
+ <div
353
+ role="status"
354
+ className="flex items-start gap-2 rounded-md border border-border bg-surface-secondary p-3 text-sm"
355
+ >
356
+ <WifiOff size={16} className="mt-0.5 shrink-0" />
357
+ <span>
358
+ You are offline. Your last loaded stops stay available; reconnect to capture proof of
359
+ delivery.
360
+ </span>
361
+ </div>
362
+ )}
363
+
364
+ {selected && view.kind === 'capture' && selected.awaitingCapture ? (
365
+ <EpodCaptureForm
366
+ key={`${workspaceId}:${tenantId}:${currentUser?.id}:${apiGatewayUrl}:${selected.jobId}`}
367
+ job={toCaptureJob(selected)}
368
+ offline={offline}
391
369
  onCaptured={handleCaptured}
392
- onCancel={() => setSelectedJob(null)}
370
+ onCancel={() => setView({ kind: 'stop', jobId: selected.jobId })}
393
371
  />
394
- ) : isLoading ? (
395
- <p className="text-text-secondary text-center py-8">Loading your deliveries…</p>
396
- ) : error ? (
397
- <FormError message={error.message} />
398
- ) : jobs.length > 0 ? (
399
- <div className="space-y-3">
400
- {jobs.map((job) => (
401
- <Card key={job.id}>
402
- <CardContent className="flex items-center justify-between gap-3 py-4">
403
- <div className="min-w-0">
404
- <p className="font-medium flex items-center gap-2">
405
- <Package size={16} /> {job.deliveryJobNumber}
406
- </p>
407
- {/* Stop state + urgency, straight off the job record — a driver
408
- needs to know which stop is already out for delivery. */}
409
- <div className="flex flex-wrap items-center gap-1.5 py-1">
410
- <StatusBadge status={mapOrderStatus(job.status)} type="order" size="sm" />
411
- <PriorityBadge priority={mapOrderPriority(job.priority)} size="sm" />
412
- </div>
413
- {job.deliveryAddress && (
414
- <p className="text-sm text-text-secondary truncate">
415
- {formatAddress(job.deliveryAddress)}
372
+ ) : selected ? (
373
+ <StopDetail
374
+ stop={selected}
375
+ offline={offline}
376
+ onBack={() => setView({ kind: 'list' })}
377
+ onCapture={() => setView({ kind: 'capture', jobId: selected.jobId })}
378
+ />
379
+ ) : (
380
+ <>
381
+ <div className="flex flex-wrap items-center gap-2">
382
+ <Button
383
+ type="button"
384
+ variant="outline"
385
+ size="sm"
386
+ aria-label="Previous day"
387
+ onClick={() => changeDate(shiftLocalDate(date, -1))}
388
+ >
389
+ <ChevronLeft size={16} />
390
+ </Button>
391
+ <Button
392
+ type="button"
393
+ variant={date === today ? 'default' : 'outline'}
394
+ size="sm"
395
+ onClick={() => changeDate(today)}
396
+ >
397
+ <CalendarDays size={16} /> Today
398
+ </Button>
399
+ <Button
400
+ type="button"
401
+ variant="outline"
402
+ size="sm"
403
+ aria-label="Next day"
404
+ onClick={() => changeDate(shiftLocalDate(date, 1))}
405
+ >
406
+ <ChevronRight size={16} />
407
+ </Button>
408
+ <Input
409
+ type="date"
410
+ aria-label="Delivery date"
411
+ value={date}
412
+ onChange={(event) => changeDate(event.target.value)}
413
+ className="w-auto"
414
+ />
415
+ <Button
416
+ type="button"
417
+ variant="ghost"
418
+ size="sm"
419
+ aria-label="Refresh stops"
420
+ disabled={isLoading || offline}
421
+ onClick={() => void refetch()}
422
+ >
423
+ <RefreshCw size={16} className={isLoading ? 'animate-spin' : undefined} />
424
+ </Button>
425
+ </div>
426
+
427
+ {error && <FormError message={error.message} />}
428
+
429
+ {isLoading && !day ? (
430
+ <p className="py-8 text-center text-text-secondary">Loading your deliveries…</p>
431
+ ) : day && day.driverId === null ? (
432
+ <div className="py-12 text-center">
433
+ <AlertTriangle size={40} className="mx-auto mb-3 text-text-secondary opacity-50" />
434
+ <p className="text-text-secondary">
435
+ Your account is not linked to a driver in this workspace. Ask your dispatcher to
436
+ link your user on the driver profile.
437
+ </p>
438
+ </div>
439
+ ) : day ? (
440
+ <>
441
+ <Card>
442
+ <CardContent className="space-y-3 py-4">
443
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
444
+ <p className="font-medium">
445
+ {day.completedStops} of {day.totalStops} stops done
446
+ {day.driverName ? ` · ${day.driverName}` : ''}
416
447
  </p>
417
- )}
418
- {job.scheduledDelivery && (
419
- <p className="text-xs text-text-secondary">
420
- Scheduled {formatDate(job.scheduledDelivery, 'short')}
448
+ <p className="text-sm text-text-secondary">
449
+ {formatDate(new Date(`${date}T12:00:00`), 'full')}
421
450
  </p>
451
+ </div>
452
+ <Progress value={progress} aria-label="Stops completed" />
453
+ {day.nextStop && (
454
+ <div className="rounded-md border border-border p-3">
455
+ <p className="text-xs uppercase tracking-wide text-text-secondary">
456
+ Next stop · Stop {day.nextStop.sequence}
457
+ </p>
458
+ <p className="mt-1 font-medium">
459
+ {day.nextStop.deliveryAddress
460
+ ? formatAddress(day.nextStop.deliveryAddress)
461
+ : day.nextStop.deliveryJobNumber}
462
+ </p>
463
+ {day.nextStop.customerName && (
464
+ <p className="text-sm text-text-secondary">
465
+ Recipient: {day.nextStop.customerName}
466
+ </p>
467
+ )}
468
+ <div className="mt-3 grid gap-2 sm:grid-cols-2">
469
+ <Button
470
+ type="button"
471
+ onClick={() => openExternal(remainingRouteUrl)}
472
+ disabled={!remainingRouteUrl}
473
+ >
474
+ <RouteIcon size={16} /> Start route
475
+ </Button>
476
+ <Button
477
+ type="button"
478
+ variant="outline"
479
+ onClick={() => {
480
+ const next = day.nextStop;
481
+ if (next) setView({ kind: 'stop', jobId: next.jobId });
482
+ }}
483
+ >
484
+ Stop details <ChevronRight size={16} />
485
+ </Button>
486
+ </div>
487
+ </div>
422
488
  )}
489
+ </CardContent>
490
+ </Card>
491
+
492
+ <div role="tablist" aria-label="Filter stops" className="flex gap-2">
493
+ {(
494
+ [
495
+ ['all', `All stops (${day.totalStops})`],
496
+ ['remaining', `Remaining (${day.remainingStops})`],
497
+ ['completed', `Done (${day.completedStops})`],
498
+ ] as const
499
+ ).map(([value, label]) => (
500
+ <Button
501
+ key={value}
502
+ type="button"
503
+ role="tab"
504
+ aria-selected={filter === value}
505
+ size="sm"
506
+ variant={filter === value ? 'default' : 'outline'}
507
+ onClick={() => setFilter(value)}
508
+ >
509
+ {label}
510
+ </Button>
511
+ ))}
512
+ </div>
513
+
514
+ {visibleStops.length > 0 ? (
515
+ <div className="space-y-3">
516
+ {visibleStops.map((stop) => (
517
+ <StopCard
518
+ key={stop.jobId}
519
+ stop={stop}
520
+ offline={offline}
521
+ onOpen={() => setView({ kind: 'stop', jobId: stop.jobId })}
522
+ onCapture={() => setView({ kind: 'capture', jobId: stop.jobId })}
523
+ />
524
+ ))}
423
525
  </div>
424
- <Button type="button" size="sm" onClick={() => setSelectedJob(job)}>
425
- Capture
426
- </Button>
427
- </CardContent>
428
- </Card>
429
- ))}
430
- {(pageIndex > 0 || hasMore) && (
431
- <div className="flex justify-end gap-2 pt-2">
432
- <Button
433
- type="button"
434
- variant="outline"
435
- size="sm"
436
- disabled={pageIndex === 0 || isLoading}
437
- onClick={() => setPageIndex((previous) => Math.max(0, previous - 1))}
438
- >
439
- <ChevronLeft size={16} /> Previous
440
- </Button>
441
- <Button
442
- type="button"
443
- variant="outline"
444
- size="sm"
445
- disabled={!hasMore || isLoading}
446
- onClick={() => setPageIndex((previous) => previous + 1)}
447
- >
448
- Next <ChevronRight size={16} />
449
- </Button>
450
- </div>
451
- )}
452
- </div>
453
- ) : (
454
- <div className="text-center py-12">
455
- <CheckCircle2 size={40} className="mx-auto mb-3 text-text-secondary opacity-50" />
456
- <p className="text-text-secondary">No deliveries awaiting proof of delivery.</p>
457
- </div>
526
+ ) : (
527
+ <div className="py-12 text-center">
528
+ <CheckCircle2
529
+ size={40}
530
+ className="mx-auto mb-3 text-text-secondary opacity-50"
531
+ />
532
+ <p className="text-text-secondary">
533
+ {day.totalStops === 0
534
+ ? 'No deliveries scheduled for this day.'
535
+ : filter === 'completed'
536
+ ? 'No stops completed yet.'
537
+ : 'All stops done for this day.'}
538
+ </p>
539
+ </div>
540
+ )}
541
+ </>
542
+ ) : null}
543
+ </>
458
544
  )}
459
545
  </div>
460
546
  </PageLayout>