@burdenoff/microfe-movethewheels 2026.725.1 → 2026.728.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.
Files changed (32) hide show
  1. package/dist/MoveTheWheelsRoutes.js +7 -3
  2. package/dist/components/ProofOfDeliveryCard.js +155 -0
  3. package/dist/components/SignaturePad.js +82 -0
  4. package/dist/hooks/index.js +1 -0
  5. package/dist/hooks/useEntityDocuments.js +4 -4
  6. package/dist/hooks/useEpod.js +208 -0
  7. package/dist/index.js +22 -21
  8. package/dist/movethewheels/components/ProofOfDeliveryCard.d.ts +5 -0
  9. package/dist/movethewheels/components/SignaturePad.d.ts +11 -0
  10. package/dist/movethewheels/hooks/index.d.ts +1 -0
  11. package/dist/movethewheels/hooks/useEntityDocuments.d.ts +17 -1
  12. package/dist/movethewheels/hooks/useEpod.d.ts +59 -0
  13. package/dist/movethewheels/pages/DeliveryCapturePage.d.ts +1 -0
  14. package/dist/movethewheels/types/index.d.ts +18 -0
  15. package/dist/node_modules/lucide-react/dist/esm/icons/loader-circle.js +8 -0
  16. package/dist/node_modules/lucide-react/dist/esm/icons/pen-line.js +11 -0
  17. package/dist/node_modules/lucide-react/dist/esm/icons/type.js +24 -0
  18. package/dist/pages/DeliveryCapturePage.js +245 -0
  19. package/dist/pages/OrderDetailsPage.js +14 -12
  20. package/dist/pages/TrackingPage.js +156 -154
  21. package/dist/utils/formatters.js +13 -2
  22. package/package.json +4 -3
  23. package/src/movethewheels/MoveTheWheelsRoutes.tsx +4 -0
  24. package/src/movethewheels/components/ProofOfDeliveryCard.tsx +149 -0
  25. package/src/movethewheels/components/SignaturePad.tsx +157 -0
  26. package/src/movethewheels/hooks/index.ts +12 -0
  27. package/src/movethewheels/hooks/useEntityDocuments.ts +30 -12
  28. package/src/movethewheels/hooks/useEpod.ts +410 -0
  29. package/src/movethewheels/pages/DeliveryCapturePage.tsx +297 -0
  30. package/src/movethewheels/pages/OrderDetailsPage.tsx +4 -0
  31. package/src/movethewheels/pages/TrackingPage.tsx +4 -0
  32. package/src/movethewheels/types/index.ts +21 -0
@@ -0,0 +1,410 @@
1
+ /**
2
+ * Electronic proof-of-delivery (ePOD) capture — BOFF-4399.
3
+ *
4
+ * Wire shape mirrors `wspace-movethewheels-svc: src/modules/epod/schema/epod.gql`.
5
+ * Photo attachments are uploaded through the platform `wspace-files-svc`
6
+ * 3-step flow (initiateUpload → PUT → completeUpload) — the same pattern used
7
+ * by other products' file-backed fields (see `microfe-artistrybase`'s
8
+ * `useArtFiles.ts`) — and only the resulting File ids are sent to
9
+ * `captureEpod`; no bytes are ever stored by this service.
10
+ *
11
+ * Deferred (see BOFF-4399 follow-up notes): barcode/RFID capture and
12
+ * multi-language delivery instructions.
13
+ */
14
+ import { useCallback, useEffect, useMemo, useState } from 'react';
15
+ import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
16
+ import type { Order, ProofOfDelivery, SignatureType } from '../types';
17
+ import { executeGraphQL } from '../utils/graphqlClient';
18
+
19
+ interface ApiProofOfDelivery {
20
+ id: string;
21
+ deliveryJobId: string;
22
+ shipmentId?: string | null;
23
+ recipientName: string;
24
+ signatureType: 'DRAWN' | 'TYPED';
25
+ signatureData: string;
26
+ photoFileIds?: string[] | null;
27
+ latitude?: number | null;
28
+ longitude?: number | null;
29
+ locationAccuracy?: number | null;
30
+ deliveredAt: string;
31
+ capturedByDriverId: string;
32
+ notes?: string | null;
33
+ }
34
+
35
+ const EPOD_FIELDS = `
36
+ id
37
+ deliveryJobId
38
+ shipmentId
39
+ recipientName
40
+ signatureType
41
+ signatureData
42
+ photoFileIds
43
+ latitude
44
+ longitude
45
+ locationAccuracy
46
+ deliveredAt
47
+ capturedByDriverId
48
+ notes
49
+ `;
50
+
51
+ const GET_EPOD_QUERY = `
52
+ query GetEpod($deliveryJobId: ID!) {
53
+ epod(deliveryJobId: $deliveryJobId) {
54
+ ${EPOD_FIELDS}
55
+ }
56
+ }
57
+ `;
58
+
59
+ const MY_PENDING_EPOD_JOBS_QUERY = `
60
+ query MyPendingEpodDeliveryJobs($pagination: PaginationInput) {
61
+ myPendingEpodDeliveryJobs(pagination: $pagination) {
62
+ id
63
+ deliveryJobNumber
64
+ status
65
+ priority
66
+ deliveryAddress
67
+ scheduledDelivery
68
+ customer {
69
+ name
70
+ phone
71
+ }
72
+ }
73
+ }
74
+ `;
75
+
76
+ const CAPTURE_EPOD_MUTATION = `
77
+ mutation CaptureEpod($input: CaptureEpodInput!) {
78
+ captureEpod(input: $input) {
79
+ ${EPOD_FIELDS}
80
+ }
81
+ }
82
+ `;
83
+
84
+ const INITIATE_UPLOAD_MUTATION = `
85
+ mutation EpodInitiateUpload($input: InitiateUploadInput!) {
86
+ initiateUpload(input: $input) {
87
+ uploadUrl
88
+ session { id }
89
+ }
90
+ }
91
+ `;
92
+
93
+ const COMPLETE_UPLOAD_MUTATION = `
94
+ mutation EpodCompleteUpload($input: CompleteUploadInput!) {
95
+ completeUpload(input: $input) {
96
+ fileId
97
+ }
98
+ }
99
+ `;
100
+
101
+ const GET_FILE_URL_QUERY = `
102
+ query EpodFileUrl($id: ID!, $expiresIn: Int) {
103
+ getFileDownloadUrl(id: $id, expiresIn: $expiresIn)
104
+ }
105
+ `;
106
+
107
+ /** 6 hours — comfortably under S3's 604800s (7-day) presign cap. */
108
+ const FILE_URL_TTL_SECONDS = 21600;
109
+
110
+ function toApiSignatureType(type: SignatureType): 'DRAWN' | 'TYPED' {
111
+ return type === 'typed' ? 'TYPED' : 'DRAWN';
112
+ }
113
+
114
+ function mapEpod(pod: ApiProofOfDelivery): ProofOfDelivery {
115
+ return {
116
+ id: pod.id,
117
+ deliveryJobId: pod.deliveryJobId,
118
+ shipmentId: pod.shipmentId ?? undefined,
119
+ recipientName: pod.recipientName,
120
+ signatureType: pod.signatureType === 'TYPED' ? 'typed' : 'drawn',
121
+ signatureData: pod.signatureData,
122
+ photoFileIds: pod.photoFileIds ?? [],
123
+ latitude: pod.latitude ?? undefined,
124
+ longitude: pod.longitude ?? undefined,
125
+ locationAccuracy: pod.locationAccuracy ?? undefined,
126
+ deliveredAt: new Date(pod.deliveredAt),
127
+ capturedByDriverId: pod.capturedByDriverId,
128
+ notes: pod.notes ?? undefined,
129
+ };
130
+ }
131
+
132
+ function useGraphQLOptions() {
133
+ const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
134
+ useMoveTheWheels();
135
+ return useMemo(
136
+ () => ({
137
+ apiGatewayUrl,
138
+ authToken,
139
+ workspaceToken,
140
+ workspaceId,
141
+ tenantId,
142
+ actorId: currentUser?.id,
143
+ }),
144
+ [apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
145
+ );
146
+ }
147
+
148
+ /** The shipper/customer-facing view: the completed ePOD record for a delivery job (or null if not yet captured). */
149
+ export function useEpod(deliveryJobId: string | undefined) {
150
+ const graphQLOptions = useGraphQLOptions();
151
+ const [epod, setEpod] = useState<ProofOfDelivery | null>(null);
152
+ const [isLoading, setIsLoading] = useState(false);
153
+ const [error, setError] = useState<Error | null>(null);
154
+
155
+ const load = useCallback(async () => {
156
+ if (!deliveryJobId) {
157
+ setEpod(null);
158
+ return;
159
+ }
160
+ setIsLoading(true);
161
+ setError(null);
162
+ try {
163
+ const data = await executeGraphQL<{ epod: ApiProofOfDelivery | null }>(
164
+ graphQLOptions,
165
+ GET_EPOD_QUERY,
166
+ { deliveryJobId }
167
+ );
168
+ setEpod(data.epod ? mapEpod(data.epod) : null);
169
+ } catch (fetchError) {
170
+ setError(fetchError instanceof Error ? fetchError : new Error('Failed to load proof of delivery'));
171
+ setEpod(null);
172
+ } finally {
173
+ setIsLoading(false);
174
+ }
175
+ }, [deliveryJobId, graphQLOptions]);
176
+
177
+ useEffect(() => {
178
+ void load();
179
+ }, [load]);
180
+
181
+ return { epod, isLoading, error, refetch: load };
182
+ }
183
+
184
+ export interface PendingEpodDeliveryJob {
185
+ id: string;
186
+ deliveryJobNumber: string;
187
+ status: string;
188
+ priority: string;
189
+ deliveryAddress: Order['deliveryAddress'] | null;
190
+ scheduledDelivery?: Date;
191
+ customerName?: string;
192
+ customerPhone?: string;
193
+ }
194
+
195
+ interface ApiPendingJob {
196
+ id: string;
197
+ deliveryJobNumber: string;
198
+ status: string;
199
+ priority: string;
200
+ deliveryAddress: Order['deliveryAddress'] | null;
201
+ scheduledDelivery?: string | null;
202
+ customer?: { name?: string | null; phone?: string | null } | null;
203
+ }
204
+
205
+ /** Type C self-list: the CALLING driver's own jobs still awaiting ePOD capture — feeds the capture flow's job picker. */
206
+ export function useMyPendingEpodDeliveryJobs() {
207
+ const graphQLOptions = useGraphQLOptions();
208
+ const [jobs, setJobs] = useState<PendingEpodDeliveryJob[]>([]);
209
+ const [isLoading, setIsLoading] = useState(false);
210
+ const [error, setError] = useState<Error | null>(null);
211
+
212
+ const load = useCallback(async () => {
213
+ setIsLoading(true);
214
+ setError(null);
215
+ try {
216
+ const data = await executeGraphQL<{ myPendingEpodDeliveryJobs: ApiPendingJob[] }>(
217
+ graphQLOptions,
218
+ MY_PENDING_EPOD_JOBS_QUERY,
219
+ { pagination: { limit: 50, offset: 0 } }
220
+ );
221
+ setJobs(
222
+ data.myPendingEpodDeliveryJobs.map((job) => ({
223
+ id: job.id,
224
+ deliveryJobNumber: job.deliveryJobNumber,
225
+ status: job.status,
226
+ priority: job.priority,
227
+ deliveryAddress: job.deliveryAddress,
228
+ scheduledDelivery: job.scheduledDelivery ? new Date(job.scheduledDelivery) : undefined,
229
+ customerName: job.customer?.name ?? undefined,
230
+ customerPhone: job.customer?.phone ?? undefined,
231
+ }))
232
+ );
233
+ } catch (fetchError) {
234
+ setError(
235
+ fetchError instanceof Error ? fetchError : new Error('Failed to load pending deliveries')
236
+ );
237
+ setJobs([]);
238
+ } finally {
239
+ setIsLoading(false);
240
+ }
241
+ }, [graphQLOptions]);
242
+
243
+ useEffect(() => {
244
+ void load();
245
+ }, [load]);
246
+
247
+ return { jobs, isLoading, error, refetch: load };
248
+ }
249
+
250
+ export interface CaptureEpodArgs {
251
+ deliveryJobId: string;
252
+ recipientName: string;
253
+ signatureType: SignatureType;
254
+ signatureData: string;
255
+ photoFileIds?: string[];
256
+ latitude?: number;
257
+ longitude?: number;
258
+ locationAccuracy?: number;
259
+ notes?: string;
260
+ }
261
+
262
+ /** Driver-facing capture mutation. Identity/ownership is enforced server-side — this never sends a driver id. */
263
+ export function useCaptureEpod() {
264
+ const graphQLOptions = useGraphQLOptions();
265
+ const [isSubmitting, setIsSubmitting] = useState(false);
266
+ const [error, setError] = useState<Error | null>(null);
267
+
268
+ const captureEpod = useCallback(
269
+ async (args: CaptureEpodArgs): Promise<ProofOfDelivery> => {
270
+ setIsSubmitting(true);
271
+ setError(null);
272
+ try {
273
+ const data = await executeGraphQL<{ captureEpod: ApiProofOfDelivery }>(
274
+ graphQLOptions,
275
+ CAPTURE_EPOD_MUTATION,
276
+ {
277
+ input: {
278
+ deliveryJobId: args.deliveryJobId,
279
+ recipientName: args.recipientName,
280
+ signatureType: toApiSignatureType(args.signatureType),
281
+ signatureData: args.signatureData,
282
+ photoFileIds: args.photoFileIds,
283
+ latitude: args.latitude,
284
+ longitude: args.longitude,
285
+ locationAccuracy: args.locationAccuracy,
286
+ notes: args.notes,
287
+ },
288
+ }
289
+ );
290
+ return mapEpod(data.captureEpod);
291
+ } catch (captureError) {
292
+ const err =
293
+ captureError instanceof Error ? captureError : new Error('Failed to capture proof of delivery');
294
+ setError(err);
295
+ throw err;
296
+ } finally {
297
+ setIsSubmitting(false);
298
+ }
299
+ },
300
+ [graphQLOptions]
301
+ );
302
+
303
+ return { captureEpod, isSubmitting, error };
304
+ }
305
+
306
+ export interface UploadedEpodPhoto {
307
+ fileId: string;
308
+ fileName: string;
309
+ }
310
+
311
+ /** Uploads a delivery photo via the platform 3-step file flow and resolves to its `fileId`. */
312
+ export function useEpodPhotoUpload() {
313
+ const graphQLOptions = useGraphQLOptions();
314
+ const [isUploading, setIsUploading] = useState(false);
315
+ const [progress, setProgress] = useState(0);
316
+ const [error, setError] = useState<Error | null>(null);
317
+
318
+ const upload = useCallback(
319
+ async (file: File): Promise<UploadedEpodPhoto> => {
320
+ setIsUploading(true);
321
+ setProgress(0);
322
+ setError(null);
323
+ try {
324
+ const dotIndex = file.name.lastIndexOf('.');
325
+ const extension = dotIndex > -1 ? file.name.slice(dotIndex + 1) : '';
326
+ const mimeType = file.type || 'application/octet-stream';
327
+
328
+ const initiated = await executeGraphQL<{
329
+ initiateUpload: { uploadUrl: string | null; session: { id: string } };
330
+ }>(graphQLOptions, INITIATE_UPLOAD_MUTATION, {
331
+ input: {
332
+ fileName: file.name,
333
+ extension,
334
+ mimeType,
335
+ fileSize: String(file.size),
336
+ visibility: 'WORKSPACE',
337
+ },
338
+ });
339
+
340
+ const { uploadUrl } = initiated.initiateUpload;
341
+ const sessionId = initiated.initiateUpload.session.id;
342
+ if (!uploadUrl) {
343
+ throw new Error('The file service did not return an upload URL.');
344
+ }
345
+
346
+ setProgress(50);
347
+ const putResponse = await fetch(uploadUrl, {
348
+ method: 'PUT',
349
+ headers: { 'Content-Type': mimeType },
350
+ body: file,
351
+ });
352
+ if (!putResponse.ok) {
353
+ throw new Error(`File storage rejected the upload (HTTP ${putResponse.status}).`);
354
+ }
355
+
356
+ const completed = await executeGraphQL<{ completeUpload: { fileId: string } }>(
357
+ graphQLOptions,
358
+ COMPLETE_UPLOAD_MUTATION,
359
+ { input: { sessionId, fileSize: String(file.size) } }
360
+ );
361
+
362
+ setProgress(100);
363
+ return { fileId: completed.completeUpload.fileId, fileName: file.name };
364
+ } catch (uploadError) {
365
+ const err = uploadError instanceof Error ? uploadError : new Error('The photo could not be uploaded.');
366
+ setError(err);
367
+ throw err;
368
+ } finally {
369
+ setIsUploading(false);
370
+ }
371
+ },
372
+ [graphQLOptions]
373
+ );
374
+
375
+ return { upload, isUploading, progress, error };
376
+ }
377
+
378
+ /** Resolves a TTL-safe download URL for a captured delivery photo, for display/download. */
379
+ export function useEpodPhotoUrl(fileId: string | null | undefined) {
380
+ const graphQLOptions = useGraphQLOptions();
381
+ const [url, setUrl] = useState<string | null>(null);
382
+ const [isLoading, setIsLoading] = useState(false);
383
+
384
+ useEffect(() => {
385
+ if (!fileId) {
386
+ setUrl(null);
387
+ return;
388
+ }
389
+ let cancelled = false;
390
+ setIsLoading(true);
391
+ executeGraphQL<{ getFileDownloadUrl: string | null }>(graphQLOptions, GET_FILE_URL_QUERY, {
392
+ id: fileId,
393
+ expiresIn: FILE_URL_TTL_SECONDS,
394
+ })
395
+ .then((data) => {
396
+ if (!cancelled) setUrl(data.getFileDownloadUrl ?? null);
397
+ })
398
+ .catch(() => {
399
+ if (!cancelled) setUrl(null);
400
+ })
401
+ .finally(() => {
402
+ if (!cancelled) setIsLoading(false);
403
+ });
404
+ return () => {
405
+ cancelled = true;
406
+ };
407
+ }, [fileId, graphQLOptions]);
408
+
409
+ return { url, isLoading };
410
+ }
@@ -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
+ }