@burdenoff/microfe-movethewheels 2026.822.1 → 2026.828.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generated/wspace-operations.d.ts +168 -912
- package/dist/generated/wspace-types.d.ts +78432 -35831
- package/dist/hooks/useFleet.js +142 -74
- package/dist/hooks/useImportExport.js +22 -51
- package/dist/hooks/useInventory.js +163 -70
- package/dist/hooks/useOrders.js +2 -2
- package/dist/hooks/useReports.js +63 -73
- package/dist/hooks/useSettings.js +52 -44
- package/dist/hooks/useTracking.js +5 -5
- package/dist/index.js +33 -33
- package/dist/movethewheels/hooks/index.d.ts +2 -2
- package/dist/movethewheels/hooks/useFleet.d.ts +32 -1
- package/dist/movethewheels/hooks/useImportExport.d.ts +10 -0
- package/dist/movethewheels/hooks/useInventory.d.ts +67 -12
- package/dist/movethewheels/hooks/useOrders.d.ts +25 -2
- package/dist/movethewheels/pages/MarketplacePage.d.ts +7 -4
- package/dist/movethewheels/types/index.d.ts +2 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/arrow-down-right.js +11 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/arrow-left-right.js +22 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/arrow-up-right.js +11 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/calendar-clock.js +32 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/history.js +18 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/lock.js +16 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/user-round.js +13 -0
- package/dist/pages/CreateOrderPage.js +246 -195
- package/dist/pages/DeliveryCapturePage.js +91 -78
- package/dist/pages/DriversPage.js +202 -108
- package/dist/pages/InventoryPage.js +356 -131
- package/dist/pages/MarketplacePage.js +11 -432
- package/dist/pages/ProductDetailsPage.js +212 -156
- package/dist/pages/ReportsPage.js +120 -109
- package/dist/pages/VehicleDetailsPage.js +257 -193
- package/dist/utils/formatters.js +4 -1
- package/package.json +2 -2
- package/src/generated/wspace-operations.ts +134 -929
- package/src/generated/wspace-types.ts +91730 -40059
- package/src/movethewheels/hooks/index.ts +9 -0
- package/src/movethewheels/hooks/useFleet.ts +133 -3
- package/src/movethewheels/hooks/useImportExport.ts +36 -72
- package/src/movethewheels/hooks/useInventory.ts +311 -53
- package/src/movethewheels/hooks/useOrders.ts +31 -7
- package/src/movethewheels/hooks/useReports.ts +73 -87
- package/src/movethewheels/hooks/useSettings.ts +50 -53
- package/src/movethewheels/hooks/useTracking.ts +14 -14
- package/src/movethewheels/pages/CreateOrderPage.tsx +67 -10
- package/src/movethewheels/pages/DeliveryCapturePage.tsx +8 -0
- package/src/movethewheels/pages/DriversPage.tsx +181 -31
- package/src/movethewheels/pages/InventoryPage.tsx +299 -19
- package/src/movethewheels/pages/MarketplacePage.tsx +16 -539
- package/src/movethewheels/pages/ProductDetailsPage.tsx +68 -3
- package/src/movethewheels/pages/ReportsPage.tsx +23 -0
- package/src/movethewheels/pages/VehicleDetailsPage.tsx +86 -27
- package/src/movethewheels/types/index.ts +2 -0
- package/src/operations/wspace/movethewheels/fragments/core.graphql +23 -53
- package/src/operations/wspace/movethewheels/fragments/core.withdrawn.graphql +59 -0
- package/src/operations/wspace/movethewheels/mutations/entities.graphql +5 -5
- package/src/operations/wspace/movethewheels/mutations/marketplace-reports.graphql +18 -78
- package/src/operations/wspace/movethewheels/mutations/marketplace-reports.withdrawn.graphql +93 -0
- package/src/operations/wspace/movethewheels/mutations/orders.graphql +7 -7
- package/src/operations/wspace/movethewheels/queries/dashboard.graphql +3 -3
- package/src/operations/wspace/movethewheels/queries/entities.graphql +3 -3
- package/src/operations/wspace/movethewheels/queries/logistics.graphql +3 -3
- package/src/operations/wspace/movethewheels/queries/marketplace-reports.withdrawn.graphql +58 -0
- package/src/operations/wspace/movethewheels/queries/orders.graphql +3 -3
- package/dist/generated/global-operations.d.ts +0 -1
- package/dist/generated/global-types.d.ts +0 -23526
- package/dist/node_modules/lucide-react/dist/esm/icons/chevron-down.js +0 -8
- package/dist/node_modules/lucide-react/dist/esm/icons/chevron-up.js +0 -8
- package/src/generated/global-operations.ts +0 -2
- package/src/generated/global-types.ts +0 -27259
- package/src/operations/wspace/movethewheels/queries/marketplace-reports.graphql +0 -40
|
@@ -2,32 +2,92 @@
|
|
|
2
2
|
* Drivers Page - List of all drivers
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { useState } from 'react';
|
|
6
|
-
import {
|
|
5
|
+
import { useMemo, useState } from 'react';
|
|
6
|
+
import {
|
|
7
|
+
Users,
|
|
8
|
+
Plus,
|
|
9
|
+
RefreshCw,
|
|
10
|
+
Download,
|
|
11
|
+
Star,
|
|
12
|
+
UserCheck,
|
|
13
|
+
Coffee,
|
|
14
|
+
UserMinus,
|
|
15
|
+
Filter,
|
|
16
|
+
Truck,
|
|
17
|
+
} from 'lucide-react';
|
|
7
18
|
import { PageLayout } from '../components/PageLayout';
|
|
8
19
|
import { MetricCard, MetricGrid } from '../components/MetricCard';
|
|
9
20
|
import { DataTable, type Column } from '../components/DataTable';
|
|
10
21
|
import { StatusBadge } from '../components/StatusBadge';
|
|
11
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
Avatar,
|
|
24
|
+
AvatarFallback,
|
|
25
|
+
Button,
|
|
26
|
+
Card,
|
|
27
|
+
CardContent,
|
|
28
|
+
Select,
|
|
29
|
+
SelectContent,
|
|
30
|
+
SelectItem,
|
|
31
|
+
SelectTrigger,
|
|
32
|
+
SelectValue,
|
|
33
|
+
} from '../components/ui';
|
|
12
34
|
import { CTAOverflowMenu, PagePurpose } from '@burdenoff/fe-libs/ui';
|
|
13
|
-
import { useDrivers, useFleetStats } from '../hooks/useFleet';
|
|
35
|
+
import { useDrivers, useDriverStatusCounts, useFleetStats, useVehicles } from '../hooks/useFleet';
|
|
14
36
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
15
37
|
import { formatDate } from '../utils/formatters';
|
|
16
38
|
import { joinPath } from '../utils/navigation';
|
|
17
|
-
import type { Driver } from '../types';
|
|
39
|
+
import type { Driver, DriverStatus } from '../types';
|
|
40
|
+
|
|
41
|
+
const STATUS_OPTIONS: Array<{ value: DriverStatus | 'all'; label: string }> = [
|
|
42
|
+
{ value: 'all', label: 'All statuses' },
|
|
43
|
+
{ value: 'available', label: 'Available' },
|
|
44
|
+
{ value: 'busy', label: 'On delivery' },
|
|
45
|
+
{ value: 'on_break', label: 'On break' },
|
|
46
|
+
{ value: 'offline', label: 'Off duty' },
|
|
47
|
+
];
|
|
18
48
|
|
|
19
49
|
export default function DriversPage() {
|
|
20
50
|
const { basePath, navigate } = useMoveTheWheels();
|
|
21
51
|
const [page, setPage] = useState(0);
|
|
22
52
|
const [pageSize, setPageSize] = useState(25);
|
|
53
|
+
const [statusFilter, setStatusFilter] = useState<DriverStatus | 'all'>('all');
|
|
54
|
+
const [licenseClassFilter, setLicenseClassFilter] = useState<string>('all');
|
|
23
55
|
const { drivers, isLoading, error, refetch } = useDrivers({
|
|
24
56
|
limit: pageSize,
|
|
25
57
|
offset: page * pageSize,
|
|
58
|
+
status: statusFilter === 'all' ? undefined : statusFilter,
|
|
26
59
|
});
|
|
27
60
|
// Workforce totals + tiles from the live aggregate, not the current page.
|
|
28
61
|
const { stats } = useFleetStats();
|
|
62
|
+
// Exact ON_BREAK / OFF_DUTY / INACTIVE counts — fleetStats cannot split these.
|
|
63
|
+
const { counts } = useDriverStatusCounts();
|
|
64
|
+
// Drivers carry only `vehicleId`; the roster shows the fleet number, so the
|
|
65
|
+
// vehicle list is joined client-side into an id -> number lookup.
|
|
66
|
+
const { vehicles } = useVehicles({ limit: 500 });
|
|
67
|
+
const vehicleNumberById = useMemo(
|
|
68
|
+
() => new Map(vehicles.map((vehicle) => [vehicle.id, vehicle.vehicleNumber])),
|
|
69
|
+
[vehicles]
|
|
70
|
+
);
|
|
29
71
|
const totalDrivers = stats.totalDrivers;
|
|
30
72
|
|
|
73
|
+
// `DriverFilterInput` has no licenseClass, so this narrowing is applied to the
|
|
74
|
+
// fetched page only — never presented as a workspace-wide count.
|
|
75
|
+
const licenseClasses = useMemo(
|
|
76
|
+
() =>
|
|
77
|
+
Array.from(new Set(drivers.map((driver) => driver.licenseType).filter(Boolean))).sort(
|
|
78
|
+
(a, b) => a.localeCompare(b)
|
|
79
|
+
),
|
|
80
|
+
[drivers]
|
|
81
|
+
);
|
|
82
|
+
const visibleDrivers = useMemo(
|
|
83
|
+
() =>
|
|
84
|
+
licenseClassFilter === 'all'
|
|
85
|
+
? drivers
|
|
86
|
+
: drivers.filter((driver) => driver.licenseType === licenseClassFilter),
|
|
87
|
+
[drivers, licenseClassFilter]
|
|
88
|
+
);
|
|
89
|
+
const filtersActive = statusFilter !== 'all' || licenseClassFilter !== 'all';
|
|
90
|
+
|
|
31
91
|
const goTo = (path: string) => navigate?.(joinPath(basePath, path));
|
|
32
92
|
|
|
33
93
|
const columns: Column<Driver>[] = [
|
|
@@ -58,24 +118,6 @@ export default function DriversPage() {
|
|
|
58
118
|
sortable: true,
|
|
59
119
|
cell: (row) => <StatusBadge status={row.status} type="driver" />,
|
|
60
120
|
},
|
|
61
|
-
{
|
|
62
|
-
id: 'rating',
|
|
63
|
-
header: 'Rating',
|
|
64
|
-
cell: (row) => (
|
|
65
|
-
<div className="flex items-center gap-1">
|
|
66
|
-
<Star size={14} className="text-status-warning-text fill-current" />
|
|
67
|
-
<span className="font-medium">{row.ratings.overall.toFixed(1)}</span>
|
|
68
|
-
<span className="text-text-secondary text-sm">({row.ratings.totalRatings})</span>
|
|
69
|
-
</div>
|
|
70
|
-
),
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
id: 'deliveries',
|
|
74
|
-
header: 'Deliveries',
|
|
75
|
-
accessorKey: 'completedOrders',
|
|
76
|
-
sortable: true,
|
|
77
|
-
cell: (row) => <span className="font-medium">{row.completedOrders.toLocaleString()}</span>,
|
|
78
|
-
},
|
|
79
121
|
{
|
|
80
122
|
id: 'license',
|
|
81
123
|
header: 'License',
|
|
@@ -86,6 +128,52 @@ export default function DriversPage() {
|
|
|
86
128
|
</div>
|
|
87
129
|
),
|
|
88
130
|
},
|
|
131
|
+
{
|
|
132
|
+
id: 'phone',
|
|
133
|
+
header: 'Phone',
|
|
134
|
+
accessorKey: 'phone',
|
|
135
|
+
cell: (row) =>
|
|
136
|
+
row.phone ? (
|
|
137
|
+
<a
|
|
138
|
+
href={`tel:${row.phone}`}
|
|
139
|
+
className="text-sm hover:underline"
|
|
140
|
+
onClick={(e) => e.stopPropagation()}
|
|
141
|
+
>
|
|
142
|
+
{row.phone}
|
|
143
|
+
</a>
|
|
144
|
+
) : (
|
|
145
|
+
<span className="text-sm text-text-secondary">—</span>
|
|
146
|
+
),
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: 'rating',
|
|
150
|
+
header: 'Rating',
|
|
151
|
+
cell: (row) =>
|
|
152
|
+
row.ratings.overall > 0 ? (
|
|
153
|
+
<div className="flex items-center gap-1">
|
|
154
|
+
<Star size={14} className="text-status-warning-text fill-current" />
|
|
155
|
+
<span className="font-medium">{row.ratings.overall.toFixed(1)}</span>
|
|
156
|
+
</div>
|
|
157
|
+
) : (
|
|
158
|
+
<span className="text-sm text-text-secondary">Not rated</span>
|
|
159
|
+
),
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: 'vehicle',
|
|
163
|
+
header: 'Assigned vehicle',
|
|
164
|
+
cell: (row) => {
|
|
165
|
+
if (!row.assignedVehicle) {
|
|
166
|
+
return <span className="text-sm text-text-secondary">Unassigned</span>;
|
|
167
|
+
}
|
|
168
|
+
const vehicleNumber = vehicleNumberById.get(row.assignedVehicle);
|
|
169
|
+
return (
|
|
170
|
+
<span className="inline-flex items-center gap-1.5 text-sm font-medium">
|
|
171
|
+
<Truck size={14} className="text-text-secondary" />
|
|
172
|
+
{vehicleNumber ?? 'Assigned'}
|
|
173
|
+
</span>
|
|
174
|
+
);
|
|
175
|
+
},
|
|
176
|
+
},
|
|
89
177
|
{
|
|
90
178
|
id: 'joined',
|
|
91
179
|
header: 'Join Date',
|
|
@@ -119,8 +207,8 @@ export default function DriversPage() {
|
|
|
119
207
|
}
|
|
120
208
|
>
|
|
121
209
|
<PagePurpose icon={<Users size={18} />}>
|
|
122
|
-
Your delivery workforce. Track each driver's availability,
|
|
123
|
-
|
|
210
|
+
Your delivery workforce. Track each driver's availability, rating, licence status,
|
|
211
|
+
contact number and the vehicle they are on — open a driver to review their record before
|
|
124
212
|
assigning them to an order or route.
|
|
125
213
|
</PagePurpose>
|
|
126
214
|
|
|
@@ -137,15 +225,17 @@ export default function DriversPage() {
|
|
|
137
225
|
/>
|
|
138
226
|
<MetricCard
|
|
139
227
|
label="On break"
|
|
140
|
-
value={
|
|
228
|
+
value={counts.onBreak}
|
|
141
229
|
icon={<Coffee size={20} />}
|
|
142
230
|
accent="warning"
|
|
231
|
+
subtitle="Back on shift shortly"
|
|
143
232
|
/>
|
|
144
233
|
<MetricCard
|
|
145
234
|
label="Off duty"
|
|
146
|
-
value={
|
|
235
|
+
value={counts.offDuty}
|
|
147
236
|
icon={<UserMinus size={20} />}
|
|
148
237
|
accent="neutral"
|
|
238
|
+
subtitle={counts.inactive > 0 ? `${counts.inactive} inactive` : undefined}
|
|
149
239
|
/>
|
|
150
240
|
<MetricCard
|
|
151
241
|
label="Avg rating"
|
|
@@ -157,9 +247,65 @@ export default function DriversPage() {
|
|
|
157
247
|
</MetricGrid>
|
|
158
248
|
</div>
|
|
159
249
|
|
|
250
|
+
{/* Filters */}
|
|
251
|
+
<Card>
|
|
252
|
+
<CardContent className="p-4">
|
|
253
|
+
<div className="flex flex-wrap items-center gap-4">
|
|
254
|
+
<div className="flex items-center gap-2">
|
|
255
|
+
<Filter size={16} className="text-text-secondary" />
|
|
256
|
+
<span className="text-sm font-medium">Filters:</span>
|
|
257
|
+
</div>
|
|
258
|
+
<Select
|
|
259
|
+
value={statusFilter}
|
|
260
|
+
onValueChange={(value) => {
|
|
261
|
+
setStatusFilter(value as DriverStatus | 'all');
|
|
262
|
+
setPage(0);
|
|
263
|
+
}}
|
|
264
|
+
>
|
|
265
|
+
<SelectTrigger className="w-[180px]">
|
|
266
|
+
<SelectValue placeholder="Status" />
|
|
267
|
+
</SelectTrigger>
|
|
268
|
+
<SelectContent>
|
|
269
|
+
{STATUS_OPTIONS.map((option) => (
|
|
270
|
+
<SelectItem key={option.value} value={option.value}>
|
|
271
|
+
{option.label}
|
|
272
|
+
</SelectItem>
|
|
273
|
+
))}
|
|
274
|
+
</SelectContent>
|
|
275
|
+
</Select>
|
|
276
|
+
<Select value={licenseClassFilter} onValueChange={setLicenseClassFilter}>
|
|
277
|
+
<SelectTrigger className="w-[180px]">
|
|
278
|
+
<SelectValue placeholder="License class" />
|
|
279
|
+
</SelectTrigger>
|
|
280
|
+
<SelectContent>
|
|
281
|
+
<SelectItem value="all">All licence classes</SelectItem>
|
|
282
|
+
{licenseClasses.map((licenseClass) => (
|
|
283
|
+
<SelectItem key={licenseClass} value={licenseClass}>
|
|
284
|
+
{licenseClass}
|
|
285
|
+
</SelectItem>
|
|
286
|
+
))}
|
|
287
|
+
</SelectContent>
|
|
288
|
+
</Select>
|
|
289
|
+
{filtersActive && (
|
|
290
|
+
<Button
|
|
291
|
+
variant="ghost"
|
|
292
|
+
size="sm"
|
|
293
|
+
onClick={() => {
|
|
294
|
+
setStatusFilter('all');
|
|
295
|
+
setLicenseClassFilter('all');
|
|
296
|
+
setPage(0);
|
|
297
|
+
}}
|
|
298
|
+
>
|
|
299
|
+
Clear filters
|
|
300
|
+
</Button>
|
|
301
|
+
)}
|
|
302
|
+
</div>
|
|
303
|
+
</CardContent>
|
|
304
|
+
</Card>
|
|
305
|
+
|
|
160
306
|
<DataTable
|
|
161
307
|
columns={columns}
|
|
162
|
-
data={
|
|
308
|
+
data={visibleDrivers}
|
|
163
309
|
isLoading={isLoading}
|
|
164
310
|
error={error}
|
|
165
311
|
onRetry={refetch}
|
|
@@ -175,10 +321,14 @@ export default function DriversPage() {
|
|
|
175
321
|
setPageSize(size);
|
|
176
322
|
setPage(0);
|
|
177
323
|
}}
|
|
178
|
-
totalItems={totalDrivers}
|
|
324
|
+
totalItems={filtersActive ? undefined : totalDrivers}
|
|
179
325
|
emptyIllustration="empty-people"
|
|
180
|
-
emptyTitle=
|
|
181
|
-
emptyDescription=
|
|
326
|
+
emptyTitle={filtersActive ? 'No drivers match these filters' : 'No drivers yet'}
|
|
327
|
+
emptyDescription={
|
|
328
|
+
filtersActive
|
|
329
|
+
? 'Clear the status or licence-class filter to see the rest of the roster.'
|
|
330
|
+
: 'Add drivers to start assigning deliveries and routes.'
|
|
331
|
+
}
|
|
182
332
|
/>
|
|
183
333
|
</PageLayout>
|
|
184
334
|
);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Inventory Page - Overview of inventory management
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { useMemo } from 'react';
|
|
5
6
|
import {
|
|
6
7
|
Package,
|
|
7
8
|
Warehouse,
|
|
@@ -9,18 +10,79 @@ import {
|
|
|
9
10
|
ChevronRight,
|
|
10
11
|
Plus,
|
|
11
12
|
Boxes,
|
|
12
|
-
|
|
13
|
+
CircleCheck,
|
|
14
|
+
Lock,
|
|
15
|
+
History,
|
|
16
|
+
ArrowDownRight,
|
|
17
|
+
ArrowUpRight,
|
|
18
|
+
ArrowLeftRight,
|
|
13
19
|
} from 'lucide-react';
|
|
14
20
|
import { PageLayout, PageSection } from '../components/PageLayout';
|
|
15
21
|
import { MetricCard, MetricGrid, MetricCardSkeleton } from '../components/MetricCard';
|
|
16
|
-
import {
|
|
22
|
+
import { DataTable, type Column } from '../components/DataTable';
|
|
23
|
+
import { StatusBadge } from '../components/StatusBadge';
|
|
24
|
+
import {
|
|
25
|
+
Card,
|
|
26
|
+
CardContent,
|
|
27
|
+
CardHeader,
|
|
28
|
+
CardTitle,
|
|
29
|
+
Button,
|
|
30
|
+
Badge,
|
|
31
|
+
Progress,
|
|
32
|
+
} from '../components/ui';
|
|
17
33
|
import { CTAOverflowMenu, EmphasisPanel, PagePurpose } from '@burdenoff/fe-libs/ui';
|
|
18
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
useInventory,
|
|
36
|
+
useInventoryStats,
|
|
37
|
+
useInventoryTransactions,
|
|
38
|
+
type InventoryTransactionType,
|
|
39
|
+
} from '../hooks/useInventory';
|
|
19
40
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
20
41
|
import { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';
|
|
21
|
-
import { formatCurrency } from '../utils/formatters';
|
|
42
|
+
import { formatCurrency, formatDate } from '../utils/formatters';
|
|
22
43
|
import { joinPath } from '../utils/navigation';
|
|
23
44
|
|
|
45
|
+
/** One stock line joined to its catalog item and warehouse, for the position table. */
|
|
46
|
+
interface StockRow {
|
|
47
|
+
id: string;
|
|
48
|
+
productId: string;
|
|
49
|
+
productName: string;
|
|
50
|
+
sku: string;
|
|
51
|
+
warehouseName: string;
|
|
52
|
+
onHand: number;
|
|
53
|
+
allocated: number;
|
|
54
|
+
available: number;
|
|
55
|
+
reorderLevel: number;
|
|
56
|
+
stockState: 'out_of_stock' | 'low' | 'in_stock';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const STOCK_STATE_COLORS = {
|
|
60
|
+
out_of_stock: { bg: 'bg-status-error-bg-subtle', text: 'text-status-error-text' },
|
|
61
|
+
low: { bg: 'bg-status-warning-bg-subtle', text: 'text-status-warning-text' },
|
|
62
|
+
in_stock: { bg: 'bg-status-success-bg-subtle', text: 'text-status-success-text' },
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Movement direction per `InventoryTransactionType`. RECEIPT/RETURN add stock,
|
|
67
|
+
* ISSUE removes it, TRANSFER and ADJUSTMENT are direction-neutral in the ledger
|
|
68
|
+
* (the signed quantity on the row carries the direction).
|
|
69
|
+
*/
|
|
70
|
+
const TRANSACTION_DIRECTION: Record<InventoryTransactionType, 'in' | 'out' | 'neutral'> = {
|
|
71
|
+
RECEIPT: 'in',
|
|
72
|
+
RETURN: 'in',
|
|
73
|
+
ISSUE: 'out',
|
|
74
|
+
TRANSFER: 'neutral',
|
|
75
|
+
ADJUSTMENT: 'neutral',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const TRANSACTION_LABELS: Record<InventoryTransactionType, string> = {
|
|
79
|
+
RECEIPT: 'Inbound',
|
|
80
|
+
RETURN: 'Return',
|
|
81
|
+
ISSUE: 'Outbound',
|
|
82
|
+
TRANSFER: 'Transfer',
|
|
83
|
+
ADJUSTMENT: 'Adjustment',
|
|
84
|
+
};
|
|
85
|
+
|
|
24
86
|
export default function InventoryPage() {
|
|
25
87
|
const { t: translate } = useI18n();
|
|
26
88
|
const t = (key: string, fallback: string): string => {
|
|
@@ -30,6 +92,11 @@ export default function InventoryPage() {
|
|
|
30
92
|
const { basePath, navigate } = useMoveTheWheels();
|
|
31
93
|
const { inventory, products, warehouses, isLoading, error, refetch } = useInventory();
|
|
32
94
|
const { stats } = useInventoryStats();
|
|
95
|
+
const {
|
|
96
|
+
transactions,
|
|
97
|
+
isLoading: transactionsLoading,
|
|
98
|
+
error: transactionsError,
|
|
99
|
+
} = useInventoryTransactions({ limit: 8 });
|
|
33
100
|
|
|
34
101
|
const goTo = (path: string) => navigate?.(joinPath(basePath, path));
|
|
35
102
|
const onRowKeyDown = (e: React.KeyboardEvent, action: () => void) => {
|
|
@@ -40,6 +107,104 @@ export default function InventoryPage() {
|
|
|
40
107
|
};
|
|
41
108
|
const lowStockItems = inventory.filter((inv) => inv.availableQuantity <= inv.reorderLevel);
|
|
42
109
|
|
|
110
|
+
const unknownProductLabel = t(
|
|
111
|
+
'movethewheels.inventory.lowStock.unknownProduct',
|
|
112
|
+
'Unknown Product'
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const stockRows = useMemo<StockRow[]>(() => {
|
|
116
|
+
const productById = new Map(products.map((product) => [product.id, product]));
|
|
117
|
+
const warehouseById = new Map(warehouses.map((warehouse) => [warehouse.id, warehouse]));
|
|
118
|
+
return inventory.map((item) => {
|
|
119
|
+
const product = productById.get(item.productId);
|
|
120
|
+
return {
|
|
121
|
+
id: item.id,
|
|
122
|
+
productId: item.productId,
|
|
123
|
+
productName: product?.name ?? unknownProductLabel,
|
|
124
|
+
sku: product?.sku ?? '—',
|
|
125
|
+
warehouseName: warehouseById.get(item.warehouseId)?.name ?? '—',
|
|
126
|
+
onHand: item.quantity,
|
|
127
|
+
allocated: item.reservedQuantity,
|
|
128
|
+
available: item.availableQuantity,
|
|
129
|
+
reorderLevel: item.reorderLevel,
|
|
130
|
+
stockState:
|
|
131
|
+
item.quantity === 0
|
|
132
|
+
? 'out_of_stock'
|
|
133
|
+
: item.availableQuantity <= item.reorderLevel
|
|
134
|
+
? 'low'
|
|
135
|
+
: 'in_stock',
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
}, [inventory, products, warehouses, unknownProductLabel]);
|
|
139
|
+
|
|
140
|
+
const stockColumns: Column<StockRow>[] = [
|
|
141
|
+
{
|
|
142
|
+
id: 'product',
|
|
143
|
+
header: t('movethewheels.inventory.stock.product', 'Product'),
|
|
144
|
+
accessorKey: 'productName',
|
|
145
|
+
sortable: true,
|
|
146
|
+
cell: (row) => <span className="font-medium">{row.productName}</span>,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: 'sku',
|
|
150
|
+
header: t('movethewheels.inventory.stock.sku', 'SKU'),
|
|
151
|
+
accessorKey: 'sku',
|
|
152
|
+
sortable: true,
|
|
153
|
+
cell: (row) => <span className="text-sm text-text-secondary">{row.sku}</span>,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: 'warehouse',
|
|
157
|
+
header: t('movethewheels.inventory.stock.warehouse', 'Warehouse'),
|
|
158
|
+
accessorKey: 'warehouseName',
|
|
159
|
+
sortable: true,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: 'onHand',
|
|
163
|
+
header: t('movethewheels.inventory.stock.onHand', 'On hand'),
|
|
164
|
+
accessorKey: 'onHand',
|
|
165
|
+
sortable: true,
|
|
166
|
+
cell: (row) => <span className="font-medium">{row.onHand.toLocaleString()}</span>,
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: 'allocated',
|
|
170
|
+
header: t('movethewheels.inventory.stock.allocated', 'Allocated'),
|
|
171
|
+
accessorKey: 'allocated',
|
|
172
|
+
sortable: true,
|
|
173
|
+
cell: (row) => row.allocated.toLocaleString(),
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: 'available',
|
|
177
|
+
header: t('movethewheels.inventory.stock.available', 'Available'),
|
|
178
|
+
accessorKey: 'available',
|
|
179
|
+
sortable: true,
|
|
180
|
+
cell: (row) => <span className="font-medium">{row.available.toLocaleString()}</span>,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: 'reorderPoint',
|
|
184
|
+
header: t('movethewheels.inventory.stock.reorderPoint', 'Reorder point'),
|
|
185
|
+
accessorKey: 'reorderLevel',
|
|
186
|
+
sortable: true,
|
|
187
|
+
cell: (row) => (row.reorderLevel > 0 ? row.reorderLevel.toLocaleString() : '—'),
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: 'state',
|
|
191
|
+
header: t('movethewheels.inventory.stock.status', 'Status'),
|
|
192
|
+
cell: (row) => (
|
|
193
|
+
<StatusBadge
|
|
194
|
+
status={
|
|
195
|
+
row.stockState === 'out_of_stock'
|
|
196
|
+
? t('movethewheels.inventory.stock.outOfStock', 'Out of stock')
|
|
197
|
+
: row.stockState === 'low'
|
|
198
|
+
? t('movethewheels.inventory.stock.low', 'Low')
|
|
199
|
+
: t('movethewheels.inventory.stock.inStock', 'In stock')
|
|
200
|
+
}
|
|
201
|
+
customColors={STOCK_STATE_COLORS[row.stockState]}
|
|
202
|
+
size="sm"
|
|
203
|
+
/>
|
|
204
|
+
),
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
|
|
43
208
|
return (
|
|
44
209
|
<PageLayout
|
|
45
210
|
title={t('movethewheels.inventory.title', 'Inventory')}
|
|
@@ -68,7 +233,7 @@ export default function InventoryPage() {
|
|
|
68
233
|
<PagePurpose icon={<Package size={18} />}>
|
|
69
234
|
{t(
|
|
70
235
|
'movethewheels.inventory.purpose',
|
|
71
|
-
'A real-time view of what you have and where. Track
|
|
236
|
+
'A real-time view of what you have and where. Track on-hand, allocated and free-to-promise quantities across warehouses, follow every stock movement, catch low-stock items before they run out, and drill into products or warehouses to manage what you can promise on an order.'
|
|
72
237
|
)}
|
|
73
238
|
</PagePurpose>
|
|
74
239
|
|
|
@@ -85,35 +250,54 @@ export default function InventoryPage() {
|
|
|
85
250
|
</CardContent>
|
|
86
251
|
</Card>
|
|
87
252
|
) : isLoading ? (
|
|
88
|
-
<MetricGrid columns={
|
|
89
|
-
{Array.from({ length:
|
|
253
|
+
<MetricGrid columns={6}>
|
|
254
|
+
{Array.from({ length: 6 }).map((_, i) => (
|
|
90
255
|
<MetricCardSkeleton key={i} />
|
|
91
256
|
))}
|
|
92
257
|
</MetricGrid>
|
|
93
258
|
) : (
|
|
94
|
-
<MetricGrid columns={
|
|
259
|
+
<MetricGrid columns={6}>
|
|
95
260
|
<MetricCard
|
|
96
|
-
label={t('movethewheels.inventory.metrics.
|
|
97
|
-
value={stats.
|
|
98
|
-
icon={<
|
|
261
|
+
label={t('movethewheels.inventory.metrics.totalOnHand', 'Total on-hand')}
|
|
262
|
+
value={stats.totalOnHand.toLocaleString()}
|
|
263
|
+
icon={<Boxes size={20} />}
|
|
99
264
|
accent="brand"
|
|
100
|
-
|
|
265
|
+
subtitle={t('movethewheels.inventory.metrics.onHandHint', 'Units in the network')}
|
|
101
266
|
/>
|
|
102
267
|
<MetricCard
|
|
103
|
-
label={t('movethewheels.inventory.metrics.
|
|
104
|
-
value={stats.
|
|
105
|
-
icon={<
|
|
268
|
+
label={t('movethewheels.inventory.metrics.available', 'Available')}
|
|
269
|
+
value={stats.totalAvailable.toLocaleString()}
|
|
270
|
+
icon={<CircleCheck size={20} />}
|
|
106
271
|
accent="success"
|
|
272
|
+
subtitle={t('movethewheels.inventory.metrics.availableHint', 'Free to promise')}
|
|
107
273
|
/>
|
|
108
274
|
<MetricCard
|
|
109
|
-
label={t('movethewheels.inventory.metrics.
|
|
110
|
-
value={
|
|
111
|
-
icon={<
|
|
275
|
+
label={t('movethewheels.inventory.metrics.allocated', 'Allocated')}
|
|
276
|
+
value={stats.totalAllocated.toLocaleString()}
|
|
277
|
+
icon={<Lock size={20} />}
|
|
112
278
|
accent="blue"
|
|
279
|
+
subtitle={t('movethewheels.inventory.metrics.allocatedHint', 'Committed to orders')}
|
|
280
|
+
/>
|
|
281
|
+
<MetricCard
|
|
282
|
+
label={t('movethewheels.inventory.metrics.lowStock', 'Low-stock items')}
|
|
283
|
+
value={stats.lowStockCount.toLocaleString()}
|
|
284
|
+
icon={<AlertTriangle size={20} />}
|
|
285
|
+
accent="warning"
|
|
286
|
+
subtitle={t(
|
|
287
|
+
'movethewheels.inventory.metrics.lowStockHint',
|
|
288
|
+
'At or under reorder point'
|
|
289
|
+
)}
|
|
290
|
+
/>
|
|
291
|
+
<MetricCard
|
|
292
|
+
label={t('movethewheels.inventory.metrics.totalProducts', 'Catalog items')}
|
|
293
|
+
value={stats.totalProducts.toLocaleString()}
|
|
294
|
+
icon={<Package size={20} />}
|
|
295
|
+
accent="neutral"
|
|
296
|
+
onClick={() => goTo('inventory/products')}
|
|
113
297
|
/>
|
|
114
298
|
<MetricCard
|
|
115
299
|
label={t('movethewheels.inventory.metrics.warehouses', 'Warehouses')}
|
|
116
|
-
value={stats.totalWarehouses}
|
|
300
|
+
value={stats.totalWarehouses.toLocaleString()}
|
|
117
301
|
icon={<Warehouse size={20} />}
|
|
118
302
|
accent="neutral"
|
|
119
303
|
onClick={() => goTo('inventory/warehouses')}
|
|
@@ -164,6 +348,102 @@ export default function InventoryPage() {
|
|
|
164
348
|
</EmphasisPanel>
|
|
165
349
|
)}
|
|
166
350
|
|
|
351
|
+
{/* Stock position + ledger. The table is the per-warehouse stock line;
|
|
352
|
+
the side panel is the real movement history from Query.inventoryTransactions. */}
|
|
353
|
+
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
|
354
|
+
<div className="xl:col-span-2">
|
|
355
|
+
<PageSection title={t('movethewheels.inventory.stock.title', 'Stock position')}>
|
|
356
|
+
<DataTable
|
|
357
|
+
columns={stockColumns}
|
|
358
|
+
data={stockRows}
|
|
359
|
+
isLoading={isLoading}
|
|
360
|
+
error={error}
|
|
361
|
+
onRetry={refetch}
|
|
362
|
+
searchable
|
|
363
|
+
searchPlaceholder={t(
|
|
364
|
+
'movethewheels.inventory.stock.search',
|
|
365
|
+
'Search product, SKU or warehouse...'
|
|
366
|
+
)}
|
|
367
|
+
searchFields={['productName', 'sku', 'warehouseName'] as (keyof StockRow)[]}
|
|
368
|
+
onRowClick={(row) => goTo(`inventory/products/${row.productId}`)}
|
|
369
|
+
getRowId={(row) => row.id}
|
|
370
|
+
emptyIllustration="empty-data"
|
|
371
|
+
emptyTitle={t('movethewheels.inventory.stock.emptyTitle', 'No stock recorded yet')}
|
|
372
|
+
emptyDescription={t(
|
|
373
|
+
'movethewheels.inventory.stock.emptyDescription',
|
|
374
|
+
'Stock lines appear here once a product is received into a warehouse.'
|
|
375
|
+
)}
|
|
376
|
+
/>
|
|
377
|
+
</PageSection>
|
|
378
|
+
</div>
|
|
379
|
+
|
|
380
|
+
<Card className="h-fit">
|
|
381
|
+
<CardHeader>
|
|
382
|
+
<CardTitle className="flex items-center gap-2">
|
|
383
|
+
<History size={18} />
|
|
384
|
+
{t('movethewheels.inventory.transactions.title', 'Recent transactions')}
|
|
385
|
+
</CardTitle>
|
|
386
|
+
</CardHeader>
|
|
387
|
+
<CardContent className="p-0">
|
|
388
|
+
{transactionsError ? (
|
|
389
|
+
<p className="p-6 text-sm text-status-error-text">{transactionsError.message}</p>
|
|
390
|
+
) : transactionsLoading ? (
|
|
391
|
+
<p className="p-6 text-sm text-text-secondary">
|
|
392
|
+
{t('movethewheels.inventory.transactions.loading', 'Loading movements…')}
|
|
393
|
+
</p>
|
|
394
|
+
) : transactions.length === 0 ? (
|
|
395
|
+
<p className="p-6 text-sm text-text-secondary">
|
|
396
|
+
{t(
|
|
397
|
+
'movethewheels.inventory.transactions.empty',
|
|
398
|
+
'No stock movements recorded yet. Receipts, issues and adjustments show up here as they happen.'
|
|
399
|
+
)}
|
|
400
|
+
</p>
|
|
401
|
+
) : (
|
|
402
|
+
<ul className="divide-y">
|
|
403
|
+
{transactions.map((transaction) => {
|
|
404
|
+
const direction = TRANSACTION_DIRECTION[transaction.type];
|
|
405
|
+
const productName =
|
|
406
|
+
products.find((product) => product.id === transaction.productId)?.name ??
|
|
407
|
+
transaction.transactionNumber;
|
|
408
|
+
return (
|
|
409
|
+
<li key={transaction.id} className="flex items-start justify-between gap-3 p-4">
|
|
410
|
+
<div className="min-w-0">
|
|
411
|
+
<p className="flex items-center gap-1.5 font-medium">
|
|
412
|
+
{direction === 'in' ? (
|
|
413
|
+
<ArrowDownRight size={14} className="text-status-success-text" />
|
|
414
|
+
) : direction === 'out' ? (
|
|
415
|
+
<ArrowUpRight size={14} className="text-status-warning-text" />
|
|
416
|
+
) : (
|
|
417
|
+
<ArrowLeftRight size={14} className="text-text-secondary" />
|
|
418
|
+
)}
|
|
419
|
+
{TRANSACTION_LABELS[transaction.type]}
|
|
420
|
+
</p>
|
|
421
|
+
<p className="truncate text-sm text-text-secondary">{productName}</p>
|
|
422
|
+
<p className="text-xs text-text-secondary">
|
|
423
|
+
{formatDate(transaction.createdAt, 'short')}
|
|
424
|
+
</p>
|
|
425
|
+
</div>
|
|
426
|
+
<span
|
|
427
|
+
className={
|
|
428
|
+
direction === 'in'
|
|
429
|
+
? 'font-medium text-status-success-text'
|
|
430
|
+
: direction === 'out'
|
|
431
|
+
? 'font-medium text-status-warning-text'
|
|
432
|
+
: 'font-medium text-text-primary'
|
|
433
|
+
}
|
|
434
|
+
>
|
|
435
|
+
{direction === 'in' ? '+' : direction === 'out' ? '−' : ''}
|
|
436
|
+
{Math.abs(transaction.quantity).toLocaleString()}
|
|
437
|
+
</span>
|
|
438
|
+
</li>
|
|
439
|
+
);
|
|
440
|
+
})}
|
|
441
|
+
</ul>
|
|
442
|
+
)}
|
|
443
|
+
</CardContent>
|
|
444
|
+
</Card>
|
|
445
|
+
</div>
|
|
446
|
+
|
|
167
447
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
168
448
|
<PageSection
|
|
169
449
|
title={t('movethewheels.inventory.warehouses.title', 'Warehouses')}
|