@burdenoff/microfe-movethewheels 2026.703.3 → 2026.703.5
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-C3cPsWt-.js → AIAssistantPage-D2gaEL1U.js} +16 -15
- package/dist/AnalyticsPage-DZMlEoBi.js +194 -0
- package/dist/CreateOrderPage-CWHx8yys.js +490 -0
- package/dist/CustomerDetailsPage-nR4oZ_ju.js +615 -0
- package/dist/CustomersPage-Bbvwvn8k.js +205 -0
- package/dist/DashboardPage-DrEhNf7N.js +542 -0
- package/dist/DataTable-CqMUD4er.js +340 -0
- package/dist/DocumentsCard-edDkgnJj.js +169 -0
- package/dist/DriverDetailsPage-whEMOWs9.js +490 -0
- package/dist/DriversPage-CHF2ZEzd.js +221 -0
- package/dist/FinancePage-Dn9GrW-1.js +175 -0
- package/dist/{FleetPage-DqkFi3s3.js → FleetPage-CrtGPy-p.js} +36 -35
- package/dist/{FormError-BlOjnXOq.js → FormError-BKcb_wT-.js} +1 -1
- package/dist/{ImportExportPage-DKJRCezl.js → ImportExportPage-WYdwZomZ.js} +52 -72
- package/dist/InventoryPage-l5A1f753.js +259 -0
- package/dist/LiveTrackingPage-DhEaNKZ4.js +429 -0
- package/dist/MarketplacePage-B9RYOb1Y.js +426 -0
- package/dist/MetricCard-y3bWHKUQ.js +191 -0
- package/dist/Notice-DOO-tjpV.js +46 -0
- package/dist/OrderDetailsPage-CwYHwk1K.js +584 -0
- package/dist/OrdersListPage-DhZJoQeI.js +386 -0
- package/dist/{PageLayout-D4LDHJJq.js → PageLayout-BniRJaQ6.js} +1 -1
- package/dist/{ProductDetailsPage-C9uOiYLp.js → ProductDetailsPage-1Gm9JKy0.js} +56 -56
- package/dist/ProductsPage-C8sn-1tN.js +186 -0
- package/dist/ReportsPage-D1tb0dcz.js +377 -0
- package/dist/RouteDetailsPage-CkMyR9b_.js +610 -0
- package/dist/{RoutesPage-DMfoicM1.js → RoutesPage-QkENzsE5.js} +58 -47
- package/dist/{SettingsPage-Bn-v0mbQ.js → SettingsPage-DV3_tU_X.js} +16 -38
- package/dist/Sparkline-K2RMJ58u.js +16729 -0
- package/dist/{StatusBadge-BZurYHMy.js → StatusBadge-BNHXYvZx.js} +7 -7
- package/dist/TrackingPage-BZrg4RCu.js +368 -0
- package/dist/VehicleDetailsPage-D-8XPZ2G.js +393 -0
- package/dist/VehiclesPage-D5BELbtM.js +184 -0
- package/dist/{WarehouseDetailsPage-L_6sWigM.js → WarehouseDetailsPage-Cmg-8Frn.js} +70 -70
- package/dist/WarehousesPage-C1Y1M867.js +182 -0
- package/dist/{arrow-left-n48Y-Wis.js → arrow-left-ClW4Isj7.js} +1 -1
- package/dist/arrow-right-CmKOmIkh.js +11 -0
- package/dist/arrow-up-BJijRLAZ.js +17 -0
- package/dist/{box-D9Pal4HM.js → box-BT3NsRQP.js} +1 -1
- package/dist/boxes-B_OGvQiW.js +54 -0
- package/dist/{chart-column-BFIJysDR.js → chart-column-DQTTdUPk.js} +1 -1
- package/dist/charts-Tj_PPTfA.js +2018 -0
- package/dist/chevron-left-B1rZyWWb.js +8 -0
- package/dist/{chevron-right-EMIIyyJ1.js → chevron-right-BIcwUJZq.js} +1 -1
- package/dist/chunk-BdsCSvBP.js +20 -0
- package/dist/{circle-alert-iY24lSlp.js → circle-alert-CNkuDNc6.js} +1 -1
- package/dist/circle-check-Cs1OVjtV.js +13 -0
- package/dist/{circle-check-big-w0TrLKMF.js → circle-check-big-C-p4eNwn.js} +1 -1
- package/dist/{clock-DJZtdl8u.js → clock-dRGG4rkF.js} +1 -1
- package/dist/{dollar-sign-CgKQRz_e.js → dollar-sign-B6RoYHHz.js} +1 -1
- package/dist/{download-DRXxoiQR.js → download-DP04iWTd.js} +1 -1
- package/dist/{file-text-DWWY6_f5.js → file-text-BuDx4hZp.js} +1 -1
- package/dist/{filter-DloLC2eE.js → filter-BD483_8g.js} +1 -1
- package/dist/{wrench-CANXWxF-.js → fuel-DLH1ag04.js} +3 -6
- package/dist/index.css +2 -0
- package/dist/index.js +150 -172
- package/dist/{map-CkJaULb2.js → map-DNQn9fyY.js} +1 -1
- package/dist/{map-pin-BGiCvyCs.js → map-pin-CqN1bbT1.js} +1 -1
- package/dist/mapMarkers-NCV9MRKR.js +28 -0
- package/dist/maps-yzLqiK_C.js +5070 -0
- package/dist/{navigation-D36prnAn.js → navigation-CgGUt2tR.js} +1 -1
- package/dist/{package-BChUzpJS.js → package-DmRF7iQD.js} +1 -1
- package/dist/{phone-Ac9s3mo_.js → phone-Cl32EXKW.js} +1 -1
- package/dist/{plus-Z76q-FZm.js → plus-DlANgOFx.js} +1 -1
- package/dist/{refresh-cw-DKt6f5T6.js → refresh-cw-rW59D4b7.js} +1 -1
- package/dist/route-CWjPrtAN.js +22 -0
- package/dist/{save-CojywNhG.js → save-UDlog7hg.js} +1 -1
- package/dist/{search-CDGefqb3.js → search-DNIG5Ck7.js} +1 -1
- package/dist/{square-pen-CopE9GmB.js → square-pen-BcfB3Grd.js} +1 -1
- package/dist/{star-nHlLjDKR.js → star-CVZHFwDx.js} +1 -1
- package/dist/{store-BR9Voi5E.js → store-bwnmCK9f.js} +1 -1
- package/dist/{trash-2-BKhar6Hs.js → trash-2-B80G12d4.js} +1 -1
- package/dist/{trending-up-Dohgd4t_.js → trending-up-CH4R4KOa.js} +1 -1
- package/dist/{triangle-alert-i-XQL9Rd.js → triangle-alert-CmS8AUU6.js} +1 -1
- package/dist/{truck-C0v_hbLR.js → truck-9rLDncav.js} +1 -1
- package/dist/upload-DanJY1Vo.js +21 -0
- package/dist/{useAssistant-D4v3oxD3.js → useAssistant-CnEwkTLm.js} +11 -28
- package/dist/{useCustomers-BmTNGxiI.js → useCustomers-ChJao1ET.js} +30 -27
- package/dist/{useFleet-DKqXZ16m.js → useFleet-g5Yri0G8.js} +82 -70
- package/dist/{useImportExport-DHr-SWSw.js → useImportExport-MYwIT3rE.js} +1 -1
- package/dist/{useInventory-DkyiVY_A.js → useInventory-BSOn-R1c.js} +84 -39
- package/dist/{useOrders-Cfk1GLiq.js → useOrders-BSiqo61w.js} +59 -48
- package/dist/{useReports-CTqZr-AB.js → useReports-fWESfsF9.js} +41 -35
- package/dist/{useRoutes-BQWO607N.js → useRoutes-zD8Yp323.js} +50 -42
- package/dist/{useSettings-CkDH_-gM.js → useSettings-B1lsWeZ1.js} +6 -6
- package/dist/{useTracking-B-va1kO3.js → useTracking-BKG0I2ln.js} +3 -3
- package/dist/{user-jyFU3lXk.js → user-DU51el3m.js} +1 -1
- package/dist/user-check-IMhk-qkX.js +20 -0
- package/dist/{users-DZ-ilS6B.js → users-B8KhJBGg.js} +1 -1
- package/dist/{warehouse-6m5kXMLu.js → warehouse-CcMMioeY.js} +1 -1
- package/dist/wrench-Dg-sgH2e.js +8 -0
- package/dist/x-C4ObLVEa.js +11 -0
- package/package.json +5 -1
- package/src/movethewheels/components/DataTable.tsx +252 -84
- package/src/movethewheels/components/DocumentsCard.tsx +102 -0
- package/src/movethewheels/components/GlassPanel.tsx +71 -0
- package/src/movethewheels/components/MetricCard.tsx +171 -73
- package/src/movethewheels/components/Notice.tsx +61 -0
- 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 +51 -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/index.ts +9 -0
- package/src/movethewheels/hooks/useAnalytics.ts +109 -4
- package/src/movethewheels/hooks/useCustomers.ts +11 -4
- package/src/movethewheels/hooks/useEntityDocuments.ts +132 -0
- package/src/movethewheels/hooks/useFleet.ts +26 -7
- package/src/movethewheels/hooks/useInventory.ts +70 -7
- package/src/movethewheels/hooks/useOrders.ts +32 -2
- package/src/movethewheels/hooks/useReports.ts +14 -4
- package/src/movethewheels/hooks/useRoutes.ts +20 -3
- package/src/movethewheels/pages/AnalyticsPage.tsx +93 -73
- package/src/movethewheels/pages/CreateOrderPage.tsx +48 -20
- package/src/movethewheels/pages/CustomerDetailsPage.tsx +239 -33
- package/src/movethewheels/pages/CustomersPage.tsx +53 -28
- package/src/movethewheels/pages/DashboardPage.tsx +238 -12
- package/src/movethewheels/pages/DriverDetailsPage.tsx +17 -31
- package/src/movethewheels/pages/DriversPage.tsx +59 -31
- package/src/movethewheels/pages/FinancePage.tsx +103 -82
- package/src/movethewheels/pages/InventoryPage.tsx +15 -3
- package/src/movethewheels/pages/LiveTrackingPage.tsx +124 -16
- package/src/movethewheels/pages/MarketplacePage.tsx +265 -50
- package/src/movethewheels/pages/OrderDetailsPage.tsx +214 -10
- package/src/movethewheels/pages/OrdersListPage.tsx +118 -47
- package/src/movethewheels/pages/ProductsPage.tsx +52 -28
- package/src/movethewheels/pages/ReportsPage.tsx +88 -4
- package/src/movethewheels/pages/RouteDetailsPage.tsx +119 -19
- package/src/movethewheels/pages/RoutesPage.tsx +22 -6
- package/src/movethewheels/pages/SettingsPage.tsx +5 -21
- package/src/movethewheels/pages/TrackingPage.tsx +76 -6
- package/src/movethewheels/pages/VehicleDetailsPage.tsx +4 -34
- package/src/movethewheels/pages/VehiclesPage.tsx +58 -27
- package/src/movethewheels/pages/WarehouseDetailsPage.tsx +1 -1
- package/src/movethewheels/pages/WarehousesPage.tsx +47 -36
- package/src/movethewheels/types/index.ts +9 -1
- package/src/movethewheels/utils/download.ts +62 -0
- package/src/movethewheels/utils/mapMarkers.ts +58 -0
- package/dist/AnalyticsPage-Dz6g2MvK.js +0 -215
- package/dist/CreateOrderPage-C5iwnjp4.js +0 -479
- package/dist/CustomerDetailsPage-B6nwFyLO.js +0 -406
- package/dist/CustomersPage-BKxMS1AV.js +0 -161
- package/dist/DashboardPage-BVjQi0EB.js +0 -387
- package/dist/DataTable-DmciIKKM.js +0 -249
- package/dist/DriverDetailsPage-CN3WQ0B9.js +0 -507
- package/dist/DriversPage-CH9rjQC-.js +0 -165
- package/dist/FinancePage-DEDykSOP.js +0 -158
- package/dist/InventoryPage-e0ze63iX.js +0 -253
- package/dist/LiveTrackingPage-DCX-K-LR.js +0 -379
- package/dist/MarketplacePage-CIQ97_zp.js +0 -247
- package/dist/MetricCard-BY0QrMD4.js +0 -146
- package/dist/OrderDetailsPage-DYiZ-9UK.js +0 -429
- package/dist/OrdersListPage-Cn90P1Xx.js +0 -307
- package/dist/ProductsPage-QJfHHpqB.js +0 -169
- package/dist/ReportsPage-Zp53q3_8.js +0 -284
- package/dist/RouteDetailsPage-DUStXSf9.js +0 -531
- package/dist/TrackingPage-W-jtdM3Y.js +0 -325
- package/dist/VehicleDetailsPage-Bceo5oz3.js +0 -410
- package/dist/VehiclesPage-ApKThYeG.js +0 -165
- package/dist/WarehousesPage-DCbXXXWE.js +0 -162
- package/dist/useAnalytics-DS8n0xgg.js +0 -297
- /package/dist/{formatters-CF2S2l1m.js → formatters-FWJDwTFv.js} +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Customer Details Page
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { useState } from 'react';
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
6
6
|
import { useParams } from 'react-router-dom';
|
|
7
7
|
import {
|
|
8
8
|
Users,
|
|
@@ -15,9 +15,11 @@ import {
|
|
|
15
15
|
Building,
|
|
16
16
|
FileText,
|
|
17
17
|
Save,
|
|
18
|
+
Package,
|
|
19
|
+
X,
|
|
18
20
|
} from 'lucide-react';
|
|
19
21
|
import { PageLayout, LoadingState, ErrorState } from '../components/PageLayout';
|
|
20
|
-
import { TypeBadge } from '../components/StatusBadge';
|
|
22
|
+
import { TypeBadge, StatusBadge } from '../components/StatusBadge';
|
|
21
23
|
import {
|
|
22
24
|
Card,
|
|
23
25
|
CardContent,
|
|
@@ -35,10 +37,28 @@ import {
|
|
|
35
37
|
} from '../components/ui';
|
|
36
38
|
import { EmphasisPanel, NextSteps, PagePurpose } from '@burdenoff/fe-libs/ui';
|
|
37
39
|
import { useCustomer, useCustomers } from '../hooks/useCustomers';
|
|
40
|
+
import { useOrders } from '../hooks/useOrders';
|
|
38
41
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
39
|
-
import { formatAddress, formatDate } from '../utils/formatters';
|
|
42
|
+
import { formatAddress, formatCurrency, formatDate } from '../utils/formatters';
|
|
40
43
|
import { joinPath } from '../utils/navigation';
|
|
41
44
|
import { FormError } from '../components/FormError';
|
|
45
|
+
import { Notice, useNotice } from '../components/Notice';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Only surface http(s) links. Anything else (javascript:, data:, mailto:, etc.)
|
|
49
|
+
* is unsafe to render as a raw href, so we drop it.
|
|
50
|
+
*/
|
|
51
|
+
function safeHttpUrl(raw: string | undefined): string | null {
|
|
52
|
+
if (!raw) return null;
|
|
53
|
+
const value = raw.trim();
|
|
54
|
+
const candidate = /^https?:\/\//i.test(value) ? value : `https://${value}`;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = new URL(candidate);
|
|
57
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
42
62
|
|
|
43
63
|
function NewCustomerForm() {
|
|
44
64
|
const { basePath, navigate } = useMoveTheWheels();
|
|
@@ -177,10 +197,77 @@ export default function CustomerDetailsPage() {
|
|
|
177
197
|
const { customerId } = useParams<{ customerId: string }>();
|
|
178
198
|
const { basePath, navigate } = useMoveTheWheels();
|
|
179
199
|
const { customer, isLoading, error, refetch } = useCustomer(customerId);
|
|
200
|
+
const { updateCustomer } = useCustomers();
|
|
201
|
+
const { orders, isLoading: ordersLoading } = useOrders({ filters: { customerId } });
|
|
202
|
+
const { message: notice, notify } = useNotice();
|
|
203
|
+
|
|
204
|
+
const [editing, setEditing] = useState(false);
|
|
205
|
+
const [saving, setSaving] = useState(false);
|
|
206
|
+
const [editError, setEditError] = useState<string | null>(null);
|
|
207
|
+
const [form, setForm] = useState({
|
|
208
|
+
name: '',
|
|
209
|
+
email: '',
|
|
210
|
+
phone: '',
|
|
211
|
+
company: '',
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
if (customer) {
|
|
216
|
+
setForm({
|
|
217
|
+
name: customer.name,
|
|
218
|
+
email: customer.email,
|
|
219
|
+
phone: customer.phone,
|
|
220
|
+
company: customer.company ?? '',
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}, [customer]);
|
|
180
224
|
|
|
181
225
|
const goBack = () => navigate?.(joinPath(basePath, 'customers'));
|
|
182
226
|
const goTo = (path: string) => navigate?.(joinPath(basePath, path));
|
|
183
227
|
|
|
228
|
+
const startEdit = () => {
|
|
229
|
+
setEditError(null);
|
|
230
|
+
setEditing(true);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const cancelEdit = () => {
|
|
234
|
+
setEditError(null);
|
|
235
|
+
setEditing(false);
|
|
236
|
+
if (customer) {
|
|
237
|
+
setForm({
|
|
238
|
+
name: customer.name,
|
|
239
|
+
email: customer.email,
|
|
240
|
+
phone: customer.phone,
|
|
241
|
+
company: customer.company ?? '',
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const handleSave = async () => {
|
|
247
|
+
if (!customerId) return;
|
|
248
|
+
if (!form.name.trim()) {
|
|
249
|
+
setEditError('Customer name is required.');
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
setSaving(true);
|
|
253
|
+
setEditError(null);
|
|
254
|
+
try {
|
|
255
|
+
await updateCustomer(customerId, {
|
|
256
|
+
name: form.name.trim(),
|
|
257
|
+
email: form.email.trim(),
|
|
258
|
+
phone: form.phone.trim(),
|
|
259
|
+
company: form.company.trim() || undefined,
|
|
260
|
+
});
|
|
261
|
+
notify('Customer updated.');
|
|
262
|
+
setEditing(false);
|
|
263
|
+
refetch();
|
|
264
|
+
} catch (err) {
|
|
265
|
+
setEditError(err instanceof Error ? err.message : 'Failed to update customer.');
|
|
266
|
+
} finally {
|
|
267
|
+
setSaving(false);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
184
271
|
// Handle new customer form
|
|
185
272
|
if (!customerId) {
|
|
186
273
|
return <NewCustomerForm />;
|
|
@@ -199,9 +286,20 @@ export default function CustomerDetailsPage() {
|
|
|
199
286
|
<Button variant="outline" onClick={goBack}>
|
|
200
287
|
<ArrowLeft size={16} /> Back
|
|
201
288
|
</Button>
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
289
|
+
{editing ? (
|
|
290
|
+
<>
|
|
291
|
+
<Button variant="outline" onClick={cancelEdit} disabled={saving}>
|
|
292
|
+
<X size={16} /> Cancel
|
|
293
|
+
</Button>
|
|
294
|
+
<Button onClick={handleSave} disabled={saving}>
|
|
295
|
+
<Save size={16} /> {saving ? 'Saving…' : 'Save'}
|
|
296
|
+
</Button>
|
|
297
|
+
</>
|
|
298
|
+
) : (
|
|
299
|
+
<Button onClick={startEdit}>
|
|
300
|
+
<Edit size={16} /> Edit
|
|
301
|
+
</Button>
|
|
302
|
+
)}
|
|
205
303
|
</div>
|
|
206
304
|
}
|
|
207
305
|
headerContent={
|
|
@@ -238,53 +336,112 @@ export default function CustomerDetailsPage() {
|
|
|
238
336
|
label: 'Update contact details',
|
|
239
337
|
description: 'Keep email, phone and address current.',
|
|
240
338
|
icon: <Edit size={16} />,
|
|
241
|
-
onClick:
|
|
339
|
+
onClick: startEdit,
|
|
242
340
|
},
|
|
243
341
|
]}
|
|
244
342
|
/>
|
|
245
343
|
|
|
344
|
+
{notice && (
|
|
345
|
+
<div className="mb-2">
|
|
346
|
+
<Notice message={notice} />
|
|
347
|
+
</div>
|
|
348
|
+
)}
|
|
349
|
+
|
|
246
350
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
247
351
|
<div className="lg:col-span-2 space-y-6">
|
|
248
352
|
{/* Contact Information — the page's single emphasis zone */}
|
|
249
353
|
<EmphasisPanel>
|
|
250
354
|
<h2 className="text-base font-semibold text-text-primary mb-4">Contact Information</h2>
|
|
251
|
-
|
|
355
|
+
{editing ? (
|
|
252
356
|
<div className="space-y-4">
|
|
253
|
-
<
|
|
254
|
-
|
|
255
|
-
<div>
|
|
256
|
-
<
|
|
257
|
-
|
|
357
|
+
<FormError message={editError} />
|
|
358
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
359
|
+
<div className="space-y-2">
|
|
360
|
+
<Label htmlFor="edit-name">
|
|
361
|
+
Name <span className="text-status-error-text">*</span>
|
|
362
|
+
</Label>
|
|
363
|
+
<Input
|
|
364
|
+
id="edit-name"
|
|
365
|
+
value={form.name}
|
|
366
|
+
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
|
367
|
+
/>
|
|
258
368
|
</div>
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
369
|
+
<div className="space-y-2">
|
|
370
|
+
<Label htmlFor="edit-company">Company</Label>
|
|
371
|
+
<Input
|
|
372
|
+
id="edit-company"
|
|
373
|
+
value={form.company}
|
|
374
|
+
onChange={(e) => setForm((prev) => ({ ...prev, company: e.target.value }))}
|
|
375
|
+
placeholder="Company name (for B2B accounts)"
|
|
376
|
+
/>
|
|
377
|
+
</div>
|
|
378
|
+
<div className="space-y-2">
|
|
379
|
+
<Label htmlFor="edit-email">Email</Label>
|
|
380
|
+
<Input
|
|
381
|
+
id="edit-email"
|
|
382
|
+
type="email"
|
|
383
|
+
value={form.email}
|
|
384
|
+
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
|
385
|
+
/>
|
|
386
|
+
</div>
|
|
387
|
+
<div className="space-y-2">
|
|
388
|
+
<Label htmlFor="edit-phone">Phone</Label>
|
|
389
|
+
<Input
|
|
390
|
+
id="edit-phone"
|
|
391
|
+
value={form.phone}
|
|
392
|
+
onChange={(e) => setForm((prev) => ({ ...prev, phone: e.target.value }))}
|
|
393
|
+
/>
|
|
265
394
|
</div>
|
|
266
395
|
</div>
|
|
267
396
|
</div>
|
|
268
|
-
|
|
269
|
-
|
|
397
|
+
) : (
|
|
398
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
399
|
+
<div className="space-y-4">
|
|
270
400
|
<div className="flex items-center gap-3">
|
|
271
|
-
<
|
|
401
|
+
<Mail size={18} className="text-text-secondary" />
|
|
272
402
|
<div>
|
|
273
|
-
<p className="text-sm text-text-secondary">
|
|
274
|
-
<p className="font-medium">{customer.
|
|
403
|
+
<p className="text-sm text-text-secondary">Email</p>
|
|
404
|
+
<p className="font-medium">{customer.email || '—'}</p>
|
|
275
405
|
</div>
|
|
276
406
|
</div>
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
</a>
|
|
407
|
+
<div className="flex items-center gap-3">
|
|
408
|
+
<Phone size={18} className="text-text-secondary" />
|
|
409
|
+
<div>
|
|
410
|
+
<p className="text-sm text-text-secondary">Phone</p>
|
|
411
|
+
<p className="font-medium">{customer.phone || '—'}</p>
|
|
412
|
+
</div>
|
|
284
413
|
</div>
|
|
285
|
-
|
|
414
|
+
</div>
|
|
415
|
+
<div className="space-y-4">
|
|
416
|
+
{customer.company && (
|
|
417
|
+
<div className="flex items-center gap-3">
|
|
418
|
+
<Building size={18} className="text-text-secondary" />
|
|
419
|
+
<div>
|
|
420
|
+
<p className="text-sm text-text-secondary">Company</p>
|
|
421
|
+
<p className="font-medium">{customer.company}</p>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
425
|
+
{(() => {
|
|
426
|
+
const website = safeHttpUrl(customer.website);
|
|
427
|
+
if (!website) return null;
|
|
428
|
+
return (
|
|
429
|
+
<div>
|
|
430
|
+
<p className="text-sm text-text-secondary">Website</p>
|
|
431
|
+
<a
|
|
432
|
+
href={website}
|
|
433
|
+
target="_blank"
|
|
434
|
+
rel="noopener noreferrer"
|
|
435
|
+
className="text-primary hover:underline"
|
|
436
|
+
>
|
|
437
|
+
{website}
|
|
438
|
+
</a>
|
|
439
|
+
</div>
|
|
440
|
+
);
|
|
441
|
+
})()}
|
|
442
|
+
</div>
|
|
286
443
|
</div>
|
|
287
|
-
|
|
444
|
+
)}
|
|
288
445
|
</EmphasisPanel>
|
|
289
446
|
|
|
290
447
|
<Card>
|
|
@@ -310,6 +467,55 @@ export default function CustomerDetailsPage() {
|
|
|
310
467
|
</CardContent>
|
|
311
468
|
</Card>
|
|
312
469
|
)}
|
|
470
|
+
|
|
471
|
+
{/* Orders for this customer */}
|
|
472
|
+
<Card>
|
|
473
|
+
<CardHeader className="flex flex-row items-center justify-between">
|
|
474
|
+
<CardTitle className="flex items-center gap-2">
|
|
475
|
+
<Package size={18} /> Orders
|
|
476
|
+
</CardTitle>
|
|
477
|
+
<Button variant="outline" size="sm" onClick={() => goTo('orders/new')}>
|
|
478
|
+
<FileText size={14} /> New order
|
|
479
|
+
</Button>
|
|
480
|
+
</CardHeader>
|
|
481
|
+
<CardContent>
|
|
482
|
+
{ordersLoading ? (
|
|
483
|
+
<p className="text-text-secondary text-center py-4">Loading orders…</p>
|
|
484
|
+
) : orders.length > 0 ? (
|
|
485
|
+
<div className="space-y-2">
|
|
486
|
+
{orders.map((order) => (
|
|
487
|
+
<button
|
|
488
|
+
key={order.id}
|
|
489
|
+
type="button"
|
|
490
|
+
onClick={() => goTo(`orders/${order.id}`)}
|
|
491
|
+
className="w-full flex items-center justify-between gap-3 p-3 border rounded-lg text-left hover:bg-bg-sunken/50 transition-colors"
|
|
492
|
+
>
|
|
493
|
+
<div className="min-w-0">
|
|
494
|
+
<p className="font-medium truncate">{order.orderNumber}</p>
|
|
495
|
+
<p className="text-sm text-text-secondary">
|
|
496
|
+
{formatDate(order.createdAt, 'relative')} ·{' '}
|
|
497
|
+
{formatCurrency(order.pricing.total)}
|
|
498
|
+
</p>
|
|
499
|
+
</div>
|
|
500
|
+
<StatusBadge status={order.status} type="order" size="sm" />
|
|
501
|
+
</button>
|
|
502
|
+
))}
|
|
503
|
+
</div>
|
|
504
|
+
) : (
|
|
505
|
+
<div className="text-center py-6">
|
|
506
|
+
<Package size={32} className="mx-auto mb-2 text-text-secondary opacity-50" />
|
|
507
|
+
<p className="text-text-secondary">No orders yet for this customer</p>
|
|
508
|
+
<button
|
|
509
|
+
type="button"
|
|
510
|
+
onClick={() => goTo('orders/new')}
|
|
511
|
+
className="mt-1 text-sm text-primary hover:underline"
|
|
512
|
+
>
|
|
513
|
+
Book their first delivery
|
|
514
|
+
</button>
|
|
515
|
+
</div>
|
|
516
|
+
)}
|
|
517
|
+
</CardContent>
|
|
518
|
+
</Card>
|
|
313
519
|
</div>
|
|
314
520
|
|
|
315
521
|
<div className="space-y-6">
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
* Customers Page
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
import { Users, Plus, RefreshCw, Download, Star, UserCheck, Building2 } from 'lucide-react';
|
|
6
7
|
import { PageLayout } from '../components/PageLayout';
|
|
8
|
+
import { MetricCard, MetricGrid } from '../components/MetricCard';
|
|
7
9
|
import { DataTable, type Column } from '../components/DataTable';
|
|
8
10
|
import { TypeBadge } from '../components/StatusBadge';
|
|
9
11
|
import { Badge, Avatar, AvatarFallback, Button } from '../components/ui';
|
|
10
|
-
import { CTAOverflowMenu,
|
|
11
|
-
import { useCustomers } from '../hooks/useCustomers';
|
|
12
|
+
import { CTAOverflowMenu, PagePurpose } from '@burdenoff/fe-libs/ui';
|
|
13
|
+
import { useCustomers, useCustomerStats } from '../hooks/useCustomers';
|
|
12
14
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
13
15
|
import { formatDate } from '../utils/formatters';
|
|
14
16
|
import { joinPath } from '../utils/navigation';
|
|
@@ -16,7 +18,14 @@ import type { Customer } from '../types';
|
|
|
16
18
|
|
|
17
19
|
export default function CustomersPage() {
|
|
18
20
|
const { basePath, navigate } = useMoveTheWheels();
|
|
19
|
-
const
|
|
21
|
+
const [page, setPage] = useState(0);
|
|
22
|
+
const [pageSize, setPageSize] = useState(25);
|
|
23
|
+
const { customers, hasMore, isLoading, refetch } = useCustomers({
|
|
24
|
+
limit: pageSize,
|
|
25
|
+
offset: page * pageSize,
|
|
26
|
+
});
|
|
27
|
+
// Bounded aggregate for the tiles + subtitle — never the truncated page.
|
|
28
|
+
const { stats } = useCustomerStats();
|
|
20
29
|
|
|
21
30
|
const goTo = (path: string) => navigate?.(joinPath(basePath, path));
|
|
22
31
|
|
|
@@ -85,7 +94,7 @@ export default function CustomersPage() {
|
|
|
85
94
|
return (
|
|
86
95
|
<PageLayout
|
|
87
96
|
title="Customers"
|
|
88
|
-
subtitle={`${
|
|
97
|
+
subtitle={`${stats.total} customers`}
|
|
89
98
|
icon={<Users size={24} />}
|
|
90
99
|
headerActions={
|
|
91
100
|
<CTAOverflowMenu
|
|
@@ -96,7 +105,11 @@ export default function CustomersPage() {
|
|
|
96
105
|
}}
|
|
97
106
|
actions={[
|
|
98
107
|
{ label: 'Refresh', icon: <RefreshCw size={16} />, onSelect: () => refetch() },
|
|
99
|
-
{
|
|
108
|
+
{
|
|
109
|
+
label: 'Export',
|
|
110
|
+
icon: <Download size={16} />,
|
|
111
|
+
onSelect: () => goTo('import-export'),
|
|
112
|
+
},
|
|
100
113
|
]}
|
|
101
114
|
/>
|
|
102
115
|
}
|
|
@@ -107,28 +120,32 @@ export default function CustomersPage() {
|
|
|
107
120
|
next shipment.
|
|
108
121
|
</PagePurpose>
|
|
109
122
|
|
|
110
|
-
{/* Customer base at a glance —
|
|
111
|
-
<
|
|
112
|
-
<p className="text-sm font-medium text-text-secondary">Customer base at a glance</p>
|
|
113
|
-
<
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
123
|
+
{/* Customer base at a glance — premium stat cards */}
|
|
124
|
+
<div>
|
|
125
|
+
<p className="mb-3 text-sm font-medium text-text-secondary">Customer base at a glance</p>
|
|
126
|
+
<MetricGrid columns={3}>
|
|
127
|
+
<MetricCard
|
|
128
|
+
label="Total customers"
|
|
129
|
+
value={stats.total}
|
|
130
|
+
icon={<Users size={20} />}
|
|
131
|
+
accent="brand"
|
|
132
|
+
/>
|
|
133
|
+
<MetricCard
|
|
134
|
+
label="Active"
|
|
135
|
+
value={stats.active}
|
|
136
|
+
icon={<UserCheck size={20} />}
|
|
137
|
+
accent="success"
|
|
138
|
+
subtitle="Currently transacting"
|
|
139
|
+
/>
|
|
140
|
+
<MetricCard
|
|
141
|
+
label="Business accounts"
|
|
142
|
+
value={stats.b2b}
|
|
143
|
+
icon={<Building2 size={20} />}
|
|
144
|
+
accent="blue"
|
|
145
|
+
subtitle="B2B relationships"
|
|
146
|
+
/>
|
|
147
|
+
</MetricGrid>
|
|
148
|
+
</div>
|
|
132
149
|
|
|
133
150
|
<DataTable
|
|
134
151
|
columns={columns}
|
|
@@ -139,6 +156,14 @@ export default function CustomersPage() {
|
|
|
139
156
|
searchFields={['name', 'email', 'company'] as (keyof Customer)[]}
|
|
140
157
|
onRowClick={(row) => goTo(`customers/${row.id}`)}
|
|
141
158
|
getRowId={(row) => row.id}
|
|
159
|
+
pageIndex={page}
|
|
160
|
+
pageSize={pageSize}
|
|
161
|
+
onPageChange={setPage}
|
|
162
|
+
onPageSizeChange={(size) => {
|
|
163
|
+
setPageSize(size);
|
|
164
|
+
setPage(0);
|
|
165
|
+
}}
|
|
166
|
+
hasNextPage={hasMore}
|
|
142
167
|
emptyIllustration="empty-people"
|
|
143
168
|
emptyTitle="No customers yet"
|
|
144
169
|
emptyDescription="Add your first customer to start booking deliveries for them."
|