@burdenoff/microfe-movethewheels 2026.703.4 → 2026.703.6
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/{AIAssistantPage-3YOnHO9Y.js → AIAssistantPage-BmILdHgO.js} +16 -15
- package/dist/AnalyticsPage-BSlsiSDE.js +194 -0
- package/dist/{CreateOrderPage-DRAgGo9i.js → CreateOrderPage-DaCgVaO3.js} +40 -40
- package/dist/{CustomerDetailsPage-BCeMmOFe.js → CustomerDetailsPage-DErpbafW.js} +71 -71
- package/dist/CustomersPage-C1lRU6xs.js +205 -0
- package/dist/DashboardPage-Q5-5EKAU.js +542 -0
- package/dist/{DataTable-XvUcTjwi.js → DataTable-KjzbwnRL.js} +31 -31
- package/dist/{DocumentsCard-U2lSe-cn.js → DocumentsCard-T9y6cv4-.js} +25 -25
- package/dist/{DriverDetailsPage-BQrYd_Lp.js → DriverDetailsPage-DnHGkDC7.js} +64 -64
- package/dist/DriversPage-C0ujWVOn.js +221 -0
- package/dist/FinancePage-CA2XEgsQ.js +175 -0
- package/dist/{FleetPage-3ZHsB-TN.js → FleetPage-DrvXW7wK.js} +22 -22
- package/dist/{FormError-cuLV2jVD.js → FormError-CRsMP7TQ.js} +1 -1
- package/dist/{ImportExportPage-C6f7DqhM.js → ImportExportPage-Cac9HjFx.js} +12 -12
- package/dist/InventoryPage-Ezf4gqKI.js +259 -0
- package/dist/LiveTrackingPage-CzbVwDqK.js +429 -0
- package/dist/{MarketplacePage-Bh_2ns4v.js → MarketplacePage-BHDmSQmo.js} +116 -116
- package/dist/MetricCard-DQlDNqmK.js +191 -0
- package/dist/{Notice-CSoDYKw9.js → Notice-GmA7JDR3.js} +2 -2
- package/dist/{OrderDetailsPage-zCj0lW0j.js → OrderDetailsPage-CR0Wc1aN.js} +58 -58
- package/dist/OrdersListPage-C8Bu6wXM.js +386 -0
- package/dist/{PageLayout-D4LDHJJq.js → PageLayout-DX1Ou6V6.js} +164 -134
- package/dist/{ProductDetailsPage-wsxAQkA0.js → ProductDetailsPage-B_ns2MOe.js} +55 -55
- package/dist/ProductsPage-c92BsEoA.js +186 -0
- package/dist/ReportsPage-CPLXtLT2.js +377 -0
- package/dist/{RouteDetailsPage-DyMJE9AS.js → RouteDetailsPage-DOfcRiFv.js} +208 -181
- package/dist/{RoutesPage-Dtc669yc.js → RoutesPage-BZGublby.js} +12 -12
- package/dist/{SettingsPage-CpH9xhQg.js → SettingsPage-5cweX_xh.js} +15 -15
- package/dist/Sparkline-zSXvG6vA.js +16729 -0
- package/dist/{StatusBadge-C87mDpDP.js → StatusBadge-oZjownr1.js} +7 -7
- package/dist/TrackingPage-Ln9qVflK.js +368 -0
- package/dist/{VehicleDetailsPage-DldYNsYV.js → VehicleDetailsPage-CrkzE0dN.js} +53 -53
- package/dist/VehiclesPage-CTQWH7af.js +184 -0
- package/dist/{WarehouseDetailsPage-CMG7or6G.js → WarehouseDetailsPage-CwDLaG65.js} +64 -64
- package/dist/WarehousesPage-KW02DvZg.js +182 -0
- package/dist/{arrow-left-n48Y-Wis.js → arrow-left-DJDw_Yqv.js} +1 -1
- package/dist/{arrow-right-Q3XQpTPR.js → arrow-right-Cb2fLTPJ.js} +1 -1
- package/dist/{arrow-up-xNF1zJUG.js → arrow-up-PxaOIyiT.js} +1 -1
- package/dist/{box-Eu05PXDh.js → box-hSJVtvGf.js} +1 -1
- package/dist/boxes-DDnxEkT5.js +54 -0
- package/dist/{chart-column-ouzgxIv_.js → chart-column-xXgAZvRl.js} +1 -1
- package/dist/charts-CfCwQg6o.js +2018 -0
- package/dist/{chevron-left-B2_r0Elx.js → chevron-left-dhEGJYdh.js} +1 -1
- package/dist/{chevron-right-eV4LjaSi.js → chevron-right-Cud2NDvm.js} +1 -1
- package/dist/chunk-BdsCSvBP.js +20 -0
- package/dist/{circle-alert-Bzh2DBSF.js → circle-alert-Rx0p3D5w.js} +1 -1
- package/dist/circle-check-BCxRFdFo.js +13 -0
- package/dist/{circle-check-big-CGMSpQmD.js → circle-check-big-BcIJ8xjx.js} +1 -1
- package/dist/{clock-Bl3ptexL.js → clock-B2_YxJgB.js} +1 -1
- package/dist/{dollar-sign-CAVU95SO.js → dollar-sign-CQmAICv4.js} +1 -1
- package/dist/{download-C5NbvVDc.js → download-CG4Tponq.js} +1 -1
- package/dist/{file-text-D-1uvLZG.js → file-text-CzH-zMub.js} +1 -1
- package/dist/{filter-DUfUg6BO.js → filter-C0u2Lwfn.js} +1 -1
- package/dist/{fuel-B6_lnKDs.js → fuel-C2KELcM-.js} +1 -1
- package/dist/index.css +2 -0
- package/dist/index.js +214 -225
- package/dist/{map-C-M-FQLR.js → map-FuPu5aTw.js} +1 -1
- package/dist/{map-pin-Dznl5l40.js → map-pin-czpLv9uy.js} +1 -1
- package/dist/mapMarkers-NCV9MRKR.js +28 -0
- package/dist/maps-D9m7PKrs.js +5070 -0
- package/dist/{navigation-BJSJl-VG.js → navigation-D5_pdCSS.js} +1 -1
- package/dist/{package-CcRttV2N.js → package-B0sdi6lU.js} +1 -1
- package/dist/{phone-DVSZHt_j.js → phone-V2nuzPu9.js} +1 -1
- package/dist/{plus-C_XZDwBz.js → plus-BqQxkfoU.js} +1 -1
- package/dist/{refresh-cw-CfgN67l3.js → refresh-cw-CEOAPEcV.js} +1 -1
- package/dist/route-CZWcW-Dw.js +22 -0
- package/dist/{save-DJE8Fpdv.js → save-DDFvrxWl.js} +1 -1
- package/dist/{search-D_VyGcZT.js → search-D7Ya6jxj.js} +1 -1
- package/dist/{square-pen-D-6JYrVQ.js → square-pen-DoZZS4Lw.js} +1 -1
- package/dist/{star-BpRlUZuU.js → star-B7PgVu5j.js} +1 -1
- package/dist/{store-B-8HMuyZ.js → store-YVzcIG4A.js} +1 -1
- package/dist/{trash-2-8vmv-XV_.js → trash-2-BegnBzwI.js} +1 -1
- package/dist/{trending-up-Dz6PqPRj.js → trending-up-DBd3XNdb.js} +1 -1
- package/dist/{triangle-alert-D7Y3J_4z.js → triangle-alert-Bxk3Kgs6.js} +1 -1
- package/dist/{truck-D-y2IDzv.js → truck-Clrn0oHp.js} +1 -1
- package/dist/{upload-CHZofOqb.js → upload-Df5JmMwH.js} +1 -1
- package/dist/{useAssistant-DVW4QgQb.js → useAssistant-Cb8nnIVC.js} +11 -28
- package/dist/{useCustomers-BPW4__PR.js → useCustomers-Dq2IR2HQ.js} +1 -1
- package/dist/{useFleet-ChBkxAd6.js → useFleet-CapnWBcA.js} +1 -1
- package/dist/{useImportExport-DrPDHEDK.js → useImportExport-BoGzrlJy.js} +1 -1
- package/dist/{useInventory-CAAVMRp6.js → useInventory-Ci6M2d9f.js} +1 -1
- package/dist/{useOrders-B465S2nt.js → useOrders-BuQU1OHX.js} +1 -1
- package/dist/{useReports-jQ3GpNOm.js → useReports-DIx8W7CO.js} +1 -1
- package/dist/{useRoutes-hS3d5-xr.js → useRoutes-D7EvI_pm.js} +1 -1
- package/dist/{useSettings-BB8X2RLh.js → useSettings-BhX251UQ.js} +6 -6
- package/dist/{useTracking-RjGz9PZG.js → useTracking-C40jmZ6W.js} +3 -3
- package/dist/{user-DzfTn4dE.js → user-BlcJrbtE.js} +1 -1
- package/dist/{user-check-BEKRCi7v.js → user-check-DtnQmimP.js} +1 -1
- package/dist/{users-CCrq-w0C.js → users-Cvwko-Dx.js} +1 -1
- package/dist/{warehouse-CaqfmMKh.js → warehouse-LWLVTk6G.js} +1 -1
- package/dist/{wrench-q_rgJfaP.js → wrench-Cme0RbyR.js} +1 -1
- package/dist/{x-tMeDMcCw.js → x-D8C9Pg5V.js} +1 -1
- package/package.json +5 -1
- package/src/movethewheels/components/GlassPanel.tsx +71 -0
- package/src/movethewheels/components/MetricCard.tsx +171 -73
- package/src/movethewheels/components/StatusBadge.tsx +7 -6
- package/src/movethewheels/components/charts/ChartShell.tsx +78 -0
- package/src/movethewheels/components/charts/Sparkline.tsx +85 -0
- package/src/movethewheels/components/charts/StatusDonut.tsx +115 -0
- package/src/movethewheels/components/charts/TrendChart.tsx +179 -0
- package/src/movethewheels/components/charts/VolumeBar.tsx +97 -0
- package/src/movethewheels/components/charts/chartTokens.ts +43 -0
- package/src/movethewheels/components/charts/index.ts +9 -0
- package/src/movethewheels/components/index.ts +44 -1
- package/src/movethewheels/components/maps/LogisticsMap.tsx +94 -0
- package/src/movethewheels/components/maps/MapCanvas.tsx +118 -0
- package/src/movethewheels/components/maps/RouteMap.tsx +71 -0
- package/src/movethewheels/components/maps/TrackingMap.tsx +102 -0
- package/src/movethewheels/components/maps/index.ts +16 -0
- package/src/movethewheels/components/maps/leaflet-setup.ts +124 -0
- package/src/movethewheels/components/maps/map-markers.css +121 -0
- package/src/movethewheels/hooks/useAnalytics.ts +14 -4
- package/src/movethewheels/pages/AnalyticsPage.tsx +93 -73
- package/src/movethewheels/pages/CreateOrderPage.tsx +4 -4
- package/src/movethewheels/pages/CustomersPage.tsx +29 -20
- package/src/movethewheels/pages/DashboardPage.tsx +238 -12
- package/src/movethewheels/pages/DriverDetailsPage.tsx +1 -1
- package/src/movethewheels/pages/DriversPage.tsx +35 -24
- package/src/movethewheels/pages/FinancePage.tsx +68 -30
- package/src/movethewheels/pages/InventoryPage.tsx +15 -3
- package/src/movethewheels/pages/LiveTrackingPage.tsx +124 -16
- package/src/movethewheels/pages/OrdersListPage.tsx +46 -38
- package/src/movethewheels/pages/ProductsPage.tsx +28 -24
- package/src/movethewheels/pages/ReportsPage.tsx +23 -0
- package/src/movethewheels/pages/RouteDetailsPage.tsx +46 -7
- package/src/movethewheels/pages/SettingsPage.tsx +2 -2
- package/src/movethewheels/pages/TrackingPage.tsx +76 -6
- package/src/movethewheels/pages/VehicleDetailsPage.tsx +1 -1
- package/src/movethewheels/pages/VehiclesPage.tsx +34 -24
- package/src/movethewheels/pages/WarehouseDetailsPage.tsx +1 -1
- package/src/movethewheels/pages/WarehousesPage.tsx +28 -22
- package/src/movethewheels/types/index.ts +1 -1
- package/src/movethewheels/utils/graphqlClient.ts +81 -31
- package/src/movethewheels/utils/mapMarkers.ts +58 -0
- package/dist/AnalyticsPage-CMpI1070.js +0 -215
- package/dist/CustomersPage-CaNBMXmM.js +0 -172
- package/dist/DashboardPage-DIKlXde3.js +0 -387
- package/dist/DriversPage-PYmdFiby.js +0 -176
- package/dist/FinancePage-Cwh9Bw4M.js +0 -136
- package/dist/InventoryPage-DpiqVu1v.js +0 -253
- package/dist/LiveTrackingPage-FAzzlVnX.js +0 -379
- package/dist/MetricCard-CVBVpu7o.js +0 -146
- package/dist/OrdersListPage-CAMsuUck.js +0 -384
- package/dist/ProductsPage-DC5qDLfz.js +0 -180
- package/dist/ReportsPage-BwUJ37eO.js +0 -356
- package/dist/TrackingPage-DaNEFG_h.js +0 -325
- package/dist/VehiclesPage-BFw13iMt.js +0 -176
- package/dist/WarehousesPage-Wkytnt86.js +0 -173
- package/dist/useAnalytics-D7HW2XRb.js +0 -347
- /package/dist/{formatters-BQr-aR5W.js → formatters-FWJDwTFv.js} +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { Marker, Polyline, Popup } from 'react-leaflet';
|
|
3
|
+
import { MapCanvas } from './MapCanvas';
|
|
4
|
+
import { createCurrentIcon, createPinIcon, isRealCoord } from './leaflet-setup';
|
|
5
|
+
|
|
6
|
+
export interface TrackPoint {
|
|
7
|
+
lat: number;
|
|
8
|
+
lng: number;
|
|
9
|
+
timestamp?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TrackEndpoint {
|
|
13
|
+
lat: number;
|
|
14
|
+
lng: number;
|
|
15
|
+
label?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface TrackingMapProps {
|
|
19
|
+
origin?: TrackEndpoint | null;
|
|
20
|
+
destination?: TrackEndpoint | null;
|
|
21
|
+
/** Ordered breadcrumb of positions the shipment has passed through. */
|
|
22
|
+
trail: TrackPoint[];
|
|
23
|
+
current?: TrackEndpoint | null;
|
|
24
|
+
height?: number | string;
|
|
25
|
+
className?: string;
|
|
26
|
+
/** Trail polyline color (design-token string). Defaults to brand blue. */
|
|
27
|
+
trailColor?: string;
|
|
28
|
+
emptyMessage?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* TrackingMap — renders a shipment's breadcrumb trail with origin/destination
|
|
33
|
+
* pins and a live "current position" dot. Everything is filtered for real
|
|
34
|
+
* coordinates so a missing GPS fix never plots at null-island.
|
|
35
|
+
*/
|
|
36
|
+
export function TrackingMap({
|
|
37
|
+
origin,
|
|
38
|
+
destination,
|
|
39
|
+
trail,
|
|
40
|
+
current,
|
|
41
|
+
height = 360,
|
|
42
|
+
className,
|
|
43
|
+
trailColor = 'var(--color-chart-1)',
|
|
44
|
+
emptyMessage = 'No tracking positions yet',
|
|
45
|
+
}: TrackingMapProps) {
|
|
46
|
+
const trailLine = useMemo<Array<[number, number]>>(
|
|
47
|
+
() => trail.filter((p) => isRealCoord(p.lat, p.lng)).map((p) => [p.lat, p.lng]),
|
|
48
|
+
[trail]
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const originOk = origin && isRealCoord(origin.lat, origin.lng) ? origin : null;
|
|
52
|
+
const destOk = destination && isRealCoord(destination.lat, destination.lng) ? destination : null;
|
|
53
|
+
const currentOk = current && isRealCoord(current.lat, current.lng) ? current : null;
|
|
54
|
+
|
|
55
|
+
const fitPoints = useMemo<Array<[number, number]>>(() => {
|
|
56
|
+
const pts: Array<[number, number]> = [...trailLine];
|
|
57
|
+
if (originOk) pts.push([originOk.lat, originOk.lng]);
|
|
58
|
+
if (destOk) pts.push([destOk.lat, destOk.lng]);
|
|
59
|
+
if (currentOk) pts.push([currentOk.lat, currentOk.lng]);
|
|
60
|
+
return pts;
|
|
61
|
+
}, [trailLine, originOk, destOk, currentOk]);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<MapCanvas
|
|
65
|
+
points={fitPoints}
|
|
66
|
+
height={height}
|
|
67
|
+
className={className}
|
|
68
|
+
emptyState={<p className="text-sm text-text-secondary">{emptyMessage}</p>}
|
|
69
|
+
>
|
|
70
|
+
{trailLine.length >= 2 && (
|
|
71
|
+
<Polyline
|
|
72
|
+
positions={trailLine}
|
|
73
|
+
pathOptions={{ color: trailColor, weight: 4, opacity: 0.85, dashArray: '1 8', lineCap: 'round' }}
|
|
74
|
+
/>
|
|
75
|
+
)}
|
|
76
|
+
{originOk && (
|
|
77
|
+
<Marker position={[originOk.lat, originOk.lng]} icon={createPinIcon('origin', { glyph: false })}>
|
|
78
|
+
<Popup>
|
|
79
|
+
<span className="font-semibold">{originOk.label ?? 'Origin'}</span>
|
|
80
|
+
</Popup>
|
|
81
|
+
</Marker>
|
|
82
|
+
)}
|
|
83
|
+
{destOk && (
|
|
84
|
+
<Marker
|
|
85
|
+
position={[destOk.lat, destOk.lng]}
|
|
86
|
+
icon={createPinIcon('destination', { glyph: false })}
|
|
87
|
+
>
|
|
88
|
+
<Popup>
|
|
89
|
+
<span className="font-semibold">{destOk.label ?? 'Destination'}</span>
|
|
90
|
+
</Popup>
|
|
91
|
+
</Marker>
|
|
92
|
+
)}
|
|
93
|
+
{currentOk && (
|
|
94
|
+
<Marker position={[currentOk.lat, currentOk.lng]} icon={createCurrentIcon()}>
|
|
95
|
+
<Popup>
|
|
96
|
+
<span className="font-semibold">{currentOk.label ?? 'Current position'}</span>
|
|
97
|
+
</Popup>
|
|
98
|
+
</Marker>
|
|
99
|
+
)}
|
|
100
|
+
</MapCanvas>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map primitives (react-leaflet + OpenStreetMap tiles) — brand-styled markers,
|
|
3
|
+
* mount-safe, responsive, honest empty states. Coordinates at null-island (0,0)
|
|
4
|
+
* or non-finite values are always filtered out.
|
|
5
|
+
*/
|
|
6
|
+
export { LogisticsMap, type LogisticsMarker, type LogisticsRoute } from './LogisticsMap';
|
|
7
|
+
export { RouteMap, type RouteStop } from './RouteMap';
|
|
8
|
+
export { TrackingMap, type TrackPoint, type TrackEndpoint } from './TrackingMap';
|
|
9
|
+
export { MapCanvas } from './MapCanvas';
|
|
10
|
+
export {
|
|
11
|
+
createPinIcon,
|
|
12
|
+
createStopIcon,
|
|
13
|
+
createCurrentIcon,
|
|
14
|
+
isRealCoord,
|
|
15
|
+
type MapMarkerStatus,
|
|
16
|
+
} from './leaflet-setup';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import L from 'leaflet';
|
|
2
|
+
// Leaflet's stylesheet — imported once here so every map component inherits it.
|
|
3
|
+
import 'leaflet/dist/leaflet.css';
|
|
4
|
+
// Brand-styled marker CSS (pins, numbered stops, live dot).
|
|
5
|
+
import './map-markers.css';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* OpenStreetMap raster tiles. There is no CSP blocking external tiles in the
|
|
9
|
+
* app-shell, so we use the standard OSM tile server.
|
|
10
|
+
*/
|
|
11
|
+
export const OSM_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|
12
|
+
export const OSM_ATTRIBUTION =
|
|
13
|
+
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
|
14
|
+
|
|
15
|
+
export type MapMarkerStatus =
|
|
16
|
+
| 'active'
|
|
17
|
+
| 'in_transit'
|
|
18
|
+
| 'idle'
|
|
19
|
+
| 'delayed'
|
|
20
|
+
| 'delivered'
|
|
21
|
+
| 'origin'
|
|
22
|
+
| 'destination'
|
|
23
|
+
| 'default';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A coordinate is "real" only when it is finite and not the null-island (0,0)
|
|
27
|
+
* sentinel that backends emit for "unknown location". We never plot those.
|
|
28
|
+
*/
|
|
29
|
+
export function isRealCoord(lat: unknown, lng: unknown): lat is number {
|
|
30
|
+
return (
|
|
31
|
+
typeof lat === 'number' &&
|
|
32
|
+
typeof lng === 'number' &&
|
|
33
|
+
Number.isFinite(lat) &&
|
|
34
|
+
Number.isFinite(lng) &&
|
|
35
|
+
!(lat === 0 && lng === 0) &&
|
|
36
|
+
Math.abs(lat) <= 90 &&
|
|
37
|
+
Math.abs(lng) <= 180
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Map a domain status to a fe-libs design token color (resolved via CSS var, so
|
|
43
|
+
* it stays theme-aware). No raw palette colors.
|
|
44
|
+
*/
|
|
45
|
+
function statusColorToken(status: MapMarkerStatus): string {
|
|
46
|
+
switch (status) {
|
|
47
|
+
case 'delayed':
|
|
48
|
+
return 'var(--color-status-error-text)';
|
|
49
|
+
case 'idle':
|
|
50
|
+
return 'var(--color-status-warning-text)';
|
|
51
|
+
case 'delivered':
|
|
52
|
+
case 'destination':
|
|
53
|
+
return 'var(--color-status-success-text)';
|
|
54
|
+
case 'in_transit':
|
|
55
|
+
case 'active':
|
|
56
|
+
case 'origin':
|
|
57
|
+
default:
|
|
58
|
+
return 'var(--color-brand-primary)';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const TRUCK_GLYPH = `
|
|
63
|
+
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="white"
|
|
64
|
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
65
|
+
<path d="M10 17h4V5H2v12h3"/><path d="M20 17h2v-3.34a4 4 0 0 0-1.17-2.83L19 9h-5v8h1"/>
|
|
66
|
+
<circle cx="7.5" cy="17.5" r="1.5"/><circle cx="17.5" cy="17.5" r="1.5"/>
|
|
67
|
+
</svg>`;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A brand-styled teardrop pin (orange by default) with a white truck glyph.
|
|
71
|
+
* Built as an HTML `divIcon` because Leaflet's default marker PNGs break under
|
|
72
|
+
* bundlers.
|
|
73
|
+
*/
|
|
74
|
+
export function createPinIcon(
|
|
75
|
+
status: MapMarkerStatus = 'default',
|
|
76
|
+
options?: { pulse?: boolean; glyph?: boolean }
|
|
77
|
+
): L.DivIcon {
|
|
78
|
+
const color = statusColorToken(status);
|
|
79
|
+
const pulse = options?.pulse ?? (status === 'in_transit' || status === 'active');
|
|
80
|
+
const glyph = options?.glyph ?? true;
|
|
81
|
+
|
|
82
|
+
const html = `
|
|
83
|
+
<div class="mtw-pin${pulse ? ' mtw-pin--pulse' : ''}" style="--mtw-pin-color:${color}">
|
|
84
|
+
<span class="mtw-pin__body">${glyph ? TRUCK_GLYPH : ''}</span>
|
|
85
|
+
</div>`;
|
|
86
|
+
|
|
87
|
+
return L.divIcon({
|
|
88
|
+
html,
|
|
89
|
+
className: 'mtw-pin-wrapper',
|
|
90
|
+
iconSize: [30, 38],
|
|
91
|
+
iconAnchor: [15, 36],
|
|
92
|
+
popupAnchor: [0, -32],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A small numbered circle marker for ordered route stops.
|
|
98
|
+
*/
|
|
99
|
+
export function createStopIcon(sequence: number, color = 'var(--color-brand-primary)'): L.DivIcon {
|
|
100
|
+
const html = `
|
|
101
|
+
<div class="mtw-stop" style="--mtw-pin-color:${color}">
|
|
102
|
+
<span class="mtw-stop__num">${sequence}</span>
|
|
103
|
+
</div>`;
|
|
104
|
+
return L.divIcon({
|
|
105
|
+
html,
|
|
106
|
+
className: 'mtw-pin-wrapper',
|
|
107
|
+
iconSize: [28, 28],
|
|
108
|
+
iconAnchor: [14, 14],
|
|
109
|
+
popupAnchor: [0, -14],
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A pulsing dot for the shipment's current/live position.
|
|
115
|
+
*/
|
|
116
|
+
export function createCurrentIcon(color = 'var(--color-brand-primary)'): L.DivIcon {
|
|
117
|
+
const html = `<div class="mtw-current" style="--mtw-pin-color:${color}"><span></span></div>`;
|
|
118
|
+
return L.divIcon({
|
|
119
|
+
html,
|
|
120
|
+
className: 'mtw-pin-wrapper',
|
|
121
|
+
iconSize: [20, 20],
|
|
122
|
+
iconAnchor: [10, 10],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/* Brand-styled Leaflet markers for MoveTheWheels.
|
|
2
|
+
* Colors come from --mtw-pin-color, which each divIcon sets from a design token
|
|
3
|
+
* (var(--color-*)), so markers stay theme-aware with no raw palette values. */
|
|
4
|
+
|
|
5
|
+
.mtw-pin-wrapper {
|
|
6
|
+
background: transparent;
|
|
7
|
+
border: 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.mtw-pin {
|
|
11
|
+
position: relative;
|
|
12
|
+
width: 30px;
|
|
13
|
+
height: 38px;
|
|
14
|
+
filter: drop-shadow(0 4px 6px rgb(0 0 0 / 0.28));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.mtw-pin__body {
|
|
18
|
+
position: absolute;
|
|
19
|
+
inset: 0;
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
justify-content: center;
|
|
23
|
+
padding-bottom: 8px;
|
|
24
|
+
background: var(--mtw-pin-color, #f97316);
|
|
25
|
+
border: 2px solid rgb(255 255 255 / 0.9);
|
|
26
|
+
border-radius: 50% 50% 50% 0;
|
|
27
|
+
transform: rotate(-45deg);
|
|
28
|
+
box-sizing: border-box;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.mtw-pin__body > svg {
|
|
32
|
+
transform: rotate(45deg);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.mtw-pin--pulse::before {
|
|
36
|
+
content: '';
|
|
37
|
+
position: absolute;
|
|
38
|
+
left: 50%;
|
|
39
|
+
top: 13px;
|
|
40
|
+
width: 30px;
|
|
41
|
+
height: 30px;
|
|
42
|
+
transform: translate(-50%, -50%);
|
|
43
|
+
border-radius: 50%;
|
|
44
|
+
background: var(--mtw-pin-color, #f97316);
|
|
45
|
+
opacity: 0.45;
|
|
46
|
+
animation: mtw-pin-pulse 1.8s ease-out infinite;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@keyframes mtw-pin-pulse {
|
|
50
|
+
0% {
|
|
51
|
+
transform: translate(-50%, -50%) scale(0.6);
|
|
52
|
+
opacity: 0.5;
|
|
53
|
+
}
|
|
54
|
+
100% {
|
|
55
|
+
transform: translate(-50%, -50%) scale(2.4);
|
|
56
|
+
opacity: 0;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.mtw-stop {
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
justify-content: center;
|
|
64
|
+
width: 28px;
|
|
65
|
+
height: 28px;
|
|
66
|
+
border-radius: 9999px;
|
|
67
|
+
background: var(--mtw-pin-color, #f97316);
|
|
68
|
+
border: 2px solid rgb(255 255 255 / 0.95);
|
|
69
|
+
box-shadow: 0 2px 6px rgb(0 0 0 / 0.28);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.mtw-stop__num {
|
|
73
|
+
color: #fff;
|
|
74
|
+
font-size: 12px;
|
|
75
|
+
font-weight: 700;
|
|
76
|
+
line-height: 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.mtw-current {
|
|
80
|
+
position: relative;
|
|
81
|
+
width: 20px;
|
|
82
|
+
height: 20px;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.mtw-current > span {
|
|
86
|
+
position: absolute;
|
|
87
|
+
inset: 4px;
|
|
88
|
+
border-radius: 9999px;
|
|
89
|
+
background: var(--mtw-pin-color, #f97316);
|
|
90
|
+
border: 2px solid rgb(255 255 255 / 0.95);
|
|
91
|
+
box-shadow: 0 2px 6px rgb(0 0 0 / 0.3);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.mtw-current::after {
|
|
95
|
+
content: '';
|
|
96
|
+
position: absolute;
|
|
97
|
+
inset: 0;
|
|
98
|
+
border-radius: 9999px;
|
|
99
|
+
background: var(--mtw-pin-color, #f97316);
|
|
100
|
+
opacity: 0.4;
|
|
101
|
+
animation: mtw-pin-pulse 1.8s ease-out infinite;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@media (prefers-reduced-motion: reduce) {
|
|
105
|
+
.mtw-pin--pulse::before,
|
|
106
|
+
.mtw-current::after {
|
|
107
|
+
animation: none;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Keep Leaflet controls/attribution visually consistent with the app surface. */
|
|
112
|
+
.leaflet-container {
|
|
113
|
+
font-family: inherit;
|
|
114
|
+
background: var(--color-bg-sunken, #eef1f5);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.leaflet-container .leaflet-control-attribution {
|
|
118
|
+
background: rgb(255 255 255 / 0.75);
|
|
119
|
+
color: #4b5563;
|
|
120
|
+
font-size: 10px;
|
|
121
|
+
}
|
|
@@ -165,6 +165,7 @@ export function useAnalytics(_options: UseAnalyticsOptions = {}): UseAnalyticsRe
|
|
|
165
165
|
const data = await executeGraphQL<{
|
|
166
166
|
analytics: {
|
|
167
167
|
orderTrend: Array<{ label: string; value: number }>;
|
|
168
|
+
deliveryTrend: Array<{ label: string; value: number }>;
|
|
168
169
|
revenueTrend: Array<{ label: string; value: number }>;
|
|
169
170
|
};
|
|
170
171
|
dashboardMetrics: {
|
|
@@ -191,10 +192,19 @@ export function useAnalytics(_options: UseAnalyticsOptions = {}): UseAnalyticsRe
|
|
|
191
192
|
customerSatisfaction: data.dashboardMetrics.customerSatisfaction,
|
|
192
193
|
},
|
|
193
194
|
trends: {
|
|
194
|
-
dailyOrders:
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
195
|
+
dailyOrders: (() => {
|
|
196
|
+
const deliveriesByLabel = new Map(
|
|
197
|
+
(data.analytics.deliveryTrend ?? []).map((item) => [
|
|
198
|
+
item.label,
|
|
199
|
+
Math.round(item.value),
|
|
200
|
+
])
|
|
201
|
+
);
|
|
202
|
+
return data.analytics.orderTrend.map((item) => ({
|
|
203
|
+
date: item.label,
|
|
204
|
+
orders: Math.round(item.value),
|
|
205
|
+
deliveries: deliveriesByLabel.get(item.label) ?? 0,
|
|
206
|
+
}));
|
|
207
|
+
})(),
|
|
198
208
|
revenue: data.analytics.revenueTrend.map((item) => ({
|
|
199
209
|
month: item.label,
|
|
200
210
|
revenue: item.value,
|
|
@@ -1,20 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Analytics Page
|
|
3
|
+
*
|
|
4
|
+
* Real charts (recharts) fed entirely from Query.analytics / orderStats /
|
|
5
|
+
* fleetStats. Metrics we cannot source (e.g. per-lane volume) are simply not
|
|
6
|
+
* shown rather than faked.
|
|
3
7
|
*/
|
|
4
8
|
|
|
5
|
-
import {
|
|
9
|
+
import { useMemo } from 'react';
|
|
10
|
+
import { BarChart3, TrendingUp, Package, DollarSign, Users, Star } from 'lucide-react';
|
|
6
11
|
import { PageLayout } from '../components/PageLayout';
|
|
7
12
|
import { MetricCard, MetricGrid, MetricCardSkeleton } from '../components/MetricCard';
|
|
8
13
|
import { Card, CardContent, CardHeader, CardTitle, Progress, Button } from '../components/ui';
|
|
14
|
+
import { TrendChart, VolumeBar, StatusDonut, type DonutSlice } from '../components/charts';
|
|
9
15
|
import { EmphasisPanel, PagePurpose } from '@burdenoff/fe-libs/ui';
|
|
10
16
|
import { useAnalytics, useOrderAnalytics, useFleetAnalytics } from '../hooks/useAnalytics';
|
|
11
17
|
import { formatCurrency } from '../utils/formatters';
|
|
12
18
|
|
|
19
|
+
const statusLabel = (status: string) =>
|
|
20
|
+
status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
21
|
+
|
|
13
22
|
export default function AnalyticsPage() {
|
|
14
23
|
const { analytics, isLoading, error, refetch } = useAnalytics();
|
|
15
24
|
const { orderData } = useOrderAnalytics();
|
|
16
25
|
const { fleetData } = useFleetAnalytics();
|
|
17
26
|
|
|
27
|
+
const orderSpark = useMemo(
|
|
28
|
+
() => analytics.trends.dailyOrders.map((d) => d.orders),
|
|
29
|
+
[analytics.trends.dailyOrders]
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const revenueData = useMemo(
|
|
33
|
+
() => analytics.trends.revenue.map((r) => ({ month: r.month, revenue: r.revenue })),
|
|
34
|
+
[analytics.trends.revenue]
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const statusSlices = useMemo<DonutSlice[]>(
|
|
38
|
+
() =>
|
|
39
|
+
orderData.byStatus
|
|
40
|
+
.filter((s) => s.count > 0)
|
|
41
|
+
.map((s) => ({ name: statusLabel(s.status), value: s.count })),
|
|
42
|
+
[orderData.byStatus]
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const statusTotal = statusSlices.reduce((sum, s) => sum + s.value, 0);
|
|
46
|
+
|
|
18
47
|
return (
|
|
19
48
|
<PageLayout
|
|
20
49
|
title="Analytics"
|
|
@@ -38,17 +67,19 @@ export default function AnalyticsPage() {
|
|
|
38
67
|
</CardContent>
|
|
39
68
|
</Card>
|
|
40
69
|
) : isLoading ? (
|
|
41
|
-
<MetricGrid columns={
|
|
42
|
-
{Array.from({ length:
|
|
70
|
+
<MetricGrid columns={4}>
|
|
71
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
43
72
|
<MetricCardSkeleton key={i} />
|
|
44
73
|
))}
|
|
45
74
|
</MetricGrid>
|
|
46
75
|
) : (
|
|
47
|
-
<MetricGrid columns={
|
|
76
|
+
<MetricGrid columns={4}>
|
|
48
77
|
<MetricCard
|
|
49
|
-
label="
|
|
78
|
+
label="Order Volume"
|
|
50
79
|
value={analytics.overview.totalOrders.toLocaleString()}
|
|
51
80
|
icon={<Package size={20} />}
|
|
81
|
+
accent="brand"
|
|
82
|
+
sparklineData={orderSpark.length >= 2 ? orderSpark : undefined}
|
|
52
83
|
/>
|
|
53
84
|
<MetricCard
|
|
54
85
|
treatment="glass"
|
|
@@ -56,99 +87,83 @@ export default function AnalyticsPage() {
|
|
|
56
87
|
label="Revenue"
|
|
57
88
|
value={formatCurrency(analytics.overview.totalRevenue)}
|
|
58
89
|
icon={<DollarSign size={20} />}
|
|
90
|
+
accent="success"
|
|
59
91
|
/>
|
|
60
92
|
<MetricCard
|
|
61
|
-
label="
|
|
62
|
-
value={analytics.overview.activeVehicles}
|
|
63
|
-
icon={<Truck size={20} />}
|
|
64
|
-
/>
|
|
65
|
-
<MetricCard
|
|
66
|
-
label="On-Time Rate"
|
|
93
|
+
label="On-Time Delivery"
|
|
67
94
|
value={`${analytics.overview.onTimeDeliveryRate.toFixed(1)}%`}
|
|
68
95
|
icon={<TrendingUp size={20} />}
|
|
69
|
-
|
|
96
|
+
accent="blue"
|
|
97
|
+
delta={
|
|
98
|
+
analytics.overview.onTimeDeliveryRate >= 90
|
|
99
|
+
? { value: 'On target', direction: 'up' }
|
|
100
|
+
: { value: 'Below 90%', direction: 'down' }
|
|
101
|
+
}
|
|
70
102
|
/>
|
|
71
103
|
<MetricCard
|
|
72
|
-
label="Satisfaction"
|
|
104
|
+
label="Customer Satisfaction"
|
|
73
105
|
value={analytics.overview.customerSatisfaction.toFixed(1)}
|
|
74
|
-
icon={<
|
|
106
|
+
icon={<Star size={20} />}
|
|
107
|
+
accent="warning"
|
|
75
108
|
subtitle="out of 5"
|
|
76
109
|
/>
|
|
77
110
|
</MetricGrid>
|
|
78
111
|
)}
|
|
79
112
|
|
|
113
|
+
{/* Order & delivery trend */}
|
|
114
|
+
<Card>
|
|
115
|
+
<CardHeader>
|
|
116
|
+
<CardTitle>Order & delivery trend</CardTitle>
|
|
117
|
+
</CardHeader>
|
|
118
|
+
<CardContent>
|
|
119
|
+
<TrendChart
|
|
120
|
+
xKey="date"
|
|
121
|
+
height={300}
|
|
122
|
+
data={analytics.trends.dailyOrders}
|
|
123
|
+
series={[
|
|
124
|
+
{ dataKey: 'orders', name: 'Orders' },
|
|
125
|
+
{ dataKey: 'deliveries', name: 'Deliveries' },
|
|
126
|
+
]}
|
|
127
|
+
emptyMessage="No order trend recorded yet"
|
|
128
|
+
/>
|
|
129
|
+
</CardContent>
|
|
130
|
+
</Card>
|
|
131
|
+
|
|
80
132
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
81
133
|
<Card>
|
|
82
134
|
<CardHeader>
|
|
83
|
-
<CardTitle>
|
|
135
|
+
<CardTitle>Revenue by month</CardTitle>
|
|
84
136
|
</CardHeader>
|
|
85
137
|
<CardContent>
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
<div key={t.date} className="text-center p-2 bg-bg-sunken/50 rounded">
|
|
96
|
-
<p className="text-xs text-text-secondary">
|
|
97
|
-
{t.date.split('-').slice(1).join('/')}
|
|
98
|
-
</p>
|
|
99
|
-
<p className="font-medium">{t.orders}</p>
|
|
100
|
-
</div>
|
|
101
|
-
))}
|
|
102
|
-
</div>
|
|
138
|
+
<VolumeBar
|
|
139
|
+
xKey="month"
|
|
140
|
+
height={280}
|
|
141
|
+
showLegend={false}
|
|
142
|
+
data={revenueData}
|
|
143
|
+
series={[{ dataKey: 'revenue', name: 'Revenue' }]}
|
|
144
|
+
valueFormatter={(v) => formatCurrency(Number(v))}
|
|
145
|
+
emptyMessage="No revenue recorded yet"
|
|
146
|
+
/>
|
|
103
147
|
</CardContent>
|
|
104
148
|
</Card>
|
|
105
149
|
|
|
106
150
|
<Card>
|
|
107
151
|
<CardHeader>
|
|
108
|
-
<CardTitle>
|
|
152
|
+
<CardTitle>Orders by status</CardTitle>
|
|
109
153
|
</CardHeader>
|
|
110
154
|
<CardContent>
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
{analytics.trends.revenue.map((r) => (
|
|
119
|
-
<div key={r.month} className="text-center p-2 bg-bg-sunken/50 rounded">
|
|
120
|
-
<p className="text-xs text-text-secondary">{r.month}</p>
|
|
121
|
-
<p className="font-medium text-sm">{formatCurrency(r.revenue / 1000)}k</p>
|
|
122
|
-
</div>
|
|
123
|
-
))}
|
|
124
|
-
</div>
|
|
155
|
+
<StatusDonut
|
|
156
|
+
data={statusSlices}
|
|
157
|
+
height={240}
|
|
158
|
+
centerLabel={statusTotal.toLocaleString()}
|
|
159
|
+
centerSubLabel="orders"
|
|
160
|
+
emptyMessage="No orders to break down yet"
|
|
161
|
+
/>
|
|
125
162
|
</CardContent>
|
|
126
163
|
</Card>
|
|
127
164
|
</div>
|
|
128
165
|
|
|
129
|
-
<div className="grid grid-cols-1 lg:grid-cols-
|
|
130
|
-
<Card>
|
|
131
|
-
<CardHeader>
|
|
132
|
-
<CardTitle>Orders by Status</CardTitle>
|
|
133
|
-
</CardHeader>
|
|
134
|
-
<CardContent className="space-y-3">
|
|
135
|
-
{orderData.byStatus
|
|
136
|
-
.filter((s) => s.count > 0)
|
|
137
|
-
.map(({ status, count }) => (
|
|
138
|
-
<div key={status} className="flex items-center justify-between">
|
|
139
|
-
<span className="text-sm capitalize">{status.replace('_', ' ')}</span>
|
|
140
|
-
<div className="flex items-center gap-2">
|
|
141
|
-
<Progress
|
|
142
|
-
value={(count / analytics.overview.totalOrders) * 100}
|
|
143
|
-
className="w-20 h-2"
|
|
144
|
-
/>
|
|
145
|
-
<span className="font-medium w-8 text-right">{count}</span>
|
|
146
|
-
</div>
|
|
147
|
-
</div>
|
|
148
|
-
))}
|
|
149
|
-
</CardContent>
|
|
150
|
-
</Card>
|
|
151
|
-
|
|
166
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
152
167
|
<Card>
|
|
153
168
|
<CardHeader>
|
|
154
169
|
<CardTitle>Fleet Performance</CardTitle>
|
|
@@ -188,9 +203,14 @@ export default function AnalyticsPage() {
|
|
|
188
203
|
{analytics.overview.customerSatisfaction.toFixed(1)}/5
|
|
189
204
|
</p>
|
|
190
205
|
</div>
|
|
191
|
-
<div>
|
|
192
|
-
<
|
|
193
|
-
<
|
|
206
|
+
<div className="flex items-center gap-2">
|
|
207
|
+
<Users size={16} className="text-text-secondary" />
|
|
208
|
+
<div>
|
|
209
|
+
<p className="text-sm text-text-secondary">Driver Ratings</p>
|
|
210
|
+
<p className="text-2xl font-bold">
|
|
211
|
+
{analytics.performance.driverRatings.toFixed(1)}/5
|
|
212
|
+
</p>
|
|
213
|
+
</div>
|
|
194
214
|
</div>
|
|
195
215
|
</EmphasisPanel>
|
|
196
216
|
</div>
|
|
@@ -313,7 +313,7 @@ export default function CreateOrderPage() {
|
|
|
313
313
|
placeholder="123 Main St"
|
|
314
314
|
/>
|
|
315
315
|
</div>
|
|
316
|
-
<div className="grid grid-cols-2 gap-4">
|
|
316
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
317
317
|
<div className="space-y-2">
|
|
318
318
|
<Label htmlFor="pickupCity">City</Label>
|
|
319
319
|
<Input
|
|
@@ -333,7 +333,7 @@ export default function CreateOrderPage() {
|
|
|
333
333
|
/>
|
|
334
334
|
</div>
|
|
335
335
|
</div>
|
|
336
|
-
<div className="grid grid-cols-2 gap-4">
|
|
336
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
337
337
|
<div className="space-y-2">
|
|
338
338
|
<Label htmlFor="pickupPostal">Postal Code</Label>
|
|
339
339
|
<Input
|
|
@@ -371,7 +371,7 @@ export default function CreateOrderPage() {
|
|
|
371
371
|
placeholder="456 Oak Ave"
|
|
372
372
|
/>
|
|
373
373
|
</div>
|
|
374
|
-
<div className="grid grid-cols-2 gap-4">
|
|
374
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
375
375
|
<div className="space-y-2">
|
|
376
376
|
<Label htmlFor="deliveryCity">City</Label>
|
|
377
377
|
<Input
|
|
@@ -391,7 +391,7 @@ export default function CreateOrderPage() {
|
|
|
391
391
|
/>
|
|
392
392
|
</div>
|
|
393
393
|
</div>
|
|
394
|
-
<div className="grid grid-cols-2 gap-4">
|
|
394
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
395
395
|
<div className="space-y-2">
|
|
396
396
|
<Label htmlFor="deliveryPostal">Postal Code</Label>
|
|
397
397
|
<Input
|