@burdenoff/microfe-movethewheels 2026.1031.6 → 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/MoveTheWheelsRoot.js +141 -133
- package/dist/MoveTheWheelsRoutes.js +29 -25
- 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/useCarrierIntegrations.js +141 -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/useCarrierIntegrations.d.ts +39 -0
- package/dist/movethewheels/hooks/useDriverDay.d.ts +53 -0
- package/dist/movethewheels/pages/IntegrationsPage.d.ts +6 -0
- package/dist/movethewheels/utils/carrierIntegrations.d.ts +88 -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/copy.js +16 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/pause.js +19 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/play.js +8 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/wifi-off.js +34 -0
- package/dist/pages/DeliveryCapturePage.js +450 -321
- package/dist/pages/IntegrationsPage.js +616 -0
- package/dist/pages/SettingsPage.js +168 -150
- package/dist/utils/carrierIntegrations.js +105 -0
- package/dist/utils/driverNavigation.js +51 -0
- package/dist/utils/formatters.js +18 -9
- package/package.json +4 -2
- package/src/generated/wspace-types.ts +10266 -142
- package/src/movethewheels/MoveTheWheelsRoot.tsx +8 -0
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +4 -0
- package/src/movethewheels/components/EpodCaptureForm.tsx +343 -0
- package/src/movethewheels/hooks/index.ts +9 -0
- package/src/movethewheels/hooks/useCarrierIntegrations.ts +221 -0
- package/src/movethewheels/hooks/useDriverDay.ts +289 -0
- package/src/movethewheels/pages/DeliveryCapturePage.tsx +478 -392
- package/src/movethewheels/pages/IntegrationsPage.tsx +752 -0
- package/src/movethewheels/pages/SettingsPage.tsx +19 -3
- package/src/movethewheels/utils/carrierIntegrations.ts +252 -0
- package/src/movethewheels/utils/driverNavigation.ts +103 -0
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
Store,
|
|
26
26
|
ArrowDownUp,
|
|
27
27
|
Settings,
|
|
28
|
+
Plug,
|
|
28
29
|
} from 'lucide-react';
|
|
29
30
|
import { MoveTheWheelsProvider } from './providers/MoveTheWheelsProvider';
|
|
30
31
|
import { ModuleSettingsProvider } from './providers/ModuleSettingsProvider';
|
|
@@ -309,6 +310,13 @@ const NavRegistrar: FC<Pick<MoveTheWheelsRootProps, 'registerNavItems' | 'basePa
|
|
|
309
310
|
icon: <Bot size={16} />,
|
|
310
311
|
order: 13,
|
|
311
312
|
},
|
|
313
|
+
{
|
|
314
|
+
id: 'mtw-integrations',
|
|
315
|
+
label: tr('movethewheels.root.nav.integrations', 'Integrations'),
|
|
316
|
+
path: joinPath(basePath, 'integrations'),
|
|
317
|
+
icon: <Plug size={16} />,
|
|
318
|
+
order: 98,
|
|
319
|
+
},
|
|
312
320
|
{
|
|
313
321
|
id: 'mtw-settings',
|
|
314
322
|
label: tr('movethewheels.root.nav.settings', 'Settings'),
|
|
@@ -75,6 +75,7 @@ const CustomerDetailsPage = lazy(() => import('./pages/CustomerDetailsPage'));
|
|
|
75
75
|
const MarketplacePage = lazy(() => import('./pages/MarketplacePage'));
|
|
76
76
|
const ImportExportPage = lazy(() => import('./pages/ImportExportPage'));
|
|
77
77
|
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
|
|
78
|
+
const IntegrationsPage = lazy(() => import('./pages/IntegrationsPage'));
|
|
78
79
|
const RoutesPage = lazy(() => import('./pages/RoutesPage'));
|
|
79
80
|
const RouteDetailsPage = lazy(() => import('./pages/RouteDetailsPage'));
|
|
80
81
|
const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage'));
|
|
@@ -172,6 +173,9 @@ export function MoveTheWheelsRoutes() {
|
|
|
172
173
|
{/* Workspace operational defaults: currency, units, time zone (BOFF-10488) */}
|
|
173
174
|
<Route path="settings" element={<SettingsPage />} />
|
|
174
175
|
|
|
176
|
+
{/* Carrier, ELD and GPS tracking feeds as signed webhooks (BOFF-4404) */}
|
|
177
|
+
<Route path="integrations" element={<IntegrationsPage />} />
|
|
178
|
+
|
|
175
179
|
{/* Routes */}
|
|
176
180
|
<Route path="routes" element={<RoutesPage />} />
|
|
177
181
|
<Route path="routes/trip-utilization" element={<TripUtilizationPage />} />
|
|
@@ -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,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carrier and telematics integration hooks (BOFF-4404).
|
|
3
|
+
*
|
|
4
|
+
* Reads and writes the workspace's `MtwIntegration` rows and their delivery
|
|
5
|
+
* log through wspace-movethewheels-svc's integration module. The service
|
|
6
|
+
* validates every write (supported provider, signing-secret length) and never
|
|
7
|
+
* returns the signing secret.
|
|
8
|
+
*/
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
10
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
11
|
+
import { executeGraphQL } from '../utils/graphqlClient';
|
|
12
|
+
import {
|
|
13
|
+
CREATE_INTEGRATION_MUTATION,
|
|
14
|
+
DELETE_INTEGRATION_MUTATION,
|
|
15
|
+
INTEGRATION_COUNTS_QUERY,
|
|
16
|
+
INTEGRATION_EVENTS_QUERY,
|
|
17
|
+
LIST_INTEGRATIONS_QUERY,
|
|
18
|
+
UPDATE_INTEGRATION_MUTATION,
|
|
19
|
+
type CarrierIntegration,
|
|
20
|
+
type CarrierIntegrationEvent,
|
|
21
|
+
type IntegrationStatus,
|
|
22
|
+
type SupportedProvider,
|
|
23
|
+
} from '../utils/carrierIntegrations';
|
|
24
|
+
|
|
25
|
+
export interface CreateCarrierIntegrationInput {
|
|
26
|
+
provider: SupportedProvider;
|
|
27
|
+
name: string;
|
|
28
|
+
apiSecret: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface UpdateCarrierIntegrationInput {
|
|
32
|
+
name?: string;
|
|
33
|
+
status?: IntegrationStatus;
|
|
34
|
+
apiSecret?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function useGraphQLOptions() {
|
|
38
|
+
const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
|
|
39
|
+
useMoveTheWheels();
|
|
40
|
+
return useMemo(
|
|
41
|
+
() => ({
|
|
42
|
+
apiGatewayUrl,
|
|
43
|
+
authToken,
|
|
44
|
+
workspaceToken,
|
|
45
|
+
workspaceId,
|
|
46
|
+
tenantId,
|
|
47
|
+
actorId: currentUser?.id,
|
|
48
|
+
}),
|
|
49
|
+
[apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toError(error: unknown, fallback: string): Error {
|
|
54
|
+
return error instanceof Error ? error : new Error(fallback);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function useCarrierIntegrations(options: {
|
|
58
|
+
status?: IntegrationStatus;
|
|
59
|
+
limit: number;
|
|
60
|
+
offset: number;
|
|
61
|
+
}) {
|
|
62
|
+
const graphQLOptions = useGraphQLOptions();
|
|
63
|
+
const { status, limit, offset } = options;
|
|
64
|
+
const [integrations, setIntegrations] = useState<CarrierIntegration[]>([]);
|
|
65
|
+
const [total, setTotal] = useState(0);
|
|
66
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
67
|
+
const [error, setError] = useState<Error | null>(null);
|
|
68
|
+
// Only the latest request may update state: a slower response for a
|
|
69
|
+
// previous filter or page must not overwrite the current view.
|
|
70
|
+
const latestRequest = useRef(0);
|
|
71
|
+
|
|
72
|
+
const load = useCallback(async () => {
|
|
73
|
+
const request = ++latestRequest.current;
|
|
74
|
+
setIsLoading(true);
|
|
75
|
+
setError(null);
|
|
76
|
+
try {
|
|
77
|
+
const data = await executeGraphQL<{
|
|
78
|
+
integrations: CarrierIntegration[];
|
|
79
|
+
integrationsCount: number;
|
|
80
|
+
}>(graphQLOptions, LIST_INTEGRATIONS_QUERY, {
|
|
81
|
+
filter: status ? { status } : undefined,
|
|
82
|
+
pagination: { limit, offset },
|
|
83
|
+
});
|
|
84
|
+
if (request !== latestRequest.current) return;
|
|
85
|
+
setIntegrations(data.integrations);
|
|
86
|
+
setTotal(data.integrationsCount);
|
|
87
|
+
} catch (loadError) {
|
|
88
|
+
if (request !== latestRequest.current) return;
|
|
89
|
+
setError(toError(loadError, 'Failed to load integrations'));
|
|
90
|
+
} finally {
|
|
91
|
+
if (request === latestRequest.current) setIsLoading(false);
|
|
92
|
+
}
|
|
93
|
+
}, [graphQLOptions, status, limit, offset]);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
void load();
|
|
97
|
+
return () => {
|
|
98
|
+
// Invalidate the in-flight request when the filter, page or unmount changes.
|
|
99
|
+
latestRequest.current += 1;
|
|
100
|
+
};
|
|
101
|
+
}, [load]);
|
|
102
|
+
|
|
103
|
+
// A refetch requested by a mutation that started under an earlier filter or
|
|
104
|
+
// page must load the view that is current when it completes, not the one
|
|
105
|
+
// captured when the mutation started.
|
|
106
|
+
const latestLoad = useRef(load);
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
latestLoad.current = load;
|
|
109
|
+
}, [load]);
|
|
110
|
+
const refetch = useCallback(() => latestLoad.current(), []);
|
|
111
|
+
|
|
112
|
+
return { integrations, total, isLoading, error, refetch };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Workspace-wide feed counts (all and active) in one request, independent of the list filter. */
|
|
116
|
+
export function useCarrierIntegrationCounts() {
|
|
117
|
+
const graphQLOptions = useGraphQLOptions();
|
|
118
|
+
const [counts, setCounts] = useState<{ all: number; active: number }>({ all: 0, active: 0 });
|
|
119
|
+
const latestRequest = useRef(0);
|
|
120
|
+
|
|
121
|
+
const load = useCallback(async () => {
|
|
122
|
+
const request = ++latestRequest.current;
|
|
123
|
+
try {
|
|
124
|
+
const data = await executeGraphQL<{ all: number; active: number }>(
|
|
125
|
+
graphQLOptions,
|
|
126
|
+
INTEGRATION_COUNTS_QUERY,
|
|
127
|
+
{}
|
|
128
|
+
);
|
|
129
|
+
if (request === latestRequest.current) setCounts({ all: data.all, active: data.active });
|
|
130
|
+
} catch {
|
|
131
|
+
// The list request surfaces load errors; the counts keep their last value.
|
|
132
|
+
}
|
|
133
|
+
}, [graphQLOptions]);
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
void load();
|
|
137
|
+
return () => {
|
|
138
|
+
latestRequest.current += 1;
|
|
139
|
+
};
|
|
140
|
+
}, [load]);
|
|
141
|
+
|
|
142
|
+
return { ...counts, refetch: load };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The most recent deliveries for one integration, newest first. */
|
|
146
|
+
export function useCarrierIntegrationEvents(
|
|
147
|
+
integrationId: string | null,
|
|
148
|
+
refreshKey: number,
|
|
149
|
+
limit = 20
|
|
150
|
+
) {
|
|
151
|
+
const graphQLOptions = useGraphQLOptions();
|
|
152
|
+
const [events, setEvents] = useState<CarrierIntegrationEvent[]>([]);
|
|
153
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
154
|
+
const [error, setError] = useState<Error | null>(null);
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (!integrationId) {
|
|
158
|
+
setEvents([]);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
let cancelled = false;
|
|
162
|
+
setIsLoading(true);
|
|
163
|
+
setError(null);
|
|
164
|
+
executeGraphQL<{ integrationEvents: CarrierIntegrationEvent[] }>(
|
|
165
|
+
graphQLOptions,
|
|
166
|
+
INTEGRATION_EVENTS_QUERY,
|
|
167
|
+
{ integrationId, pagination: { limit, offset: 0 } }
|
|
168
|
+
)
|
|
169
|
+
.then((data) => {
|
|
170
|
+
if (!cancelled) setEvents(data.integrationEvents);
|
|
171
|
+
})
|
|
172
|
+
.catch((loadError: unknown) => {
|
|
173
|
+
if (!cancelled) setError(toError(loadError, 'Failed to load deliveries'));
|
|
174
|
+
})
|
|
175
|
+
.finally(() => {
|
|
176
|
+
if (!cancelled) setIsLoading(false);
|
|
177
|
+
});
|
|
178
|
+
return () => {
|
|
179
|
+
cancelled = true;
|
|
180
|
+
};
|
|
181
|
+
}, [graphQLOptions, integrationId, refreshKey, limit]);
|
|
182
|
+
|
|
183
|
+
return { events, isLoading, error };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function useCarrierIntegrationActions() {
|
|
187
|
+
const graphQLOptions = useGraphQLOptions();
|
|
188
|
+
|
|
189
|
+
return useMemo(
|
|
190
|
+
() => ({
|
|
191
|
+
create: async (input: CreateCarrierIntegrationInput) => {
|
|
192
|
+
// A lost response must not create a second integration with a secret
|
|
193
|
+
// the operator never saw, so the write is attempted once.
|
|
194
|
+
const data = await executeGraphQL<{ createIntegration: CarrierIntegration }>(
|
|
195
|
+
graphQLOptions,
|
|
196
|
+
CREATE_INTEGRATION_MUTATION,
|
|
197
|
+
{ input: { ...input, name: input.name.trim(), status: 'ACTIVE' } },
|
|
198
|
+
'SINGLE_ATTEMPT'
|
|
199
|
+
);
|
|
200
|
+
return data.createIntegration;
|
|
201
|
+
},
|
|
202
|
+
update: async (id: string, input: UpdateCarrierIntegrationInput) => {
|
|
203
|
+
const data = await executeGraphQL<{ updateIntegration: CarrierIntegration }>(
|
|
204
|
+
graphQLOptions,
|
|
205
|
+
UPDATE_INTEGRATION_MUTATION,
|
|
206
|
+
{ id, input: input.name !== undefined ? { ...input, name: input.name.trim() } : input }
|
|
207
|
+
);
|
|
208
|
+
return data.updateIntegration;
|
|
209
|
+
},
|
|
210
|
+
remove: async (id: string) => {
|
|
211
|
+
const data = await executeGraphQL<{ deleteIntegration: boolean }>(
|
|
212
|
+
graphQLOptions,
|
|
213
|
+
DELETE_INTEGRATION_MUTATION,
|
|
214
|
+
{ id }
|
|
215
|
+
);
|
|
216
|
+
return data.deleteIntegration;
|
|
217
|
+
},
|
|
218
|
+
}),
|
|
219
|
+
[graphQLOptions]
|
|
220
|
+
);
|
|
221
|
+
}
|