@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.
- package/dist/components/EpodCaptureForm.js +256 -0
- package/dist/generated/wspace-types.d.ts +9012 -132
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useDriverDay.js +153 -0
- package/dist/hooks/useOrders.js +1 -1
- package/dist/index.js +27 -26
- package/dist/movethewheels/components/EpodCaptureForm.d.ts +12 -0
- package/dist/movethewheels/hooks/index.d.ts +1 -0
- package/dist/movethewheels/hooks/useDriverDay.d.ts +53 -0
- package/dist/movethewheels/utils/driverNavigation.d.ts +31 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/calendar-days.js +50 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/wifi-off.js +34 -0
- package/dist/pages/DeliveryCapturePage.js +450 -321
- package/dist/utils/driverNavigation.js +51 -0
- package/dist/utils/formatters.js +18 -9
- package/package.json +3 -2
- package/src/generated/wspace-types.ts +10266 -142
- package/src/movethewheels/components/EpodCaptureForm.tsx +343 -0
- package/src/movethewheels/hooks/index.ts +9 -0
- package/src/movethewheels/hooks/useDriverDay.ts +289 -0
- package/src/movethewheels/pages/DeliveryCapturePage.tsx +478 -392
- package/src/movethewheels/utils/driverNavigation.ts +103 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ePOD capture form (BOFF-4399), extracted from the driver deliveries page so
|
|
3
|
+
* the BOFF-3991 driver day view can open it from a stop's detail screen.
|
|
4
|
+
*
|
|
5
|
+
* Captures recipient name, signature (drawn or typed), an optional delivery
|
|
6
|
+
* photo (uploaded via the platform files primitive), device GPS if available,
|
|
7
|
+
* and notes, after the driver reviews the linked shipment status and attests
|
|
8
|
+
* completion. `captureEpod` owner-verifies the calling driver server-side.
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useRef, useState } from 'react';
|
|
11
|
+
import {
|
|
12
|
+
CheckCircle2,
|
|
13
|
+
Loader2,
|
|
14
|
+
MapPin,
|
|
15
|
+
PenLine,
|
|
16
|
+
Truck,
|
|
17
|
+
Type as TypeIcon,
|
|
18
|
+
Upload,
|
|
19
|
+
} from 'lucide-react';
|
|
20
|
+
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Textarea } from './ui';
|
|
21
|
+
import { SignaturePad, type SignaturePadHandle } from './SignaturePad';
|
|
22
|
+
import { FormError } from './FormError';
|
|
23
|
+
import { useCaptureEpod, useEpodPhotoUpload, type PendingEpodDeliveryJob } from '../hooks/useEpod';
|
|
24
|
+
import { formatAddress } from '../utils/formatters';
|
|
25
|
+
import type { SignatureType } from '../types';
|
|
26
|
+
import { useShipmentStatusQuery } from '../hooks/useShipmentStatusAuthority';
|
|
27
|
+
import { StatusAuthorityOwnedDeliveryDocument } from '../../generated/shipment-status-authority-operations';
|
|
28
|
+
import type { StatusAuthorityOwnedDeliveryQuery } from '../../generated/shipment-status-authority-operations';
|
|
29
|
+
|
|
30
|
+
/** Resolves the device's current position, if the browser grants it. Never blocks submission. */
|
|
31
|
+
function getCurrentPosition(): Promise<GeolocationPosition | null> {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
if (!navigator.geolocation) {
|
|
34
|
+
resolve(null);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
navigator.geolocation.getCurrentPosition(
|
|
38
|
+
(position) => resolve(position),
|
|
39
|
+
() => resolve(null),
|
|
40
|
+
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 30000 }
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function EpodCaptureForm({
|
|
46
|
+
job,
|
|
47
|
+
onCaptured,
|
|
48
|
+
onCancel,
|
|
49
|
+
offline = false,
|
|
50
|
+
}: {
|
|
51
|
+
job: PendingEpodDeliveryJob;
|
|
52
|
+
onCaptured: (deliveryJobId: string) => void;
|
|
53
|
+
onCancel: () => void;
|
|
54
|
+
/**
|
|
55
|
+
* True while the device has no network. Capture needs the live linked
|
|
56
|
+
* shipment status and an acknowledged server write, so it is blocked
|
|
57
|
+
* rather than queued while offline.
|
|
58
|
+
*/
|
|
59
|
+
offline?: boolean;
|
|
60
|
+
}) {
|
|
61
|
+
const { captureEpod, isSubmitting, error: captureError, captureFeedback } = useCaptureEpod();
|
|
62
|
+
const [statusRefresh, setStatusRefresh] = useState(0);
|
|
63
|
+
const ownedStatus = useShipmentStatusQuery<StatusAuthorityOwnedDeliveryQuery>(
|
|
64
|
+
StatusAuthorityOwnedDeliveryDocument,
|
|
65
|
+
{ deliveryJobId: job.id },
|
|
66
|
+
statusRefresh
|
|
67
|
+
);
|
|
68
|
+
const [completionAttestation, setCompletionAttestation] = useState(false);
|
|
69
|
+
const uploadedPhoto = useRef<{ file: File; id: string } | null>(null);
|
|
70
|
+
const submissionLock = useRef(false);
|
|
71
|
+
const active = useRef(true);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
active.current = true;
|
|
74
|
+
return () => {
|
|
75
|
+
active.current = false;
|
|
76
|
+
};
|
|
77
|
+
}, []);
|
|
78
|
+
const [preparing, setPreparing] = useState(false);
|
|
79
|
+
const [requiredStatusRefresh, setRequiredStatusRefresh] = useState(0);
|
|
80
|
+
const { upload: uploadPhoto, isUploading, error: uploadError } = useEpodPhotoUpload();
|
|
81
|
+
const signaturePadRef = useRef<SignaturePadHandle>(null);
|
|
82
|
+
|
|
83
|
+
const [recipientName, setRecipientName] = useState('');
|
|
84
|
+
const [signatureType, setSignatureType] = useState<SignatureType>('drawn');
|
|
85
|
+
const [typedSignature, setTypedSignature] = useState('');
|
|
86
|
+
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
|
87
|
+
const [notes, setNotes] = useState('');
|
|
88
|
+
const [validationError, setValidationError] = useState<string | null>(null);
|
|
89
|
+
|
|
90
|
+
const handleSubmit = async () => {
|
|
91
|
+
if (submissionLock.current) return;
|
|
92
|
+
setValidationError(null);
|
|
93
|
+
if (offline) {
|
|
94
|
+
setValidationError('You are offline. Reconnect to confirm this delivery.');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
ownedStatus.loading ||
|
|
99
|
+
ownedStatus.error ||
|
|
100
|
+
!ownedStatus.data ||
|
|
101
|
+
statusRefresh < requiredStatusRefresh ||
|
|
102
|
+
!completionAttestation ||
|
|
103
|
+
captureFeedback?.kind === 'UNKNOWN'
|
|
104
|
+
) {
|
|
105
|
+
setValidationError(
|
|
106
|
+
'Review the current linked shipment status and confirm completion before capture. Do not resend an uncertain capture.'
|
|
107
|
+
);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const trimmedRecipient = recipientName.trim();
|
|
112
|
+
if (!trimmedRecipient) {
|
|
113
|
+
setValidationError('Recipient name is required.');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const signatureData =
|
|
118
|
+
signatureType === 'typed'
|
|
119
|
+
? typedSignature.trim()
|
|
120
|
+
: (signaturePadRef.current?.toDataUrl() ?? null);
|
|
121
|
+
if (!signatureData) {
|
|
122
|
+
setValidationError(
|
|
123
|
+
signatureType === 'typed'
|
|
124
|
+
? 'Type the recipient full name as their signature.'
|
|
125
|
+
: 'A signature is required.'
|
|
126
|
+
);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
submissionLock.current = true;
|
|
131
|
+
setPreparing(true);
|
|
132
|
+
try {
|
|
133
|
+
let photoFileIds: string[] | undefined;
|
|
134
|
+
if (photoFile) {
|
|
135
|
+
if (uploadedPhoto.current?.file === photoFile) photoFileIds = [uploadedPhoto.current.id];
|
|
136
|
+
else {
|
|
137
|
+
const uploaded = await uploadPhoto(photoFile);
|
|
138
|
+
uploadedPhoto.current = { file: photoFile, id: uploaded.fileId };
|
|
139
|
+
photoFileIds = [uploaded.fileId];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!active.current) return;
|
|
144
|
+
const position = await getCurrentPosition();
|
|
145
|
+
if (!active.current) return;
|
|
146
|
+
|
|
147
|
+
const proof = await captureEpod({
|
|
148
|
+
expectedShipmentId: ownedStatus.data.myEpodShipmentStatus?.id ?? null,
|
|
149
|
+
expectedShipmentStatusRevision: ownedStatus.data.myEpodShipmentStatus?.statusRevision,
|
|
150
|
+
completionAttestation,
|
|
151
|
+
deliveryJobId: job.id,
|
|
152
|
+
recipientName: trimmedRecipient,
|
|
153
|
+
signatureType,
|
|
154
|
+
signatureData,
|
|
155
|
+
photoFileIds,
|
|
156
|
+
latitude: position?.coords.latitude,
|
|
157
|
+
longitude: position?.coords.longitude,
|
|
158
|
+
locationAccuracy: position?.coords.accuracy,
|
|
159
|
+
notes: notes.trim() || undefined,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (!active.current) return;
|
|
163
|
+
onCaptured(proof.deliveryJobId);
|
|
164
|
+
} catch {
|
|
165
|
+
setCompletionAttestation(false);
|
|
166
|
+
setRequiredStatusRefresh(statusRefresh + 1);
|
|
167
|
+
// captureError / uploadError already carry the message for display below.
|
|
168
|
+
} finally {
|
|
169
|
+
submissionLock.current = false;
|
|
170
|
+
setPreparing(false);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const submitting = isSubmitting || isUploading || preparing;
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<Card>
|
|
178
|
+
<CardHeader>
|
|
179
|
+
<CardTitle className="flex items-center gap-2">
|
|
180
|
+
<Truck size={18} /> Capture delivery for {job.deliveryJobNumber}
|
|
181
|
+
</CardTitle>
|
|
182
|
+
</CardHeader>
|
|
183
|
+
<CardContent className="space-y-4">
|
|
184
|
+
<div className="text-sm text-text-secondary space-y-1">
|
|
185
|
+
{job.customerName && <p>Recipient on file: {job.customerName}</p>}
|
|
186
|
+
{job.deliveryAddress && <p>{formatAddress(job.deliveryAddress)}</p>}
|
|
187
|
+
</div>
|
|
188
|
+
<section aria-label="Linked shipment status" className="space-y-2">
|
|
189
|
+
<p>
|
|
190
|
+
Recorded shipment:{' '}
|
|
191
|
+
{ownedStatus.data?.myEpodShipmentStatus
|
|
192
|
+
? `${ownedStatus.data.myEpodShipmentStatus.status.toLowerCase().replaceAll('_', ' ')} · revision ${ownedStatus.data.myEpodShipmentStatus.statusRevision}`
|
|
193
|
+
: ownedStatus.data
|
|
194
|
+
? 'No linked shipment'
|
|
195
|
+
: 'Loading current record'}
|
|
196
|
+
</p>
|
|
197
|
+
{ownedStatus.error && <p role="alert">{ownedStatus.error}</p>}
|
|
198
|
+
<Button
|
|
199
|
+
variant="outline"
|
|
200
|
+
disabled={ownedStatus.loading || submitting}
|
|
201
|
+
onClick={() => {
|
|
202
|
+
setCompletionAttestation(false);
|
|
203
|
+
setStatusRefresh((value) => value + 1);
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
206
|
+
Refresh linked status
|
|
207
|
+
</Button>
|
|
208
|
+
<label className="flex items-start gap-2">
|
|
209
|
+
<input
|
|
210
|
+
type="checkbox"
|
|
211
|
+
checked={completionAttestation}
|
|
212
|
+
disabled={
|
|
213
|
+
ownedStatus.loading ||
|
|
214
|
+
!!ownedStatus.error ||
|
|
215
|
+
submitting ||
|
|
216
|
+
statusRefresh < requiredStatusRefresh ||
|
|
217
|
+
captureFeedback?.kind === 'UNKNOWN'
|
|
218
|
+
}
|
|
219
|
+
onChange={(event) => setCompletionAttestation(event.target.checked)}
|
|
220
|
+
/>
|
|
221
|
+
I reviewed the recorded linked status and attest to this delivery completion.
|
|
222
|
+
</label>
|
|
223
|
+
<p>
|
|
224
|
+
This records an assigned driver's attestation; it does not independently verify physical
|
|
225
|
+
freight movement.
|
|
226
|
+
</p>
|
|
227
|
+
{captureFeedback && <p role="status">{captureFeedback.message}</p>}
|
|
228
|
+
</section>
|
|
229
|
+
|
|
230
|
+
<div className="space-y-1.5">
|
|
231
|
+
<Label htmlFor="epod-recipient-name">Recipient name</Label>
|
|
232
|
+
<Input
|
|
233
|
+
id="epod-recipient-name"
|
|
234
|
+
value={recipientName}
|
|
235
|
+
onChange={(event) => setRecipientName(event.target.value)}
|
|
236
|
+
placeholder="Who received the delivery?"
|
|
237
|
+
/>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<div className="space-y-1.5">
|
|
241
|
+
<Label>Signature</Label>
|
|
242
|
+
<div className="flex gap-2">
|
|
243
|
+
<Button
|
|
244
|
+
type="button"
|
|
245
|
+
variant={signatureType === 'drawn' ? 'default' : 'outline'}
|
|
246
|
+
size="sm"
|
|
247
|
+
onClick={() => setSignatureType('drawn')}
|
|
248
|
+
>
|
|
249
|
+
<PenLine size={14} /> Draw
|
|
250
|
+
</Button>
|
|
251
|
+
<Button
|
|
252
|
+
type="button"
|
|
253
|
+
variant={signatureType === 'typed' ? 'default' : 'outline'}
|
|
254
|
+
size="sm"
|
|
255
|
+
onClick={() => setSignatureType('typed')}
|
|
256
|
+
>
|
|
257
|
+
<TypeIcon size={14} /> Type name instead
|
|
258
|
+
</Button>
|
|
259
|
+
</div>
|
|
260
|
+
{signatureType === 'drawn' ? (
|
|
261
|
+
<SignaturePad ref={signaturePadRef} />
|
|
262
|
+
) : (
|
|
263
|
+
<Input
|
|
264
|
+
value={typedSignature}
|
|
265
|
+
onChange={(event) => setTypedSignature(event.target.value)}
|
|
266
|
+
placeholder="Type full name as signature"
|
|
267
|
+
/>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div className="space-y-1.5">
|
|
272
|
+
<Label htmlFor="epod-photo">Delivery photo (optional)</Label>
|
|
273
|
+
<div className="flex items-center gap-2">
|
|
274
|
+
<input
|
|
275
|
+
id="epod-photo"
|
|
276
|
+
type="file"
|
|
277
|
+
accept="image/*"
|
|
278
|
+
capture="environment"
|
|
279
|
+
onChange={(event) => setPhotoFile(event.target.files?.[0] ?? null)}
|
|
280
|
+
className="text-sm"
|
|
281
|
+
/>
|
|
282
|
+
{photoFile && <Upload size={14} className="text-text-secondary" />}
|
|
283
|
+
</div>
|
|
284
|
+
{photoFile && <p className="text-xs text-text-secondary">{photoFile.name}</p>}
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<div className="space-y-1.5">
|
|
288
|
+
<Label htmlFor="epod-notes">Notes (optional)</Label>
|
|
289
|
+
<Textarea
|
|
290
|
+
id="epod-notes"
|
|
291
|
+
value={notes}
|
|
292
|
+
onChange={(event) => setNotes(event.target.value)}
|
|
293
|
+
placeholder="e.g. Left with front desk, gate code used, etc."
|
|
294
|
+
rows={2}
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
<p className="flex items-center gap-1.5 text-xs text-text-secondary">
|
|
299
|
+
<MapPin size={12} /> Device GPS is captured automatically when permitted by the browser.
|
|
300
|
+
</p>
|
|
301
|
+
|
|
302
|
+
{offline && (
|
|
303
|
+
<p role="alert" className="text-sm text-text-secondary">
|
|
304
|
+
You are offline. Your entries stay on this screen; reconnect to confirm the delivery.
|
|
305
|
+
</p>
|
|
306
|
+
)}
|
|
307
|
+
{validationError && <FormError message={validationError} />}
|
|
308
|
+
{captureError && <FormError message={captureError.message} />}
|
|
309
|
+
{uploadError && <FormError message={uploadError.message} />}
|
|
310
|
+
|
|
311
|
+
<div className="flex gap-2 justify-end">
|
|
312
|
+
<Button type="button" variant="outline" onClick={onCancel} disabled={submitting}>
|
|
313
|
+
Cancel
|
|
314
|
+
</Button>
|
|
315
|
+
<Button
|
|
316
|
+
type="button"
|
|
317
|
+
onClick={() => void handleSubmit()}
|
|
318
|
+
disabled={
|
|
319
|
+
submitting ||
|
|
320
|
+
offline ||
|
|
321
|
+
ownedStatus.loading ||
|
|
322
|
+
!!ownedStatus.error ||
|
|
323
|
+
!ownedStatus.data ||
|
|
324
|
+
statusRefresh < requiredStatusRefresh ||
|
|
325
|
+
!completionAttestation ||
|
|
326
|
+
captureFeedback?.kind === 'UNKNOWN'
|
|
327
|
+
}
|
|
328
|
+
>
|
|
329
|
+
{submitting ? (
|
|
330
|
+
<>
|
|
331
|
+
<Loader2 size={14} className="animate-spin" /> Submitting…
|
|
332
|
+
</>
|
|
333
|
+
) : (
|
|
334
|
+
<>
|
|
335
|
+
<CheckCircle2 size={14} /> Confirm delivery
|
|
336
|
+
</>
|
|
337
|
+
)}
|
|
338
|
+
</Button>
|
|
339
|
+
</div>
|
|
340
|
+
</CardContent>
|
|
341
|
+
</Card>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
@@ -80,6 +80,15 @@ export {
|
|
|
80
80
|
type EntityDocumentType,
|
|
81
81
|
} from './useEntityDocuments';
|
|
82
82
|
|
|
83
|
+
// Driver day view (mobile driver task list) — BOFF-3991
|
|
84
|
+
export {
|
|
85
|
+
useMyDeliveryDay,
|
|
86
|
+
useOnlineStatus,
|
|
87
|
+
type DriverDay,
|
|
88
|
+
type DriverStop,
|
|
89
|
+
type DriverStopState,
|
|
90
|
+
} from './useDriverDay';
|
|
91
|
+
|
|
83
92
|
// Electronic proof of delivery (ePOD) capture — BOFF-4399
|
|
84
93
|
export {
|
|
85
94
|
useEpod,
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Driver day view data (BOFF-3991): the calling driver's stops for one local
|
|
3
|
+
* calendar day, in driving sequence, from the `myDeliveryDay` Type C
|
|
4
|
+
* self-list in `wspace-movethewheels-svc` (src/modules/epod/schema/epod.gql).
|
|
5
|
+
* The driver is resolved from the authenticated actor server-side; this hook
|
|
6
|
+
* never sends a driver or user id.
|
|
7
|
+
*/
|
|
8
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
9
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
10
|
+
import type { Order } from '../types';
|
|
11
|
+
import { executeGraphQL } from '../utils/graphqlClient';
|
|
12
|
+
import { deviceTimeZone, timezoneOffsetFor } from '../utils/driverNavigation';
|
|
13
|
+
|
|
14
|
+
const STOP_FIELDS = `
|
|
15
|
+
sequence
|
|
16
|
+
state
|
|
17
|
+
awaitingCapture
|
|
18
|
+
overdue
|
|
19
|
+
deliveredAt
|
|
20
|
+
routeId
|
|
21
|
+
routeName
|
|
22
|
+
deliveryJob {
|
|
23
|
+
id
|
|
24
|
+
deliveryJobNumber
|
|
25
|
+
status
|
|
26
|
+
priority
|
|
27
|
+
deliveryAddress
|
|
28
|
+
scheduledDelivery
|
|
29
|
+
specialInstructions
|
|
30
|
+
customer {
|
|
31
|
+
name
|
|
32
|
+
phone
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
const MY_DELIVERY_DAY_QUERY = `
|
|
38
|
+
query MyDeliveryDay($date: String!, $timezoneOffsetMinutes: Int, $timeZone: String) {
|
|
39
|
+
myDeliveryDay(date: $date, timezoneOffsetMinutes: $timezoneOffsetMinutes, timeZone: $timeZone) {
|
|
40
|
+
date
|
|
41
|
+
driverId
|
|
42
|
+
driverName
|
|
43
|
+
totalStops
|
|
44
|
+
completedStops
|
|
45
|
+
remainingStops
|
|
46
|
+
stops {
|
|
47
|
+
${STOP_FIELDS}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
export type DriverStopState = 'COMPLETED' | 'EN_ROUTE' | 'UPCOMING' | 'FAILED';
|
|
54
|
+
|
|
55
|
+
export interface DriverStop {
|
|
56
|
+
sequence: number;
|
|
57
|
+
state: DriverStopState;
|
|
58
|
+
awaitingCapture: boolean;
|
|
59
|
+
overdue: boolean;
|
|
60
|
+
deliveredAt?: Date;
|
|
61
|
+
routeId?: string;
|
|
62
|
+
routeName?: string;
|
|
63
|
+
jobId: string;
|
|
64
|
+
deliveryJobNumber: string;
|
|
65
|
+
status: string;
|
|
66
|
+
priority: string;
|
|
67
|
+
deliveryAddress: Order['deliveryAddress'] | null;
|
|
68
|
+
scheduledDelivery?: Date;
|
|
69
|
+
specialInstructions?: string;
|
|
70
|
+
customerName?: string;
|
|
71
|
+
customerPhone?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface DriverDay {
|
|
75
|
+
date: string;
|
|
76
|
+
driverId: string | null;
|
|
77
|
+
driverName: string | null;
|
|
78
|
+
stops: DriverStop[];
|
|
79
|
+
totalStops: number;
|
|
80
|
+
completedStops: number;
|
|
81
|
+
remainingStops: number;
|
|
82
|
+
nextStop: DriverStop | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface ApiStop {
|
|
86
|
+
sequence: number;
|
|
87
|
+
state: DriverStopState;
|
|
88
|
+
awaitingCapture: boolean;
|
|
89
|
+
overdue: boolean;
|
|
90
|
+
deliveredAt?: string | null;
|
|
91
|
+
routeId?: string | null;
|
|
92
|
+
routeName?: string | null;
|
|
93
|
+
deliveryJob: {
|
|
94
|
+
id: string;
|
|
95
|
+
deliveryJobNumber: string;
|
|
96
|
+
status: string;
|
|
97
|
+
priority: string;
|
|
98
|
+
deliveryAddress: Order['deliveryAddress'] | null;
|
|
99
|
+
scheduledDelivery?: string | null;
|
|
100
|
+
specialInstructions?: string | null;
|
|
101
|
+
customer?: { name?: string | null; phone?: string | null } | null;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface ApiDriverDay {
|
|
106
|
+
date: string;
|
|
107
|
+
driverId: string | null;
|
|
108
|
+
driverName: string | null;
|
|
109
|
+
totalStops: number;
|
|
110
|
+
completedStops: number;
|
|
111
|
+
remainingStops: number;
|
|
112
|
+
stops: ApiStop[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function mapStop(stop: ApiStop): DriverStop {
|
|
116
|
+
const job = stop.deliveryJob;
|
|
117
|
+
return {
|
|
118
|
+
sequence: stop.sequence,
|
|
119
|
+
state: stop.state,
|
|
120
|
+
awaitingCapture: stop.awaitingCapture,
|
|
121
|
+
overdue: stop.overdue,
|
|
122
|
+
deliveredAt: stop.deliveredAt ? new Date(stop.deliveredAt) : undefined,
|
|
123
|
+
routeId: stop.routeId ?? undefined,
|
|
124
|
+
routeName: stop.routeName ?? undefined,
|
|
125
|
+
jobId: job.id,
|
|
126
|
+
deliveryJobNumber: job.deliveryJobNumber,
|
|
127
|
+
status: job.status,
|
|
128
|
+
priority: job.priority,
|
|
129
|
+
deliveryAddress: job.deliveryAddress,
|
|
130
|
+
scheduledDelivery: job.scheduledDelivery ? new Date(job.scheduledDelivery) : undefined,
|
|
131
|
+
specialInstructions: job.specialInstructions ?? undefined,
|
|
132
|
+
customerName: job.customer?.name ?? undefined,
|
|
133
|
+
customerPhone: job.customer?.phone ?? undefined,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Applies acknowledged captures on top of a server snapshot: a stop whose
|
|
139
|
+
* capture the server already acknowledged is shown completed even if a
|
|
140
|
+
* delayed read still reports it en route. This is an in-memory UI fact for
|
|
141
|
+
* the current actor/scope, never a fabricated server update.
|
|
142
|
+
*/
|
|
143
|
+
export function applyAcknowledgedCaptures(
|
|
144
|
+
stops: DriverStop[],
|
|
145
|
+
acknowledged: ReadonlySet<string>
|
|
146
|
+
): DriverDay['stops'] {
|
|
147
|
+
return stops.map((stop) =>
|
|
148
|
+
acknowledged.has(stop.jobId) && stop.state !== 'COMPLETED'
|
|
149
|
+
? { ...stop, state: 'COMPLETED', awaitingCapture: false, status: 'DELIVERED' }
|
|
150
|
+
: stop
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Derived counters and the next stop, recomputed after acknowledgement overrides. */
|
|
155
|
+
export function summariseStops(stops: DriverStop[]) {
|
|
156
|
+
const completedStops = stops.filter(
|
|
157
|
+
(stop) => stop.state === 'COMPLETED' || stop.state === 'FAILED'
|
|
158
|
+
).length;
|
|
159
|
+
return {
|
|
160
|
+
totalStops: stops.length,
|
|
161
|
+
completedStops,
|
|
162
|
+
remainingStops: stops.length - completedStops,
|
|
163
|
+
nextStop:
|
|
164
|
+
stops.find((stop) => stop.awaitingCapture) ??
|
|
165
|
+
stops.find((stop) => stop.state === 'UPCOMING') ??
|
|
166
|
+
null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function useMyDeliveryDay(date: string) {
|
|
171
|
+
const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
|
|
172
|
+
useMoveTheWheels();
|
|
173
|
+
const graphQLOptions = useMemo(
|
|
174
|
+
() => ({
|
|
175
|
+
apiGatewayUrl,
|
|
176
|
+
authToken,
|
|
177
|
+
workspaceToken,
|
|
178
|
+
workspaceId,
|
|
179
|
+
tenantId,
|
|
180
|
+
actorId: currentUser?.id,
|
|
181
|
+
}),
|
|
182
|
+
[apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
|
|
183
|
+
);
|
|
184
|
+
const scopeKey = JSON.stringify([apiGatewayUrl, workspaceId, tenantId, currentUser?.id]);
|
|
185
|
+
const readKey = JSON.stringify([scopeKey, date]);
|
|
186
|
+
const [acknowledgedJobs, setAcknowledgedJobs] = useState<{
|
|
187
|
+
scopeKey: string;
|
|
188
|
+
ids: ReadonlySet<string>;
|
|
189
|
+
}>({ scopeKey, ids: new Set<string>() });
|
|
190
|
+
const requestId = useRef(0);
|
|
191
|
+
const [snapshot, setSnapshot] = useState<{
|
|
192
|
+
readKey: string;
|
|
193
|
+
day: ApiDriverDay | null;
|
|
194
|
+
isLoading: boolean;
|
|
195
|
+
error: Error | null;
|
|
196
|
+
}>({ readKey, day: null, isLoading: true, error: null });
|
|
197
|
+
|
|
198
|
+
const load = useCallback(async () => {
|
|
199
|
+
const request = ++requestId.current;
|
|
200
|
+
setSnapshot((previous) => ({
|
|
201
|
+
readKey,
|
|
202
|
+
day: previous.readKey === readKey ? previous.day : null,
|
|
203
|
+
isLoading: true,
|
|
204
|
+
error: null,
|
|
205
|
+
}));
|
|
206
|
+
try {
|
|
207
|
+
const data = await executeGraphQL<{ myDeliveryDay: ApiDriverDay }>(
|
|
208
|
+
graphQLOptions,
|
|
209
|
+
MY_DELIVERY_DAY_QUERY,
|
|
210
|
+
{ date, timezoneOffsetMinutes: timezoneOffsetFor(date), timeZone: deviceTimeZone() }
|
|
211
|
+
);
|
|
212
|
+
if (request !== requestId.current) return;
|
|
213
|
+
setSnapshot({ readKey, day: data.myDeliveryDay, isLoading: false, error: null });
|
|
214
|
+
} catch (fetchError) {
|
|
215
|
+
if (request !== requestId.current) return;
|
|
216
|
+
setSnapshot((previous) => ({
|
|
217
|
+
readKey,
|
|
218
|
+
// Keep the last good list on screen (e.g. a dropped connection on the road).
|
|
219
|
+
day: previous.readKey === readKey ? previous.day : null,
|
|
220
|
+
isLoading: false,
|
|
221
|
+
error:
|
|
222
|
+
fetchError instanceof Error ? fetchError : new Error('Failed to load your deliveries'),
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
}, [graphQLOptions, date, readKey]);
|
|
226
|
+
|
|
227
|
+
const cancelPendingRead = useCallback(() => {
|
|
228
|
+
requestId.current++;
|
|
229
|
+
}, []);
|
|
230
|
+
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
void load();
|
|
233
|
+
return cancelPendingRead;
|
|
234
|
+
}, [load, cancelPendingRead]);
|
|
235
|
+
|
|
236
|
+
const markCaptured = useCallback(
|
|
237
|
+
(deliveryJobId: string) => {
|
|
238
|
+
setAcknowledgedJobs((previous) => {
|
|
239
|
+
// An actor/workspace switch starts a fresh acknowledgement set.
|
|
240
|
+
const ids = new Set(previous.scopeKey === scopeKey ? previous.ids : []);
|
|
241
|
+
ids.add(deliveryJobId);
|
|
242
|
+
return { scopeKey, ids };
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
[scopeKey]
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const current = snapshot.readKey === readKey ? snapshot : null;
|
|
249
|
+
const currentDay = current?.day ?? null;
|
|
250
|
+
const day = useMemo<DriverDay | null>(() => {
|
|
251
|
+
if (!currentDay) return null;
|
|
252
|
+
const acknowledged =
|
|
253
|
+
acknowledgedJobs.scopeKey === scopeKey ? acknowledgedJobs.ids : new Set<string>();
|
|
254
|
+
const stops = applyAcknowledgedCaptures(currentDay.stops.map(mapStop), acknowledged);
|
|
255
|
+
return {
|
|
256
|
+
date: currentDay.date,
|
|
257
|
+
driverId: currentDay.driverId,
|
|
258
|
+
driverName: currentDay.driverName,
|
|
259
|
+
stops,
|
|
260
|
+
...summariseStops(stops),
|
|
261
|
+
};
|
|
262
|
+
}, [currentDay, scopeKey, acknowledgedJobs]);
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
day,
|
|
266
|
+
isLoading: !current || current.isLoading,
|
|
267
|
+
error: current?.error ?? null,
|
|
268
|
+
refetch: load,
|
|
269
|
+
markCaptured,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Tracks `navigator.onLine` so the driver sees when capture is unavailable. */
|
|
274
|
+
export function useOnlineStatus(): boolean {
|
|
275
|
+
const [online, setOnline] = useState(() =>
|
|
276
|
+
typeof navigator === 'undefined' ? true : navigator.onLine !== false
|
|
277
|
+
);
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
if (typeof window === 'undefined') return;
|
|
280
|
+
const update = () => setOnline(navigator.onLine !== false);
|
|
281
|
+
window.addEventListener('online', update);
|
|
282
|
+
window.addEventListener('offline', update);
|
|
283
|
+
return () => {
|
|
284
|
+
window.removeEventListener('online', update);
|
|
285
|
+
window.removeEventListener('offline', update);
|
|
286
|
+
};
|
|
287
|
+
}, []);
|
|
288
|
+
return online;
|
|
289
|
+
}
|