@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.
Files changed (41) hide show
  1. package/dist/MoveTheWheelsRoutes.js +11 -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/useEpod.js +208 -0
  6. package/dist/hooks/useMultiModalShipments.js +124 -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/useEpod.d.ts +59 -0
  12. package/dist/movethewheels/hooks/useMultiModalShipments.d.ts +76 -0
  13. package/dist/movethewheels/pages/DeliveryCapturePage.d.ts +1 -0
  14. package/dist/movethewheels/pages/MultiModalShipmentPage.d.ts +8 -0
  15. package/dist/movethewheels/types/index.d.ts +18 -0
  16. package/dist/node_modules/lucide-react/dist/esm/icons/calculator.js +56 -0
  17. package/dist/node_modules/lucide-react/dist/esm/icons/loader-circle.js +8 -0
  18. package/dist/node_modules/lucide-react/dist/esm/icons/pen-line.js +11 -0
  19. package/dist/node_modules/lucide-react/dist/esm/icons/plane.js +8 -0
  20. package/dist/node_modules/lucide-react/dist/esm/icons/ship.js +26 -0
  21. package/dist/node_modules/lucide-react/dist/esm/icons/train-front.js +30 -0
  22. package/dist/node_modules/lucide-react/dist/esm/icons/type.js +24 -0
  23. package/dist/pages/DashboardPage.js +156 -151
  24. package/dist/pages/DeliveryCapturePage.js +245 -0
  25. package/dist/pages/MultiModalShipmentPage.js +420 -0
  26. package/dist/pages/OrderDetailsPage.js +14 -12
  27. package/dist/pages/TrackingPage.js +156 -154
  28. package/dist/utils/formatters.js +13 -2
  29. package/package.json +1 -1
  30. package/src/movethewheels/MoveTheWheelsRoutes.tsx +8 -0
  31. package/src/movethewheels/components/ProofOfDeliveryCard.tsx +149 -0
  32. package/src/movethewheels/components/SignaturePad.tsx +157 -0
  33. package/src/movethewheels/hooks/index.ts +12 -0
  34. package/src/movethewheels/hooks/useEpod.ts +410 -0
  35. package/src/movethewheels/hooks/useMultiModalShipments.ts +323 -0
  36. package/src/movethewheels/pages/DashboardPage.tsx +6 -0
  37. package/src/movethewheels/pages/DeliveryCapturePage.tsx +297 -0
  38. package/src/movethewheels/pages/MultiModalShipmentPage.tsx +552 -0
  39. package/src/movethewheels/pages/OrderDetailsPage.tsx +4 -0
  40. package/src/movethewheels/pages/TrackingPage.tsx +4 -0
  41. package/src/movethewheels/types/index.ts +21 -0
@@ -18,7 +18,7 @@ function t(e, t = "USD") {
18
18
  }
19
19
  function n(e, t = "short") {
20
20
  let n = typeof e == "string" ? new Date(e) : e;
21
- return t === "relative" ? r(n) : t === "time" ? new Intl.DateTimeFormat("en-US", {
21
+ return t === "relative" ? i(n) : t === "time" ? new Intl.DateTimeFormat("en-US", {
22
22
  hour: "numeric",
23
23
  minute: "2-digit",
24
24
  hour12: !0
@@ -37,6 +37,17 @@ function n(e, t = "short") {
37
37
  }).format(n);
38
38
  }
39
39
  function r(e) {
40
+ let t = typeof e == "string" ? new Date(e) : e;
41
+ return new Intl.DateTimeFormat("en-US", {
42
+ year: "numeric",
43
+ month: "short",
44
+ day: "numeric",
45
+ hour: "numeric",
46
+ minute: "2-digit",
47
+ hour12: !0
48
+ }).format(t);
49
+ }
50
+ function i(e) {
40
51
  let t = typeof e == "string" ? new Date(e) : e, r = Math.floor(((/* @__PURE__ */ new Date()).getTime() - t.getTime()) / 1e3);
41
52
  if (r < 60) return "Just now";
42
53
  let i = Math.floor(r / 60);
@@ -47,4 +58,4 @@ function r(e) {
47
58
  return o < 7 ? `${o} ${o === 1 ? "day" : "days"} ago` : n(t);
48
59
  }
49
60
  //#endregion
50
- export { e as formatAddress, t as formatCurrency, n as formatDate };
61
+ export { e as formatAddress, t as formatCurrency, n as formatDate, r as formatDateTime };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@burdenoff/microfe-movethewheels",
3
- "version": "2026.726.1",
3
+ "version": "2026.731.1",
4
4
  "description": "Burdenoff MoveTheWheels Microfrontend - Logistics & Supply Chain Management Platform",
5
5
  "type": "module",
6
6
  "files": [
@@ -72,6 +72,8 @@ const ImportExportPage = lazy(() => import('./pages/ImportExportPage'));
72
72
  const ReportsPage = lazy(() => import('./pages/ReportsPage'));
73
73
  const SettingsPage = lazy(() => import('./pages/SettingsPage'));
74
74
  const AIAssistantPage = lazy(() => import('./pages/AIAssistantPage'));
75
+ const DeliveryCapturePage = lazy(() => import('./pages/DeliveryCapturePage'));
76
+ const MultiModalShipmentPage = lazy(() => import('./pages/MultiModalShipmentPage'));
75
77
 
76
78
  export function MoveTheWheelsRoutes() {
77
79
  return (
@@ -90,6 +92,12 @@ export function MoveTheWheelsRoutes() {
90
92
  <Route path="tracking" element={<TrackingPage />} />
91
93
  <Route path="tracking/live" element={<LiveTrackingPage />} />
92
94
 
95
+ {/* Driver ePOD capture (BOFF-4399) */}
96
+ <Route path="deliveries/mine" element={<DeliveryCapturePage />} />
97
+
98
+ {/* Multi-modal freight execution (BOFF-4398) */}
99
+ <Route path="shipments/multi-modal" element={<MultiModalShipmentPage />} />
100
+
93
101
  {/* Fleet */}
94
102
  <Route path="fleet" element={<FleetPage />} />
95
103
  <Route path="fleet/vehicles" element={<VehiclesPage />} />
@@ -0,0 +1,149 @@
1
+ /**
2
+ * ProofOfDeliveryCard — the shipper/customer-facing view of a captured ePOD
3
+ * (BOFF-4399). Read-only: shows recipient name, signature, timestamp,
4
+ * geo-location and any delivery photos, with a download link for the
5
+ * signature image. Renders an honest "not yet delivered" empty state when no
6
+ * capture exists.
7
+ */
8
+ import { CheckCircle2, Clock, Download, MapPin, User } from 'lucide-react';
9
+ import { Card, CardContent, CardHeader, CardTitle, Badge } from './ui';
10
+ import { useEpod, useEpodPhotoUrl } from '../hooks/useEpod';
11
+ import { formatDateTime } from '../utils/formatters';
12
+
13
+ interface ProofOfDeliveryCardProps {
14
+ deliveryJobId: string;
15
+ }
16
+
17
+ function EpodPhotoThumbnail({ fileId }: { fileId: string }) {
18
+ const { url, isLoading } = useEpodPhotoUrl(fileId);
19
+
20
+ if (isLoading) {
21
+ return <div className="h-20 w-20 rounded-lg bg-bg-sunken animate-pulse" />;
22
+ }
23
+ if (!url) {
24
+ return null;
25
+ }
26
+ return (
27
+ <a href={url} target="_blank" rel="noopener noreferrer" className="shrink-0">
28
+ <img
29
+ src={url}
30
+ alt="Delivery photo"
31
+ className="h-20 w-20 rounded-lg object-cover border border-border"
32
+ />
33
+ </a>
34
+ );
35
+ }
36
+
37
+ export function ProofOfDeliveryCard({ deliveryJobId }: ProofOfDeliveryCardProps) {
38
+ const { epod, isLoading, error } = useEpod(deliveryJobId);
39
+
40
+ return (
41
+ <Card>
42
+ <CardHeader className="flex flex-row items-center justify-between">
43
+ <CardTitle className="flex items-center gap-2">
44
+ <CheckCircle2 size={18} /> Proof of Delivery
45
+ </CardTitle>
46
+ {epod && <Badge variant="success">Captured</Badge>}
47
+ </CardHeader>
48
+ <CardContent>
49
+ {isLoading ? (
50
+ <p className="text-text-secondary text-center py-4">Loading proof of delivery…</p>
51
+ ) : error ? (
52
+ <p className="text-sm text-status-error-text text-center py-4">
53
+ Failed to load proof of delivery.
54
+ </p>
55
+ ) : epod ? (
56
+ <div className="space-y-4">
57
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
58
+ <div className="flex items-start gap-2">
59
+ <User size={16} className="mt-0.5 text-text-secondary shrink-0" />
60
+ <div>
61
+ <p className="text-xs text-text-secondary">Received by</p>
62
+ <p className="font-medium">{epod.recipientName}</p>
63
+ </div>
64
+ </div>
65
+ <div className="flex items-start gap-2">
66
+ <Clock size={16} className="mt-0.5 text-text-secondary shrink-0" />
67
+ <div>
68
+ <p className="text-xs text-text-secondary">Delivered at</p>
69
+ <p className="font-medium">{formatDateTime(epod.deliveredAt)}</p>
70
+ </div>
71
+ </div>
72
+ {epod.latitude != null && epod.longitude != null && (
73
+ <div className="flex items-start gap-2 sm:col-span-2">
74
+ <MapPin size={16} className="mt-0.5 text-text-secondary shrink-0" />
75
+ <div>
76
+ <p className="text-xs text-text-secondary">Delivery location</p>
77
+ <p className="font-medium">
78
+ {epod.latitude.toFixed(5)}, {epod.longitude.toFixed(5)}
79
+ {epod.locationAccuracy != null && (
80
+ <span className="text-text-secondary"> (±{Math.round(epod.locationAccuracy)}m)</span>
81
+ )}
82
+ </p>
83
+ </div>
84
+ </div>
85
+ )}
86
+ </div>
87
+
88
+ {epod.notes && (
89
+ <div>
90
+ <p className="text-xs text-text-secondary mb-1">Notes</p>
91
+ <p className="text-sm">{epod.notes}</p>
92
+ </div>
93
+ )}
94
+
95
+ <div>
96
+ <p className="text-xs text-text-secondary mb-1">
97
+ Signature {epod.signatureType === 'typed' ? '(typed)' : '(drawn)'}
98
+ </p>
99
+ {epod.signatureType === 'drawn' ? (
100
+ <div className="border rounded-lg p-2 inline-block bg-white">
101
+ <img src={epod.signatureData} alt="Recipient signature" className="h-16" />
102
+ </div>
103
+ ) : (
104
+ <p className="text-lg italic font-medium border-b border-border inline-block pb-1">
105
+ {epod.signatureData}
106
+ </p>
107
+ )}
108
+ </div>
109
+
110
+ {epod.photoFileIds.length > 0 && (
111
+ <div>
112
+ <p className="text-xs text-text-secondary mb-2">Delivery photos</p>
113
+ <div className="flex flex-wrap gap-2">
114
+ {epod.photoFileIds.map((fileId) => (
115
+ <EpodPhotoThumbnail key={fileId} fileId={fileId} />
116
+ ))}
117
+ </div>
118
+ </div>
119
+ )}
120
+
121
+ <a
122
+ href={`data:text/plain;charset=utf-8,${encodeURIComponent(
123
+ `Proof of Delivery\nRecipient: ${epod.recipientName}\nDelivered at: ${formatDateTime(
124
+ epod.deliveredAt
125
+ )}${
126
+ epod.latitude != null && epod.longitude != null
127
+ ? `\nLocation: ${epod.latitude}, ${epod.longitude}`
128
+ : ''
129
+ }${epod.notes ? `\nNotes: ${epod.notes}` : ''}`
130
+ )}`}
131
+ download={`pod-${deliveryJobId}.txt`}
132
+ className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
133
+ >
134
+ <Download size={14} /> Download record
135
+ </a>
136
+ </div>
137
+ ) : (
138
+ <div className="text-center py-6">
139
+ <CheckCircle2 size={32} className="mx-auto mb-2 text-text-secondary opacity-50" />
140
+ <p className="text-text-secondary">Not yet delivered</p>
141
+ <p className="text-xs text-text-secondary mt-1">
142
+ Proof of delivery will appear here once the driver completes capture.
143
+ </p>
144
+ </div>
145
+ )}
146
+ </CardContent>
147
+ </Card>
148
+ );
149
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * SignaturePad — dependency-free canvas signature capture.
3
+ *
4
+ * Pointer-events drawing on a `<canvas>`, exported as a `data:image/png;base64,...`
5
+ * string via `toDataURL`. No external signature-pad package is pulled in — this
6
+ * keeps the ePOD capture core self-contained and avoids a new dependency for a
7
+ * single bounded widget.
8
+ */
9
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
10
+ import { Button } from './ui';
11
+ import { cn } from '../utils/cn';
12
+
13
+ export interface SignaturePadHandle {
14
+ clear: () => void;
15
+ isEmpty: () => boolean;
16
+ toDataUrl: () => string | null;
17
+ }
18
+
19
+ interface SignaturePadProps {
20
+ className?: string;
21
+ onChange?: (hasSignature: boolean) => void;
22
+ }
23
+
24
+ export const SignaturePad = forwardRef<SignaturePadHandle, SignaturePadProps>(
25
+ ({ className, onChange }, ref) => {
26
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
27
+ const drawingRef = useRef(false);
28
+ const hasDrawnRef = useRef(false);
29
+ const lastPointRef = useRef<{ x: number; y: number } | null>(null);
30
+ const [hasDrawn, setHasDrawn] = useState(false);
31
+
32
+ const getContext = useCallback((): CanvasRenderingContext2D | null => {
33
+ const canvas = canvasRef.current;
34
+ return canvas ? canvas.getContext('2d') : null;
35
+ }, []);
36
+
37
+ useEffect(() => {
38
+ const canvas = canvasRef.current;
39
+ const ctx = getContext();
40
+ if (!canvas || !ctx) return;
41
+ // Size the backing bitmap to the element's rendered size (device-pixel
42
+ // aware) so lines stay crisp regardless of the CSS layout width.
43
+ const ratio = window.devicePixelRatio || 1;
44
+ const rect = canvas.getBoundingClientRect();
45
+ canvas.width = rect.width * ratio;
46
+ canvas.height = rect.height * ratio;
47
+ ctx.scale(ratio, ratio);
48
+ ctx.lineWidth = 2;
49
+ ctx.lineCap = 'round';
50
+ ctx.lineJoin = 'round';
51
+ // Canvas has no CSS var support for strokeStyle — resolve the semantic
52
+ // `text-text-primary` token (applied via className below) to its
53
+ // computed color at draw-setup time instead of a hard-coded hex.
54
+ // `currentColor` is the CSS-spec fallback so a resolution failure still
55
+ // inherits the token via the element's own `color`, never a raw literal.
56
+ ctx.strokeStyle = getComputedStyle(canvas).color || 'currentColor';
57
+ }, [getContext]);
58
+
59
+ const pointFromEvent = useCallback((event: React.PointerEvent<HTMLCanvasElement>) => {
60
+ const canvas = canvasRef.current;
61
+ if (!canvas) return { x: 0, y: 0 };
62
+ const rect = canvas.getBoundingClientRect();
63
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top };
64
+ }, []);
65
+
66
+ const handlePointerDown = useCallback(
67
+ (event: React.PointerEvent<HTMLCanvasElement>) => {
68
+ const canvas = canvasRef.current;
69
+ const ctx = getContext();
70
+ if (!canvas || !ctx) return;
71
+ canvas.setPointerCapture(event.pointerId);
72
+ drawingRef.current = true;
73
+ lastPointRef.current = pointFromEvent(event);
74
+ },
75
+ [getContext, pointFromEvent]
76
+ );
77
+
78
+ const handlePointerMove = useCallback(
79
+ (event: React.PointerEvent<HTMLCanvasElement>) => {
80
+ if (!drawingRef.current) return;
81
+ const ctx = getContext();
82
+ const last = lastPointRef.current;
83
+ if (!ctx || !last) return;
84
+ const point = pointFromEvent(event);
85
+ ctx.beginPath();
86
+ ctx.moveTo(last.x, last.y);
87
+ ctx.lineTo(point.x, point.y);
88
+ ctx.stroke();
89
+ lastPointRef.current = point;
90
+ if (!hasDrawnRef.current) {
91
+ hasDrawnRef.current = true;
92
+ setHasDrawn(true);
93
+ onChange?.(true);
94
+ }
95
+ },
96
+ [getContext, onChange, pointFromEvent]
97
+ );
98
+
99
+ const handlePointerUp = useCallback((event: React.PointerEvent<HTMLCanvasElement>) => {
100
+ drawingRef.current = false;
101
+ lastPointRef.current = null;
102
+ const canvas = canvasRef.current;
103
+ if (canvas?.hasPointerCapture(event.pointerId)) {
104
+ canvas.releasePointerCapture(event.pointerId);
105
+ }
106
+ }, []);
107
+
108
+ const clear = useCallback(() => {
109
+ const canvas = canvasRef.current;
110
+ const ctx = getContext();
111
+ if (!canvas || !ctx) return;
112
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
113
+ hasDrawnRef.current = false;
114
+ setHasDrawn(false);
115
+ onChange?.(false);
116
+ }, [getContext, onChange]);
117
+
118
+ const toDataUrl = useCallback((): string | null => {
119
+ if (!hasDrawnRef.current) return null;
120
+ return canvasRef.current?.toDataURL('image/png') ?? null;
121
+ }, []);
122
+
123
+ useImperativeHandle(
124
+ ref,
125
+ (): SignaturePadHandle => ({
126
+ clear,
127
+ isEmpty: () => !hasDrawnRef.current,
128
+ toDataUrl,
129
+ }),
130
+ [clear, toDataUrl]
131
+ );
132
+
133
+ return (
134
+ <div className={cn('space-y-2', className)}>
135
+ <canvas
136
+ ref={canvasRef}
137
+ className="w-full h-40 rounded-lg border-2 border-dashed border-border bg-bg-surface text-text-primary touch-none cursor-crosshair"
138
+ onPointerDown={handlePointerDown}
139
+ onPointerMove={handlePointerMove}
140
+ onPointerUp={handlePointerUp}
141
+ onPointerLeave={handlePointerUp}
142
+ role="img"
143
+ aria-label="Signature capture pad"
144
+ />
145
+ <div className="flex items-center justify-between">
146
+ <p className="text-xs text-text-secondary">
147
+ {hasDrawn ? 'Signature captured' : 'Sign above with your finger or mouse'}
148
+ </p>
149
+ <Button type="button" variant="ghost" size="sm" onClick={clear} disabled={!hasDrawn}>
150
+ Clear
151
+ </Button>
152
+ </div>
153
+ </div>
154
+ );
155
+ }
156
+ );
157
+ SignaturePad.displayName = 'SignaturePad';
@@ -71,3 +71,15 @@ export {
71
71
  type EntityDocument,
72
72
  type EntityDocumentType,
73
73
  } from './useEntityDocuments';
74
+
75
+ // Electronic proof of delivery (ePOD) capture — BOFF-4399
76
+ export {
77
+ useEpod,
78
+ useMyPendingEpodDeliveryJobs,
79
+ useCaptureEpod,
80
+ useEpodPhotoUpload,
81
+ useEpodPhotoUrl,
82
+ type PendingEpodDeliveryJob,
83
+ type CaptureEpodArgs,
84
+ type UploadedEpodPhoto,
85
+ } from './useEpod';