@burdenoff/microfe-movethewheels 2026.726.1 → 2026.731.1
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/MoveTheWheelsRoutes.js +11 -3
- package/dist/components/ProofOfDeliveryCard.js +155 -0
- package/dist/components/SignaturePad.js +82 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useEpod.js +208 -0
- package/dist/hooks/useMultiModalShipments.js +124 -0
- package/dist/index.js +22 -21
- package/dist/movethewheels/components/ProofOfDeliveryCard.d.ts +5 -0
- package/dist/movethewheels/components/SignaturePad.d.ts +11 -0
- package/dist/movethewheels/hooks/index.d.ts +1 -0
- package/dist/movethewheels/hooks/useEpod.d.ts +59 -0
- package/dist/movethewheels/hooks/useMultiModalShipments.d.ts +76 -0
- package/dist/movethewheels/pages/DeliveryCapturePage.d.ts +1 -0
- package/dist/movethewheels/pages/MultiModalShipmentPage.d.ts +8 -0
- package/dist/movethewheels/types/index.d.ts +18 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/calculator.js +56 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/loader-circle.js +8 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/pen-line.js +11 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/plane.js +8 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/ship.js +26 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/train-front.js +30 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/type.js +24 -0
- package/dist/pages/DashboardPage.js +156 -151
- package/dist/pages/DeliveryCapturePage.js +245 -0
- package/dist/pages/MultiModalShipmentPage.js +420 -0
- package/dist/pages/OrderDetailsPage.js +14 -12
- package/dist/pages/TrackingPage.js +156 -154
- package/dist/utils/formatters.js +13 -2
- package/package.json +1 -1
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +8 -0
- package/src/movethewheels/components/ProofOfDeliveryCard.tsx +149 -0
- package/src/movethewheels/components/SignaturePad.tsx +157 -0
- package/src/movethewheels/hooks/index.ts +12 -0
- package/src/movethewheels/hooks/useEpod.ts +410 -0
- package/src/movethewheels/hooks/useMultiModalShipments.ts +323 -0
- package/src/movethewheels/pages/DashboardPage.tsx +6 -0
- package/src/movethewheels/pages/DeliveryCapturePage.tsx +297 -0
- package/src/movethewheels/pages/MultiModalShipmentPage.tsx +552 -0
- package/src/movethewheels/pages/OrderDetailsPage.tsx +4 -0
- package/src/movethewheels/pages/TrackingPage.tsx +4 -0
- package/src/movethewheels/types/index.ts +21 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-modal freight execution (BOFF-4398).
|
|
3
|
+
*
|
|
4
|
+
* Wire shape matches wspace-movethewheels-svc: src/modules/shipment/schema/shipment.gql
|
|
5
|
+
* (MtwShipment, MtwShipmentLeg, MtwFreightRateQuote). Field names here are kept in
|
|
6
|
+
* lockstep with that schema — verify against the deployed schema before renaming
|
|
7
|
+
* anything (see BOFF-3922 schema-drift guard / prior MFE-schema-drift incidents).
|
|
8
|
+
*/
|
|
9
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
10
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
11
|
+
import { executeGraphQL } from '../utils/graphqlClient';
|
|
12
|
+
|
|
13
|
+
export type TransportMode = 'ROAD' | 'AIR' | 'OCEAN' | 'RAIL';
|
|
14
|
+
export type ShipmentLegStatus =
|
|
15
|
+
'PLANNED' | 'BOOKED' | 'IN_TRANSIT' | 'ARRIVED' | 'DELIVERED' | 'CANCELLED';
|
|
16
|
+
export type OceanContainerType = 'LCL' | 'FCL_20FT' | 'FCL_40FT' | 'FCL_40FT_HC';
|
|
17
|
+
|
|
18
|
+
export interface FreightRateBreakdownLine {
|
|
19
|
+
label: string;
|
|
20
|
+
amount: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FreightRateQuote {
|
|
24
|
+
mode: TransportMode;
|
|
25
|
+
chargeableWeightKg?: number | null;
|
|
26
|
+
ratePerUnit: number;
|
|
27
|
+
calculatedCost: number;
|
|
28
|
+
currency: string;
|
|
29
|
+
breakdown: FreightRateBreakdownLine[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ShipmentLegDraft {
|
|
33
|
+
mode: TransportMode;
|
|
34
|
+
origin: { address?: string };
|
|
35
|
+
destination: { address?: string };
|
|
36
|
+
distanceKm?: number;
|
|
37
|
+
weightKg?: number;
|
|
38
|
+
lengthCm?: number;
|
|
39
|
+
widthCm?: number;
|
|
40
|
+
heightCm?: number;
|
|
41
|
+
volumeCbm?: number;
|
|
42
|
+
containerType?: OceanContainerType;
|
|
43
|
+
containerCount?: number;
|
|
44
|
+
carrierName?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ShipmentLeg extends ShipmentLegDraft {
|
|
48
|
+
id: string;
|
|
49
|
+
legNumber: number;
|
|
50
|
+
status: ShipmentLegStatus;
|
|
51
|
+
chargeableWeightKg?: number | null;
|
|
52
|
+
calculatedCost?: number | null;
|
|
53
|
+
currency: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface MultiModalShipment {
|
|
57
|
+
id: string;
|
|
58
|
+
shipmentNumber: string;
|
|
59
|
+
status: string;
|
|
60
|
+
primaryMode: TransportMode;
|
|
61
|
+
isMultiModal: boolean;
|
|
62
|
+
totalFreightCost?: number | null;
|
|
63
|
+
freightCurrency?: string | null;
|
|
64
|
+
origin: { address?: string };
|
|
65
|
+
destination: { address?: string };
|
|
66
|
+
createdAt: string;
|
|
67
|
+
legs: ShipmentLeg[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ApiShipmentLeg {
|
|
71
|
+
id: string;
|
|
72
|
+
legNumber: number;
|
|
73
|
+
mode: TransportMode;
|
|
74
|
+
status: ShipmentLegStatus;
|
|
75
|
+
origin: Record<string, unknown> | null;
|
|
76
|
+
destination: Record<string, unknown> | null;
|
|
77
|
+
distanceKm?: number | null;
|
|
78
|
+
weightKg?: number | null;
|
|
79
|
+
lengthCm?: number | null;
|
|
80
|
+
widthCm?: number | null;
|
|
81
|
+
heightCm?: number | null;
|
|
82
|
+
volumeCbm?: number | null;
|
|
83
|
+
containerType?: OceanContainerType | null;
|
|
84
|
+
containerCount?: number | null;
|
|
85
|
+
carrierName?: string | null;
|
|
86
|
+
chargeableWeightKg?: number | null;
|
|
87
|
+
calculatedCost?: number | null;
|
|
88
|
+
currency: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface ApiShipment {
|
|
92
|
+
id: string;
|
|
93
|
+
shipmentNumber: string;
|
|
94
|
+
status: string;
|
|
95
|
+
primaryMode: TransportMode;
|
|
96
|
+
isMultiModal: boolean;
|
|
97
|
+
totalFreightCost?: number | null;
|
|
98
|
+
freightCurrency?: string | null;
|
|
99
|
+
origin: Record<string, unknown> | null;
|
|
100
|
+
destination: Record<string, unknown> | null;
|
|
101
|
+
createdAt: string;
|
|
102
|
+
legs: ApiShipmentLeg[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const LEG_FIELDS = `
|
|
106
|
+
id
|
|
107
|
+
legNumber
|
|
108
|
+
mode
|
|
109
|
+
status
|
|
110
|
+
origin
|
|
111
|
+
destination
|
|
112
|
+
distanceKm
|
|
113
|
+
weightKg
|
|
114
|
+
lengthCm
|
|
115
|
+
widthCm
|
|
116
|
+
heightCm
|
|
117
|
+
volumeCbm
|
|
118
|
+
containerType
|
|
119
|
+
containerCount
|
|
120
|
+
carrierName
|
|
121
|
+
chargeableWeightKg
|
|
122
|
+
calculatedCost
|
|
123
|
+
currency
|
|
124
|
+
`;
|
|
125
|
+
|
|
126
|
+
const SHIPMENT_FIELDS = `
|
|
127
|
+
id
|
|
128
|
+
shipmentNumber
|
|
129
|
+
status
|
|
130
|
+
primaryMode
|
|
131
|
+
isMultiModal
|
|
132
|
+
totalFreightCost
|
|
133
|
+
freightCurrency
|
|
134
|
+
origin
|
|
135
|
+
destination
|
|
136
|
+
createdAt
|
|
137
|
+
legs {
|
|
138
|
+
${LEG_FIELDS}
|
|
139
|
+
}
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
const MULTI_MODAL_SHIPMENTS_QUERY = `
|
|
143
|
+
query ListMultiModalShipments($pagination: PaginationInput) {
|
|
144
|
+
shipments(filter: { isMultiModal: true }, pagination: $pagination) {
|
|
145
|
+
${SHIPMENT_FIELDS}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
const CALCULATE_FREIGHT_RATE_QUERY = `
|
|
151
|
+
query CalculateFreightRate($input: FreightRateInput!) {
|
|
152
|
+
calculateFreightRate(input: $input) {
|
|
153
|
+
mode
|
|
154
|
+
chargeableWeightKg
|
|
155
|
+
ratePerUnit
|
|
156
|
+
calculatedCost
|
|
157
|
+
currency
|
|
158
|
+
breakdown {
|
|
159
|
+
label
|
|
160
|
+
amount
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
`;
|
|
165
|
+
|
|
166
|
+
const PLAN_MULTI_MODAL_SHIPMENT_MUTATION = `
|
|
167
|
+
mutation PlanMultiModalShipment($input: PlanMultiModalShipmentInput!) {
|
|
168
|
+
planMultiModalShipment(input: $input) {
|
|
169
|
+
${SHIPMENT_FIELDS}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
`;
|
|
173
|
+
|
|
174
|
+
function toApiLeg(leg: ShipmentLegDraft) {
|
|
175
|
+
return {
|
|
176
|
+
mode: leg.mode,
|
|
177
|
+
origin: leg.origin,
|
|
178
|
+
destination: leg.destination,
|
|
179
|
+
distanceKm: leg.distanceKm,
|
|
180
|
+
weightKg: leg.weightKg,
|
|
181
|
+
lengthCm: leg.lengthCm,
|
|
182
|
+
widthCm: leg.widthCm,
|
|
183
|
+
heightCm: leg.heightCm,
|
|
184
|
+
volumeCbm: leg.volumeCbm,
|
|
185
|
+
containerType: leg.containerType,
|
|
186
|
+
containerCount: leg.containerCount,
|
|
187
|
+
carrierName: leg.carrierName,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function mapLeg(leg: ApiShipmentLeg): ShipmentLeg {
|
|
192
|
+
return {
|
|
193
|
+
id: leg.id,
|
|
194
|
+
legNumber: leg.legNumber,
|
|
195
|
+
mode: leg.mode,
|
|
196
|
+
status: leg.status,
|
|
197
|
+
origin: (leg.origin as { address?: string } | null) ?? {},
|
|
198
|
+
destination: (leg.destination as { address?: string } | null) ?? {},
|
|
199
|
+
distanceKm: leg.distanceKm ?? undefined,
|
|
200
|
+
weightKg: leg.weightKg ?? undefined,
|
|
201
|
+
lengthCm: leg.lengthCm ?? undefined,
|
|
202
|
+
widthCm: leg.widthCm ?? undefined,
|
|
203
|
+
heightCm: leg.heightCm ?? undefined,
|
|
204
|
+
volumeCbm: leg.volumeCbm ?? undefined,
|
|
205
|
+
containerType: leg.containerType ?? undefined,
|
|
206
|
+
containerCount: leg.containerCount ?? undefined,
|
|
207
|
+
carrierName: leg.carrierName ?? undefined,
|
|
208
|
+
chargeableWeightKg: leg.chargeableWeightKg,
|
|
209
|
+
calculatedCost: leg.calculatedCost,
|
|
210
|
+
currency: leg.currency,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function mapShipment(shipment: ApiShipment): MultiModalShipment {
|
|
215
|
+
return {
|
|
216
|
+
id: shipment.id,
|
|
217
|
+
shipmentNumber: shipment.shipmentNumber,
|
|
218
|
+
status: shipment.status,
|
|
219
|
+
primaryMode: shipment.primaryMode,
|
|
220
|
+
isMultiModal: shipment.isMultiModal,
|
|
221
|
+
totalFreightCost: shipment.totalFreightCost,
|
|
222
|
+
freightCurrency: shipment.freightCurrency,
|
|
223
|
+
origin: (shipment.origin as { address?: string } | null) ?? {},
|
|
224
|
+
destination: (shipment.destination as { address?: string } | null) ?? {},
|
|
225
|
+
createdAt: shipment.createdAt,
|
|
226
|
+
legs: (shipment.legs ?? []).map(mapLeg),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function useGraphqlOptions() {
|
|
231
|
+
const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
|
|
232
|
+
useMoveTheWheels();
|
|
233
|
+
return useMemo(
|
|
234
|
+
() => ({
|
|
235
|
+
apiGatewayUrl,
|
|
236
|
+
authToken,
|
|
237
|
+
workspaceToken,
|
|
238
|
+
workspaceId,
|
|
239
|
+
tenantId,
|
|
240
|
+
actorId: currentUser?.id,
|
|
241
|
+
}),
|
|
242
|
+
[apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function useMultiModalShipments() {
|
|
247
|
+
const graphQLOptions = useGraphqlOptions();
|
|
248
|
+
const [shipments, setShipments] = useState<MultiModalShipment[]>([]);
|
|
249
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
250
|
+
const [error, setError] = useState<Error | null>(null);
|
|
251
|
+
|
|
252
|
+
const load = useCallback(async () => {
|
|
253
|
+
setIsLoading(true);
|
|
254
|
+
setError(null);
|
|
255
|
+
try {
|
|
256
|
+
const data = await executeGraphQL<{ shipments: ApiShipment[] }>(
|
|
257
|
+
graphQLOptions,
|
|
258
|
+
MULTI_MODAL_SHIPMENTS_QUERY,
|
|
259
|
+
{ pagination: { limit: 100, offset: 0 } }
|
|
260
|
+
);
|
|
261
|
+
setShipments(data.shipments.map(mapShipment));
|
|
262
|
+
} catch (fetchError) {
|
|
263
|
+
setError(
|
|
264
|
+
fetchError instanceof Error ? fetchError : new Error('Failed to load multi-modal shipments')
|
|
265
|
+
);
|
|
266
|
+
} finally {
|
|
267
|
+
setIsLoading(false);
|
|
268
|
+
}
|
|
269
|
+
}, [graphQLOptions]);
|
|
270
|
+
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
void load();
|
|
273
|
+
}, [load]);
|
|
274
|
+
|
|
275
|
+
const calculateFreightRate = useCallback(
|
|
276
|
+
async (leg: ShipmentLegDraft): Promise<FreightRateQuote> => {
|
|
277
|
+
const data = await executeGraphQL<{ calculateFreightRate: FreightRateQuote }>(
|
|
278
|
+
graphQLOptions,
|
|
279
|
+
CALCULATE_FREIGHT_RATE_QUERY,
|
|
280
|
+
{ input: toApiLeg(leg) }
|
|
281
|
+
);
|
|
282
|
+
return data.calculateFreightRate;
|
|
283
|
+
},
|
|
284
|
+
[graphQLOptions]
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const planMultiModalShipment = useCallback(
|
|
288
|
+
async (input: {
|
|
289
|
+
shipmentNumber: string;
|
|
290
|
+
customerId?: string;
|
|
291
|
+
origin: { address?: string };
|
|
292
|
+
destination: { address?: string };
|
|
293
|
+
legs: ShipmentLegDraft[];
|
|
294
|
+
}): Promise<MultiModalShipment> => {
|
|
295
|
+
const data = await executeGraphQL<{ planMultiModalShipment: ApiShipment }>(
|
|
296
|
+
graphQLOptions,
|
|
297
|
+
PLAN_MULTI_MODAL_SHIPMENT_MUTATION,
|
|
298
|
+
{
|
|
299
|
+
input: {
|
|
300
|
+
shipmentNumber: input.shipmentNumber,
|
|
301
|
+
customerId: input.customerId,
|
|
302
|
+
origin: input.origin,
|
|
303
|
+
destination: input.destination,
|
|
304
|
+
legs: input.legs.map(toApiLeg),
|
|
305
|
+
},
|
|
306
|
+
}
|
|
307
|
+
);
|
|
308
|
+
const created = mapShipment(data.planMultiModalShipment);
|
|
309
|
+
setShipments((previous) => [created, ...previous]);
|
|
310
|
+
return created;
|
|
311
|
+
},
|
|
312
|
+
[graphQLOptions]
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
shipments,
|
|
317
|
+
isLoading,
|
|
318
|
+
error,
|
|
319
|
+
refetch: () => void load(),
|
|
320
|
+
calculateFreightRate,
|
|
321
|
+
planMultiModalShipment,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
MapPin,
|
|
17
17
|
ChevronRight,
|
|
18
18
|
Activity,
|
|
19
|
+
Ship,
|
|
19
20
|
} from 'lucide-react';
|
|
20
21
|
import { PageLayout, PageSection } from '../components/PageLayout';
|
|
21
22
|
import { MetricCard, MetricGrid, type MetricDelta } from '../components/MetricCard';
|
|
@@ -158,6 +159,11 @@ export default function DashboardPage() {
|
|
|
158
159
|
icon: <MapPin size={16} />,
|
|
159
160
|
onSelect: () => goTo('tracking/live'),
|
|
160
161
|
},
|
|
162
|
+
{
|
|
163
|
+
label: t('movethewheels.dashboard.actions.multiModal', 'Multi-Modal Freight'),
|
|
164
|
+
icon: <Ship size={16} />,
|
|
165
|
+
onSelect: () => goTo('shipments/multi-modal'),
|
|
166
|
+
},
|
|
161
167
|
]}
|
|
162
168
|
/>
|
|
163
169
|
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery Capture Page — driver-facing electronic proof-of-delivery (ePOD)
|
|
3
|
+
* capture flow (BOFF-4399).
|
|
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.
|
|
12
|
+
*
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
import { useRef, useState } from 'react';
|
|
19
|
+
import {
|
|
20
|
+
CheckCircle2,
|
|
21
|
+
Loader2,
|
|
22
|
+
MapPin,
|
|
23
|
+
Package,
|
|
24
|
+
PenLine,
|
|
25
|
+
Truck,
|
|
26
|
+
Type as TypeIcon,
|
|
27
|
+
Upload,
|
|
28
|
+
} from 'lucide-react';
|
|
29
|
+
import { PageLayout } from '../components/PageLayout';
|
|
30
|
+
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Textarea } from '../components/ui';
|
|
31
|
+
import { SignaturePad, type SignaturePadHandle } from '../components/SignaturePad';
|
|
32
|
+
import { FormError } from '../components/FormError';
|
|
33
|
+
import { Notice, useNotice } from '../components/Notice';
|
|
34
|
+
import {
|
|
35
|
+
useCaptureEpod,
|
|
36
|
+
useEpodPhotoUpload,
|
|
37
|
+
useMyPendingEpodDeliveryJobs,
|
|
38
|
+
type PendingEpodDeliveryJob,
|
|
39
|
+
} from '../hooks/useEpod';
|
|
40
|
+
import { formatAddress, formatDate } from '../utils/formatters';
|
|
41
|
+
import type { SignatureType } from '../types';
|
|
42
|
+
|
|
43
|
+
/** Resolves the device's current position, if the browser grants it. Never blocks submission. */
|
|
44
|
+
function getCurrentPosition(): Promise<GeolocationPosition | null> {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
if (!navigator.geolocation) {
|
|
47
|
+
resolve(null);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
navigator.geolocation.getCurrentPosition(
|
|
51
|
+
(position) => resolve(position),
|
|
52
|
+
() => resolve(null),
|
|
53
|
+
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 30000 }
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function CaptureForm({
|
|
59
|
+
job,
|
|
60
|
+
onCaptured,
|
|
61
|
+
onCancel,
|
|
62
|
+
}: {
|
|
63
|
+
job: PendingEpodDeliveryJob;
|
|
64
|
+
onCaptured: () => void;
|
|
65
|
+
onCancel: () => void;
|
|
66
|
+
}) {
|
|
67
|
+
const { captureEpod, isSubmitting, error: captureError } = useCaptureEpod();
|
|
68
|
+
const { upload: uploadPhoto, isUploading, error: uploadError } = useEpodPhotoUpload();
|
|
69
|
+
const signaturePadRef = useRef<SignaturePadHandle>(null);
|
|
70
|
+
|
|
71
|
+
const [recipientName, setRecipientName] = useState('');
|
|
72
|
+
const [signatureType, setSignatureType] = useState<SignatureType>('drawn');
|
|
73
|
+
const [typedSignature, setTypedSignature] = useState('');
|
|
74
|
+
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
|
75
|
+
const [notes, setNotes] = useState('');
|
|
76
|
+
const [validationError, setValidationError] = useState<string | null>(null);
|
|
77
|
+
|
|
78
|
+
const handleSubmit = async () => {
|
|
79
|
+
setValidationError(null);
|
|
80
|
+
|
|
81
|
+
const trimmedRecipient = recipientName.trim();
|
|
82
|
+
if (!trimmedRecipient) {
|
|
83
|
+
setValidationError('Recipient name is required.');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const signatureData =
|
|
88
|
+
signatureType === 'typed' ? typedSignature.trim() : signaturePadRef.current?.toDataUrl() ?? null;
|
|
89
|
+
if (!signatureData) {
|
|
90
|
+
setValidationError(
|
|
91
|
+
signatureType === 'typed' ? 'Type the recipient full name as their signature.' : 'A signature is required.'
|
|
92
|
+
);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
let photoFileIds: string[] | undefined;
|
|
98
|
+
if (photoFile) {
|
|
99
|
+
const uploaded = await uploadPhoto(photoFile);
|
|
100
|
+
photoFileIds = [uploaded.fileId];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const position = await getCurrentPosition();
|
|
104
|
+
|
|
105
|
+
await captureEpod({
|
|
106
|
+
deliveryJobId: job.id,
|
|
107
|
+
recipientName: trimmedRecipient,
|
|
108
|
+
signatureType,
|
|
109
|
+
signatureData,
|
|
110
|
+
photoFileIds,
|
|
111
|
+
latitude: position?.coords.latitude,
|
|
112
|
+
longitude: position?.coords.longitude,
|
|
113
|
+
locationAccuracy: position?.coords.accuracy,
|
|
114
|
+
notes: notes.trim() || undefined,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
onCaptured();
|
|
118
|
+
} catch {
|
|
119
|
+
// captureError / uploadError already carry the message for display below.
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const submitting = isSubmitting || isUploading;
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<Card>
|
|
127
|
+
<CardHeader>
|
|
128
|
+
<CardTitle className="flex items-center gap-2">
|
|
129
|
+
<Truck size={18} /> Capture delivery for {job.deliveryJobNumber}
|
|
130
|
+
</CardTitle>
|
|
131
|
+
</CardHeader>
|
|
132
|
+
<CardContent className="space-y-4">
|
|
133
|
+
<div className="text-sm text-text-secondary space-y-1">
|
|
134
|
+
{job.customerName && <p>Recipient on file: {job.customerName}</p>}
|
|
135
|
+
{job.deliveryAddress && <p>{formatAddress(job.deliveryAddress)}</p>}
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div className="space-y-1.5">
|
|
139
|
+
<Label htmlFor="epod-recipient-name">Recipient name</Label>
|
|
140
|
+
<Input
|
|
141
|
+
id="epod-recipient-name"
|
|
142
|
+
value={recipientName}
|
|
143
|
+
onChange={(event) => setRecipientName(event.target.value)}
|
|
144
|
+
placeholder="Who received the delivery?"
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div className="space-y-1.5">
|
|
149
|
+
<Label>Signature</Label>
|
|
150
|
+
<div className="flex gap-2">
|
|
151
|
+
<Button
|
|
152
|
+
type="button"
|
|
153
|
+
variant={signatureType === 'drawn' ? 'default' : 'outline'}
|
|
154
|
+
size="sm"
|
|
155
|
+
onClick={() => setSignatureType('drawn')}
|
|
156
|
+
>
|
|
157
|
+
<PenLine size={14} /> Draw
|
|
158
|
+
</Button>
|
|
159
|
+
<Button
|
|
160
|
+
type="button"
|
|
161
|
+
variant={signatureType === 'typed' ? 'default' : 'outline'}
|
|
162
|
+
size="sm"
|
|
163
|
+
onClick={() => setSignatureType('typed')}
|
|
164
|
+
>
|
|
165
|
+
<TypeIcon size={14} /> Type name instead
|
|
166
|
+
</Button>
|
|
167
|
+
</div>
|
|
168
|
+
{signatureType === 'drawn' ? (
|
|
169
|
+
<SignaturePad ref={signaturePadRef} />
|
|
170
|
+
) : (
|
|
171
|
+
<Input
|
|
172
|
+
value={typedSignature}
|
|
173
|
+
onChange={(event) => setTypedSignature(event.target.value)}
|
|
174
|
+
placeholder="Type full name as signature"
|
|
175
|
+
/>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<div className="space-y-1.5">
|
|
180
|
+
<Label htmlFor="epod-photo">Delivery photo (optional)</Label>
|
|
181
|
+
<div className="flex items-center gap-2">
|
|
182
|
+
<input
|
|
183
|
+
id="epod-photo"
|
|
184
|
+
type="file"
|
|
185
|
+
accept="image/*"
|
|
186
|
+
capture="environment"
|
|
187
|
+
onChange={(event) => setPhotoFile(event.target.files?.[0] ?? null)}
|
|
188
|
+
className="text-sm"
|
|
189
|
+
/>
|
|
190
|
+
{photoFile && <Upload size={14} className="text-text-secondary" />}
|
|
191
|
+
</div>
|
|
192
|
+
{photoFile && <p className="text-xs text-text-secondary">{photoFile.name}</p>}
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div className="space-y-1.5">
|
|
196
|
+
<Label htmlFor="epod-notes">Notes (optional)</Label>
|
|
197
|
+
<Textarea
|
|
198
|
+
id="epod-notes"
|
|
199
|
+
value={notes}
|
|
200
|
+
onChange={(event) => setNotes(event.target.value)}
|
|
201
|
+
placeholder="e.g. Left with front desk, gate code used, etc."
|
|
202
|
+
rows={2}
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<p className="flex items-center gap-1.5 text-xs text-text-secondary">
|
|
207
|
+
<MapPin size={12} /> Device GPS is captured automatically when permitted by the browser.
|
|
208
|
+
</p>
|
|
209
|
+
|
|
210
|
+
{validationError && <FormError message={validationError} />}
|
|
211
|
+
{captureError && <FormError message={captureError.message} />}
|
|
212
|
+
{uploadError && <FormError message={uploadError.message} />}
|
|
213
|
+
|
|
214
|
+
<div className="flex gap-2 justify-end">
|
|
215
|
+
<Button type="button" variant="outline" onClick={onCancel} disabled={submitting}>
|
|
216
|
+
Cancel
|
|
217
|
+
</Button>
|
|
218
|
+
<Button type="button" onClick={() => void handleSubmit()} disabled={submitting}>
|
|
219
|
+
{submitting ? (
|
|
220
|
+
<>
|
|
221
|
+
<Loader2 size={14} className="animate-spin" /> Submitting…
|
|
222
|
+
</>
|
|
223
|
+
) : (
|
|
224
|
+
<>
|
|
225
|
+
<CheckCircle2 size={14} /> Confirm delivery
|
|
226
|
+
</>
|
|
227
|
+
)}
|
|
228
|
+
</Button>
|
|
229
|
+
</div>
|
|
230
|
+
</CardContent>
|
|
231
|
+
</Card>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export default function DeliveryCapturePage() {
|
|
236
|
+
const { jobs, isLoading, error, refetch } = useMyPendingEpodDeliveryJobs();
|
|
237
|
+
const [selectedJob, setSelectedJob] = useState<PendingEpodDeliveryJob | null>(null);
|
|
238
|
+
const { message, notify } = useNotice();
|
|
239
|
+
|
|
240
|
+
const handleCaptured = () => {
|
|
241
|
+
notify('Proof of delivery captured — delivery marked complete.');
|
|
242
|
+
setSelectedJob(null);
|
|
243
|
+
refetch();
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<PageLayout
|
|
248
|
+
title="My Deliveries"
|
|
249
|
+
subtitle="Capture proof of delivery for your assigned stops"
|
|
250
|
+
icon={<Truck size={24} />}
|
|
251
|
+
>
|
|
252
|
+
<div className="space-y-4">
|
|
253
|
+
<Notice message={message} />
|
|
254
|
+
|
|
255
|
+
{selectedJob ? (
|
|
256
|
+
<CaptureForm job={selectedJob} onCaptured={handleCaptured} onCancel={() => setSelectedJob(null)} />
|
|
257
|
+
) : isLoading ? (
|
|
258
|
+
<p className="text-text-secondary text-center py-8">Loading your deliveries…</p>
|
|
259
|
+
) : error ? (
|
|
260
|
+
<FormError message={error.message} />
|
|
261
|
+
) : jobs.length > 0 ? (
|
|
262
|
+
<div className="space-y-3">
|
|
263
|
+
{jobs.map((job) => (
|
|
264
|
+
<Card key={job.id}>
|
|
265
|
+
<CardContent className="flex items-center justify-between gap-3 py-4">
|
|
266
|
+
<div className="min-w-0">
|
|
267
|
+
<p className="font-medium flex items-center gap-2">
|
|
268
|
+
<Package size={16} /> {job.deliveryJobNumber}
|
|
269
|
+
</p>
|
|
270
|
+
{job.deliveryAddress && (
|
|
271
|
+
<p className="text-sm text-text-secondary truncate">
|
|
272
|
+
{formatAddress(job.deliveryAddress)}
|
|
273
|
+
</p>
|
|
274
|
+
)}
|
|
275
|
+
{job.scheduledDelivery && (
|
|
276
|
+
<p className="text-xs text-text-secondary">
|
|
277
|
+
Scheduled {formatDate(job.scheduledDelivery, 'short')}
|
|
278
|
+
</p>
|
|
279
|
+
)}
|
|
280
|
+
</div>
|
|
281
|
+
<Button type="button" size="sm" onClick={() => setSelectedJob(job)}>
|
|
282
|
+
Capture
|
|
283
|
+
</Button>
|
|
284
|
+
</CardContent>
|
|
285
|
+
</Card>
|
|
286
|
+
))}
|
|
287
|
+
</div>
|
|
288
|
+
) : (
|
|
289
|
+
<div className="text-center py-12">
|
|
290
|
+
<CheckCircle2 size={40} className="mx-auto mb-3 text-text-secondary opacity-50" />
|
|
291
|
+
<p className="text-text-secondary">No deliveries awaiting proof of delivery.</p>
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
</PageLayout>
|
|
296
|
+
);
|
|
297
|
+
}
|