@burdenoff/microfe-movethewheels 2026.930.2 → 2026.930.4
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/components/StockActionDialogs.js +385 -0
- package/dist/hooks/useFleet.js +112 -109
- package/dist/hooks/useInventory.js +248 -124
- package/dist/index.js +34 -34
- package/dist/movethewheels/components/StockActionDialogs.d.ts +24 -0
- package/dist/movethewheels/hooks/index.d.ts +1 -1
- package/dist/movethewheels/hooks/useFleet.d.ts +1 -0
- package/dist/movethewheels/hooks/useInventory.d.ts +30 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/package-plus.js +33 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/sliders-horizontal.js +69 -0
- package/dist/pages/DriverDetailsPage.js +401 -347
- package/dist/pages/InventoryPage.js +279 -200
- package/dist/pages/RouteDetailsPage.js +4 -4
- package/package.json +1 -1
- package/src/movethewheels/components/StockActionDialogs.tsx +512 -0
- package/src/movethewheels/hooks/index.ts +3 -0
- package/src/movethewheels/hooks/useFleet.ts +18 -0
- package/src/movethewheels/hooks/useInventory.ts +294 -35
- package/src/movethewheels/pages/DriverDetailsPage.tsx +72 -1
- package/src/movethewheels/pages/InventoryPage.tsx +98 -1
- package/src/movethewheels/pages/RouteDetailsPage.tsx +5 -4
|
@@ -180,7 +180,7 @@ function G({ editId: r }) {
|
|
|
180
180
|
}),
|
|
181
181
|
children: [/* @__PURE__ */ B(ye, {
|
|
182
182
|
icon: /* @__PURE__ */ B(o, { size: 18 }),
|
|
183
|
-
children: "Plan a new delivery route — name it, list the stops to visit, and optionally assign a vehicle and driver. Once created you can optimize it to sequence the stops into the shortest run."
|
|
183
|
+
children: "Plan a new delivery route — name it, list the stops to visit, and optionally assign a vehicle and driver. Once created you can optimize it to sequence the stops into the shortest run. Optimizing also marks the route In Progress."
|
|
184
184
|
}), /* @__PURE__ */ V("form", {
|
|
185
185
|
onSubmit: $,
|
|
186
186
|
className: "max-w-3xl space-y-6",
|
|
@@ -459,7 +459,7 @@ function K() {
|
|
|
459
459
|
children: [
|
|
460
460
|
/* @__PURE__ */ B(d, { size: 16 }),
|
|
461
461
|
" ",
|
|
462
|
-
W ? "Optimizing…" : "Optimize"
|
|
462
|
+
W ? "Optimizing…" : "Optimize & start"
|
|
463
463
|
]
|
|
464
464
|
}),
|
|
465
465
|
/* @__PURE__ */ V(_, {
|
|
@@ -488,8 +488,8 @@ function K() {
|
|
|
488
488
|
steps: [
|
|
489
489
|
...m.optimized ? [] : [{
|
|
490
490
|
id: "optimize",
|
|
491
|
-
label: "Optimize this route",
|
|
492
|
-
description: "Reorder stops to cut distance and driving time.",
|
|
491
|
+
label: "Optimize & start this route",
|
|
492
|
+
description: "Reorder stops to cut distance and driving time, then mark the route In Progress.",
|
|
493
493
|
icon: /* @__PURE__ */ B(d, { size: 16 }),
|
|
494
494
|
onClick: Ee
|
|
495
495
|
}],
|
package/package.json
CHANGED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stock movement dialogs — receive, adjust and transfer inventory.
|
|
3
|
+
*
|
|
4
|
+
* Every product/warehouse choice is a searchable, server-paginated
|
|
5
|
+
* `EntityCombobox`, so an operator never has to leave the page to look a record
|
|
6
|
+
* up. Writes go through `useStockActions`, which re-reads the stock line first
|
|
7
|
+
* and lets the service record the matching ledger entries atomically.
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useState } from 'react';
|
|
10
|
+
import {
|
|
11
|
+
ResponsiveDialog,
|
|
12
|
+
ResponsiveDialogContent,
|
|
13
|
+
ResponsiveDialogDescription,
|
|
14
|
+
ResponsiveDialogFooter,
|
|
15
|
+
ResponsiveDialogHeader,
|
|
16
|
+
ResponsiveDialogTitle,
|
|
17
|
+
} from '@burdenoff/fe-libs/ui';
|
|
18
|
+
import { Button, Input, Label } from './ui';
|
|
19
|
+
import { EntityCombobox } from './EntityCombobox';
|
|
20
|
+
import { FormError } from './FormError';
|
|
21
|
+
import {
|
|
22
|
+
useProduct,
|
|
23
|
+
useProducts,
|
|
24
|
+
useStockActions,
|
|
25
|
+
useWarehouse,
|
|
26
|
+
useWarehouses,
|
|
27
|
+
} from '../hooks/useInventory';
|
|
28
|
+
|
|
29
|
+
/** The stock line an adjust/transfer acts on (a row of the stock position table). */
|
|
30
|
+
export interface StockLineTarget {
|
|
31
|
+
id: string;
|
|
32
|
+
productName: string;
|
|
33
|
+
sku: string;
|
|
34
|
+
warehouseId: string;
|
|
35
|
+
warehouseName: string;
|
|
36
|
+
onHand: number;
|
|
37
|
+
available: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface DialogBaseProps {
|
|
41
|
+
open: boolean;
|
|
42
|
+
onOpenChange: (open: boolean) => void;
|
|
43
|
+
/** Called after a successful write with a short human-readable summary. */
|
|
44
|
+
onDone: (message: string) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseWholeNumber(raw: string): number | null {
|
|
48
|
+
const trimmed = raw.trim();
|
|
49
|
+
if (!/^[+-]?\d+$/.test(trimmed)) return null;
|
|
50
|
+
const value = Number(trimmed);
|
|
51
|
+
return Number.isSafeInteger(value) ? value : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function errorMessage(error: unknown, fallback: string): string {
|
|
55
|
+
return error instanceof Error && error.message ? error.message : fallback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ProductPicker({
|
|
59
|
+
id,
|
|
60
|
+
value,
|
|
61
|
+
onChange,
|
|
62
|
+
error,
|
|
63
|
+
}: {
|
|
64
|
+
id: string;
|
|
65
|
+
value: string;
|
|
66
|
+
onChange: (id: string) => void;
|
|
67
|
+
error?: string;
|
|
68
|
+
}) {
|
|
69
|
+
const [search, setSearch] = useState('');
|
|
70
|
+
const { products, totalProducts, hasMore, isLoading, isLoadingMore, loadMore } = useProducts({
|
|
71
|
+
search,
|
|
72
|
+
limit: 20,
|
|
73
|
+
});
|
|
74
|
+
const { product: selected, isLoading: selectedLoading } = useProduct(value || undefined);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<EntityCombobox
|
|
78
|
+
id={id}
|
|
79
|
+
value={value}
|
|
80
|
+
onChange={onChange}
|
|
81
|
+
options={products}
|
|
82
|
+
loading={isLoading}
|
|
83
|
+
required
|
|
84
|
+
getId={(product) => product.id}
|
|
85
|
+
getLabel={(product) => `${product.name} (${product.sku})`}
|
|
86
|
+
renderOption={(product) => (
|
|
87
|
+
<>
|
|
88
|
+
{product.name} <span className="text-text-tertiary">({product.sku})</span>
|
|
89
|
+
</>
|
|
90
|
+
)}
|
|
91
|
+
selectedOption={selected}
|
|
92
|
+
selectedLoading={selectedLoading}
|
|
93
|
+
placeholder="Search your product catalog"
|
|
94
|
+
searchPlaceholder="Search by name or SKU…"
|
|
95
|
+
emptyText="No products found."
|
|
96
|
+
onSearchChange={setSearch}
|
|
97
|
+
hasMore={hasMore}
|
|
98
|
+
totalCount={totalProducts}
|
|
99
|
+
onLoadMore={loadMore}
|
|
100
|
+
loadingMore={isLoadingMore}
|
|
101
|
+
error={error}
|
|
102
|
+
/>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function WarehousePicker({
|
|
107
|
+
id,
|
|
108
|
+
value,
|
|
109
|
+
onChange,
|
|
110
|
+
excludeId,
|
|
111
|
+
error,
|
|
112
|
+
}: {
|
|
113
|
+
id: string;
|
|
114
|
+
value: string;
|
|
115
|
+
onChange: (id: string) => void;
|
|
116
|
+
/** Hidden from the options (e.g. the transfer source). */
|
|
117
|
+
excludeId?: string;
|
|
118
|
+
error?: string;
|
|
119
|
+
}) {
|
|
120
|
+
const [search, setSearch] = useState('');
|
|
121
|
+
const { warehouses, totalWarehouses, hasMore, isLoading, isLoadingMore, loadMore } =
|
|
122
|
+
useWarehouses({ search, limit: 20 });
|
|
123
|
+
const { warehouse: selected, isLoading: selectedLoading } = useWarehouse(value || undefined);
|
|
124
|
+
const options = excludeId
|
|
125
|
+
? warehouses.filter((warehouse) => warehouse.id !== excludeId)
|
|
126
|
+
: warehouses;
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<EntityCombobox
|
|
130
|
+
id={id}
|
|
131
|
+
value={value}
|
|
132
|
+
onChange={onChange}
|
|
133
|
+
options={options}
|
|
134
|
+
loading={isLoading}
|
|
135
|
+
required
|
|
136
|
+
getId={(warehouse) => warehouse.id}
|
|
137
|
+
getLabel={(warehouse) => `${warehouse.name} (${warehouse.code})`}
|
|
138
|
+
renderOption={(warehouse) => (
|
|
139
|
+
<>
|
|
140
|
+
{warehouse.name} <span className="text-text-tertiary">({warehouse.code})</span>
|
|
141
|
+
</>
|
|
142
|
+
)}
|
|
143
|
+
selectedOption={selected}
|
|
144
|
+
selectedLoading={selectedLoading}
|
|
145
|
+
placeholder="Search warehouses"
|
|
146
|
+
searchPlaceholder="Search by name or code…"
|
|
147
|
+
emptyText="No warehouses found."
|
|
148
|
+
onSearchChange={setSearch}
|
|
149
|
+
hasMore={hasMore}
|
|
150
|
+
totalCount={totalWarehouses}
|
|
151
|
+
onLoadMore={loadMore}
|
|
152
|
+
loadingMore={isLoadingMore}
|
|
153
|
+
error={error}
|
|
154
|
+
/>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function DialogActions({
|
|
159
|
+
submitting,
|
|
160
|
+
submitLabel,
|
|
161
|
+
submittingLabel,
|
|
162
|
+
onCancel,
|
|
163
|
+
onSubmit,
|
|
164
|
+
}: {
|
|
165
|
+
submitting: boolean;
|
|
166
|
+
submitLabel: string;
|
|
167
|
+
submittingLabel: string;
|
|
168
|
+
onCancel: () => void;
|
|
169
|
+
onSubmit: () => void;
|
|
170
|
+
}) {
|
|
171
|
+
return (
|
|
172
|
+
<ResponsiveDialogFooter>
|
|
173
|
+
<Button variant="outline" onClick={onCancel} disabled={submitting}>
|
|
174
|
+
Cancel
|
|
175
|
+
</Button>
|
|
176
|
+
<Button onClick={onSubmit} disabled={submitting}>
|
|
177
|
+
{submitting ? submittingLabel : submitLabel}
|
|
178
|
+
</Button>
|
|
179
|
+
</ResponsiveDialogFooter>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function ReceiveStockDialog({ open, onOpenChange, onDone }: DialogBaseProps) {
|
|
184
|
+
const { receiveStock } = useStockActions();
|
|
185
|
+
const [productId, setProductId] = useState('');
|
|
186
|
+
const [warehouseId, setWarehouseId] = useState('');
|
|
187
|
+
const [quantity, setQuantity] = useState('');
|
|
188
|
+
const [reorderPoint, setReorderPoint] = useState('');
|
|
189
|
+
const [error, setError] = useState<string | null>(null);
|
|
190
|
+
const [submitting, setSubmitting] = useState(false);
|
|
191
|
+
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (!open) return;
|
|
194
|
+
setProductId('');
|
|
195
|
+
setWarehouseId('');
|
|
196
|
+
setQuantity('');
|
|
197
|
+
setReorderPoint('');
|
|
198
|
+
setError(null);
|
|
199
|
+
setSubmitting(false);
|
|
200
|
+
}, [open]);
|
|
201
|
+
|
|
202
|
+
const handleSubmit = async () => {
|
|
203
|
+
const units = parseWholeNumber(quantity);
|
|
204
|
+
const reorder = reorderPoint.trim() === '' ? null : parseWholeNumber(reorderPoint);
|
|
205
|
+
if (!productId) return setError('Choose the product you received.');
|
|
206
|
+
if (!warehouseId) return setError('Choose the warehouse it was received into.');
|
|
207
|
+
if (units === null || units <= 0)
|
|
208
|
+
return setError('Quantity must be a whole number above zero.');
|
|
209
|
+
if (reorder !== null && reorder < 0) {
|
|
210
|
+
return setError('Reorder point must be zero or more.');
|
|
211
|
+
}
|
|
212
|
+
if (reorderPoint.trim() !== '' && reorder === null) {
|
|
213
|
+
return setError('Reorder point must be a whole number.');
|
|
214
|
+
}
|
|
215
|
+
setSubmitting(true);
|
|
216
|
+
setError(null);
|
|
217
|
+
try {
|
|
218
|
+
const line = await receiveStock({
|
|
219
|
+
productId,
|
|
220
|
+
warehouseId,
|
|
221
|
+
quantity: units,
|
|
222
|
+
reorderPoint: reorder,
|
|
223
|
+
});
|
|
224
|
+
onOpenChange(false);
|
|
225
|
+
onDone(
|
|
226
|
+
`Received ${units.toLocaleString()} unit(s) — ${line.productName ?? 'product'} now has ${line.quantity.toLocaleString()} on hand at ${line.warehouseName ?? 'the warehouse'}.`
|
|
227
|
+
);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
setError(errorMessage(err, 'Failed to receive stock.'));
|
|
230
|
+
} finally {
|
|
231
|
+
setSubmitting(false);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<ResponsiveDialog open={open} onOpenChange={onOpenChange}>
|
|
237
|
+
<ResponsiveDialogContent>
|
|
238
|
+
<ResponsiveDialogHeader>
|
|
239
|
+
<ResponsiveDialogTitle>Receive stock</ResponsiveDialogTitle>
|
|
240
|
+
<ResponsiveDialogDescription>
|
|
241
|
+
Add units of a product to a warehouse. If the warehouse already stocks it, the units are
|
|
242
|
+
added to that line; otherwise a new stock line is created.
|
|
243
|
+
</ResponsiveDialogDescription>
|
|
244
|
+
</ResponsiveDialogHeader>
|
|
245
|
+
<div className="space-y-4 px-1">
|
|
246
|
+
<div className="space-y-2">
|
|
247
|
+
<Label htmlFor="receive-product">Product *</Label>
|
|
248
|
+
<ProductPicker id="receive-product" value={productId} onChange={setProductId} />
|
|
249
|
+
</div>
|
|
250
|
+
<div className="space-y-2">
|
|
251
|
+
<Label htmlFor="receive-warehouse">Warehouse *</Label>
|
|
252
|
+
<WarehousePicker id="receive-warehouse" value={warehouseId} onChange={setWarehouseId} />
|
|
253
|
+
</div>
|
|
254
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
255
|
+
<div className="space-y-2">
|
|
256
|
+
<Label htmlFor="receive-quantity">Quantity received *</Label>
|
|
257
|
+
<Input
|
|
258
|
+
id="receive-quantity"
|
|
259
|
+
type="number"
|
|
260
|
+
inputMode="numeric"
|
|
261
|
+
min={1}
|
|
262
|
+
step={1}
|
|
263
|
+
value={quantity}
|
|
264
|
+
onChange={(e) => setQuantity(e.target.value)}
|
|
265
|
+
placeholder="e.g. 50"
|
|
266
|
+
/>
|
|
267
|
+
</div>
|
|
268
|
+
<div className="space-y-2">
|
|
269
|
+
<Label htmlFor="receive-reorder">Reorder point (optional)</Label>
|
|
270
|
+
<Input
|
|
271
|
+
id="receive-reorder"
|
|
272
|
+
type="number"
|
|
273
|
+
inputMode="numeric"
|
|
274
|
+
min={0}
|
|
275
|
+
step={1}
|
|
276
|
+
value={reorderPoint}
|
|
277
|
+
onChange={(e) => setReorderPoint(e.target.value)}
|
|
278
|
+
placeholder="Low-stock threshold"
|
|
279
|
+
/>
|
|
280
|
+
<p className="text-xs text-text-secondary">
|
|
281
|
+
Only used when this creates a new stock line.
|
|
282
|
+
</p>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
<FormError message={error} />
|
|
286
|
+
</div>
|
|
287
|
+
<DialogActions
|
|
288
|
+
submitting={submitting}
|
|
289
|
+
submitLabel="Receive stock"
|
|
290
|
+
submittingLabel="Receiving…"
|
|
291
|
+
onCancel={() => onOpenChange(false)}
|
|
292
|
+
onSubmit={handleSubmit}
|
|
293
|
+
/>
|
|
294
|
+
</ResponsiveDialogContent>
|
|
295
|
+
</ResponsiveDialog>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function AdjustStockDialog({
|
|
300
|
+
line,
|
|
301
|
+
open,
|
|
302
|
+
onOpenChange,
|
|
303
|
+
onDone,
|
|
304
|
+
}: DialogBaseProps & { line: StockLineTarget | null }) {
|
|
305
|
+
const { adjustStock } = useStockActions();
|
|
306
|
+
const [change, setChange] = useState('');
|
|
307
|
+
const [error, setError] = useState<string | null>(null);
|
|
308
|
+
const [submitting, setSubmitting] = useState(false);
|
|
309
|
+
|
|
310
|
+
useEffect(() => {
|
|
311
|
+
if (!open) return;
|
|
312
|
+
setChange('');
|
|
313
|
+
setError(null);
|
|
314
|
+
setSubmitting(false);
|
|
315
|
+
}, [open, line?.id]);
|
|
316
|
+
|
|
317
|
+
const delta = parseWholeNumber(change);
|
|
318
|
+
const preview =
|
|
319
|
+
line && delta !== null && delta !== 0
|
|
320
|
+
? { onHand: line.onHand + delta, available: line.available + delta }
|
|
321
|
+
: null;
|
|
322
|
+
const previewInvalid = preview !== null && (preview.onHand < 0 || preview.available < 0);
|
|
323
|
+
|
|
324
|
+
const handleSubmit = async () => {
|
|
325
|
+
if (!line) return;
|
|
326
|
+
if (delta === null || delta === 0) {
|
|
327
|
+
return setError('Enter a whole-number change other than zero, e.g. 10 or -3.');
|
|
328
|
+
}
|
|
329
|
+
if (previewInvalid) {
|
|
330
|
+
return setError(`Only ${line.available.toLocaleString()} unit(s) are available to remove.`);
|
|
331
|
+
}
|
|
332
|
+
setSubmitting(true);
|
|
333
|
+
setError(null);
|
|
334
|
+
try {
|
|
335
|
+
const updated = await adjustStock(line.id, delta);
|
|
336
|
+
onOpenChange(false);
|
|
337
|
+
onDone(
|
|
338
|
+
`Adjusted ${line.productName} at ${line.warehouseName} by ${delta > 0 ? '+' : ''}${delta.toLocaleString()} — now ${updated.quantity.toLocaleString()} on hand.`
|
|
339
|
+
);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
setError(errorMessage(err, 'Failed to adjust stock.'));
|
|
342
|
+
} finally {
|
|
343
|
+
setSubmitting(false);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<ResponsiveDialog open={open} onOpenChange={onOpenChange}>
|
|
349
|
+
<ResponsiveDialogContent>
|
|
350
|
+
<ResponsiveDialogHeader>
|
|
351
|
+
<ResponsiveDialogTitle>Adjust stock</ResponsiveDialogTitle>
|
|
352
|
+
<ResponsiveDialogDescription>
|
|
353
|
+
{line
|
|
354
|
+
? `${line.productName} (${line.sku}) at ${line.warehouseName}. Use a positive number to add units and a negative one to remove them, e.g. after a count or write-off.`
|
|
355
|
+
: 'Choose a stock line to adjust.'}
|
|
356
|
+
</ResponsiveDialogDescription>
|
|
357
|
+
</ResponsiveDialogHeader>
|
|
358
|
+
{line && (
|
|
359
|
+
<div className="space-y-4 px-1">
|
|
360
|
+
<dl className="grid grid-cols-2 gap-3 text-sm">
|
|
361
|
+
<div>
|
|
362
|
+
<dt className="text-text-secondary">On hand</dt>
|
|
363
|
+
<dd className="font-medium">{line.onHand.toLocaleString()}</dd>
|
|
364
|
+
</div>
|
|
365
|
+
<div>
|
|
366
|
+
<dt className="text-text-secondary">Available</dt>
|
|
367
|
+
<dd className="font-medium">{line.available.toLocaleString()}</dd>
|
|
368
|
+
</div>
|
|
369
|
+
</dl>
|
|
370
|
+
<div className="space-y-2">
|
|
371
|
+
<Label htmlFor="adjust-change">Change (units) *</Label>
|
|
372
|
+
<Input
|
|
373
|
+
id="adjust-change"
|
|
374
|
+
type="number"
|
|
375
|
+
inputMode="numeric"
|
|
376
|
+
step={1}
|
|
377
|
+
value={change}
|
|
378
|
+
onChange={(e) => setChange(e.target.value)}
|
|
379
|
+
placeholder="e.g. 10 or -3"
|
|
380
|
+
/>
|
|
381
|
+
{preview && (
|
|
382
|
+
<p
|
|
383
|
+
className={`text-xs ${previewInvalid ? 'text-status-error-text' : 'text-text-secondary'}`}
|
|
384
|
+
>
|
|
385
|
+
{previewInvalid
|
|
386
|
+
? 'This would take stock below zero.'
|
|
387
|
+
: `After this: ${preview.onHand.toLocaleString()} on hand, ${preview.available.toLocaleString()} available.`}
|
|
388
|
+
</p>
|
|
389
|
+
)}
|
|
390
|
+
</div>
|
|
391
|
+
<FormError message={error} />
|
|
392
|
+
</div>
|
|
393
|
+
)}
|
|
394
|
+
<DialogActions
|
|
395
|
+
submitting={submitting}
|
|
396
|
+
submitLabel="Apply adjustment"
|
|
397
|
+
submittingLabel="Applying…"
|
|
398
|
+
onCancel={() => onOpenChange(false)}
|
|
399
|
+
onSubmit={handleSubmit}
|
|
400
|
+
/>
|
|
401
|
+
</ResponsiveDialogContent>
|
|
402
|
+
</ResponsiveDialog>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function TransferStockDialog({
|
|
407
|
+
line,
|
|
408
|
+
open,
|
|
409
|
+
onOpenChange,
|
|
410
|
+
onDone,
|
|
411
|
+
}: DialogBaseProps & { line: StockLineTarget | null }) {
|
|
412
|
+
const { transferStock } = useStockActions();
|
|
413
|
+
const [destinationId, setDestinationId] = useState('');
|
|
414
|
+
const [quantity, setQuantity] = useState('');
|
|
415
|
+
const [error, setError] = useState<string | null>(null);
|
|
416
|
+
const [submitting, setSubmitting] = useState(false);
|
|
417
|
+
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
if (!open) return;
|
|
420
|
+
setDestinationId('');
|
|
421
|
+
setQuantity('');
|
|
422
|
+
setError(null);
|
|
423
|
+
setSubmitting(false);
|
|
424
|
+
}, [open, line?.id]);
|
|
425
|
+
|
|
426
|
+
const handleSubmit = async () => {
|
|
427
|
+
if (!line) return;
|
|
428
|
+
const units = parseWholeNumber(quantity);
|
|
429
|
+
if (!destinationId) return setError('Choose the destination warehouse.');
|
|
430
|
+
if (destinationId === line.warehouseId) {
|
|
431
|
+
return setError('Source and destination warehouses must be different.');
|
|
432
|
+
}
|
|
433
|
+
if (units === null || units <= 0)
|
|
434
|
+
return setError('Quantity must be a whole number above zero.');
|
|
435
|
+
if (units > line.available) {
|
|
436
|
+
return setError(`Only ${line.available.toLocaleString()} unit(s) are available to transfer.`);
|
|
437
|
+
}
|
|
438
|
+
setSubmitting(true);
|
|
439
|
+
setError(null);
|
|
440
|
+
try {
|
|
441
|
+
const result = await transferStock({
|
|
442
|
+
sourceInventoryId: line.id,
|
|
443
|
+
destinationWarehouseId: destinationId,
|
|
444
|
+
quantity: units,
|
|
445
|
+
});
|
|
446
|
+
onOpenChange(false);
|
|
447
|
+
onDone(
|
|
448
|
+
`Transferred ${units.toLocaleString()} unit(s) of ${line.productName} from ${line.warehouseName} to ${result.destination.warehouseName ?? 'the destination'}.`
|
|
449
|
+
);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
setError(errorMessage(err, 'Failed to transfer stock.'));
|
|
452
|
+
} finally {
|
|
453
|
+
setSubmitting(false);
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
return (
|
|
458
|
+
<ResponsiveDialog open={open} onOpenChange={onOpenChange}>
|
|
459
|
+
<ResponsiveDialogContent>
|
|
460
|
+
<ResponsiveDialogHeader>
|
|
461
|
+
<ResponsiveDialogTitle>Transfer stock</ResponsiveDialogTitle>
|
|
462
|
+
<ResponsiveDialogDescription>
|
|
463
|
+
{line
|
|
464
|
+
? `Move available ${line.productName} (${line.sku}) out of ${line.warehouseName}. Both warehouses are updated together.`
|
|
465
|
+
: 'Choose a stock line to transfer.'}
|
|
466
|
+
</ResponsiveDialogDescription>
|
|
467
|
+
</ResponsiveDialogHeader>
|
|
468
|
+
{line && (
|
|
469
|
+
<div className="space-y-4 px-1">
|
|
470
|
+
<p className="text-sm text-text-secondary">
|
|
471
|
+
Available to transfer:{' '}
|
|
472
|
+
<span className="font-medium text-text-primary">
|
|
473
|
+
{line.available.toLocaleString()}
|
|
474
|
+
</span>
|
|
475
|
+
</p>
|
|
476
|
+
<div className="space-y-2">
|
|
477
|
+
<Label htmlFor="transfer-destination">Destination warehouse *</Label>
|
|
478
|
+
<WarehousePicker
|
|
479
|
+
id="transfer-destination"
|
|
480
|
+
value={destinationId}
|
|
481
|
+
onChange={setDestinationId}
|
|
482
|
+
excludeId={line.warehouseId}
|
|
483
|
+
/>
|
|
484
|
+
</div>
|
|
485
|
+
<div className="space-y-2">
|
|
486
|
+
<Label htmlFor="transfer-quantity">Quantity *</Label>
|
|
487
|
+
<Input
|
|
488
|
+
id="transfer-quantity"
|
|
489
|
+
type="number"
|
|
490
|
+
inputMode="numeric"
|
|
491
|
+
min={1}
|
|
492
|
+
max={line.available}
|
|
493
|
+
step={1}
|
|
494
|
+
value={quantity}
|
|
495
|
+
onChange={(e) => setQuantity(e.target.value)}
|
|
496
|
+
placeholder="Units to move"
|
|
497
|
+
/>
|
|
498
|
+
</div>
|
|
499
|
+
<FormError message={error} />
|
|
500
|
+
</div>
|
|
501
|
+
)}
|
|
502
|
+
<DialogActions
|
|
503
|
+
submitting={submitting}
|
|
504
|
+
submitLabel="Transfer stock"
|
|
505
|
+
submittingLabel="Transferring…"
|
|
506
|
+
onCancel={() => onOpenChange(false)}
|
|
507
|
+
onSubmit={handleSubmit}
|
|
508
|
+
/>
|
|
509
|
+
</ResponsiveDialogContent>
|
|
510
|
+
</ResponsiveDialog>
|
|
511
|
+
);
|
|
512
|
+
}
|
|
@@ -33,10 +33,13 @@ export {
|
|
|
33
33
|
useWarehouse,
|
|
34
34
|
useInventoryStats,
|
|
35
35
|
useInventoryTransactions,
|
|
36
|
+
useStockActions,
|
|
36
37
|
type InventoryStats,
|
|
37
38
|
type InventoryTransaction,
|
|
38
39
|
type InventoryTransactionType,
|
|
39
40
|
type ProductStockLevel,
|
|
41
|
+
type ReceiveStockInput,
|
|
42
|
+
type TransferStockInput,
|
|
40
43
|
} from './useInventory';
|
|
41
44
|
|
|
42
45
|
// Customers
|
|
@@ -75,6 +75,7 @@ interface UseFleetResult {
|
|
|
75
75
|
updateVehicle: (id: string, input: CreateVehicleInput) => Promise<Vehicle>;
|
|
76
76
|
updateDriver: (id: string, input: UpdateDriverInput) => Promise<Driver>;
|
|
77
77
|
deleteVehicle: (id: string) => Promise<void>;
|
|
78
|
+
deleteDriver: (id: string) => Promise<void>;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
export interface ApiVehicle {
|
|
@@ -256,6 +257,12 @@ const GET_DRIVER_QUERY = `
|
|
|
256
257
|
}
|
|
257
258
|
`;
|
|
258
259
|
|
|
260
|
+
const DELETE_DRIVER_MUTATION = `
|
|
261
|
+
mutation DeleteDriver($id: ID!) {
|
|
262
|
+
deleteDriver(id: $id)
|
|
263
|
+
}
|
|
264
|
+
`;
|
|
265
|
+
|
|
259
266
|
const DELETE_VEHICLE_MUTATION = `
|
|
260
267
|
mutation DeleteVehicle($id: ID!) {
|
|
261
268
|
deleteVehicle(id: $id)
|
|
@@ -891,6 +898,16 @@ export function useFleet(options: UseFleetOptions = {}): UseFleetResult {
|
|
|
891
898
|
[graphQLOptions]
|
|
892
899
|
);
|
|
893
900
|
|
|
901
|
+
const deleteDriver = useCallback(
|
|
902
|
+
async (id: string): Promise<void> => {
|
|
903
|
+
await executeGraphQL<{ deleteDriver: boolean }>(graphQLOptions, DELETE_DRIVER_MUTATION, {
|
|
904
|
+
id,
|
|
905
|
+
});
|
|
906
|
+
setDrivers((previous) => previous.filter((item) => item.id !== id));
|
|
907
|
+
},
|
|
908
|
+
[graphQLOptions]
|
|
909
|
+
);
|
|
910
|
+
|
|
894
911
|
return {
|
|
895
912
|
vehicles,
|
|
896
913
|
drivers,
|
|
@@ -904,6 +921,7 @@ export function useFleet(options: UseFleetOptions = {}): UseFleetResult {
|
|
|
904
921
|
updateVehicle,
|
|
905
922
|
updateDriver,
|
|
906
923
|
deleteVehicle,
|
|
924
|
+
deleteDriver,
|
|
907
925
|
};
|
|
908
926
|
}
|
|
909
927
|
|