@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
|
@@ -1,460 +1,546 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* capture
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* (
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* driver
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
198
|
-
<
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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=
|
|
265
|
-
|
|
266
|
-
onClick={() =>
|
|
253
|
+
variant="outline"
|
|
254
|
+
disabled={!navigateUrl}
|
|
255
|
+
onClick={() => openExternal(navigateUrl)}
|
|
267
256
|
>
|
|
268
|
-
<
|
|
257
|
+
<Navigation size={16} /> Navigate
|
|
269
258
|
</Button>
|
|
270
259
|
<Button
|
|
271
260
|
type="button"
|
|
272
|
-
variant=
|
|
273
|
-
|
|
274
|
-
onClick={() =>
|
|
261
|
+
variant="outline"
|
|
262
|
+
disabled={!callUrl}
|
|
263
|
+
onClick={() => {
|
|
264
|
+
if (callUrl) window.location.href = callUrl;
|
|
265
|
+
}}
|
|
275
266
|
>
|
|
276
|
-
<
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
361
|
-
const
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
327
|
+
markCaptured(deliveryJobId);
|
|
372
328
|
notify('Proof of delivery captured — delivery marked complete.');
|
|
373
|
-
|
|
374
|
-
|
|
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="
|
|
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
|
-
{
|
|
388
|
-
<
|
|
389
|
-
|
|
390
|
-
|
|
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={() =>
|
|
370
|
+
onCancel={() => setView({ kind: 'stop', jobId: selected.jobId })}
|
|
393
371
|
/>
|
|
394
|
-
) :
|
|
395
|
-
<
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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>
|