@burdenoff/microfe-movethewheels 2026.920.2 → 2026.921.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/components/CarbonFootprintPanel.js +1 -1
- package/dist/components/CustomerPicker.js +271 -0
- package/dist/components/DataTable.js +140 -139
- package/dist/components/DocumentsCard.js +14 -8
- package/dist/generated/wspace-types.d.ts +1043 -5
- package/dist/hooks/useAnalytics.js +21 -1
- package/dist/hooks/useCustomers.js +94 -78
- package/dist/hooks/useDebouncedValue.js +11 -0
- package/dist/hooks/useEntityDocuments.js +28 -18
- package/dist/hooks/useEpod.js +24 -17
- package/dist/hooks/useFleet.js +146 -125
- package/dist/hooks/useInventory.js +255 -186
- package/dist/hooks/useMultiModalShipments.js +35 -27
- package/dist/hooks/useOrders.js +73 -73
- package/dist/hooks/useRoutes.js +97 -75
- package/dist/hooks/useTracking.js +100 -88
- package/dist/movethewheels/components/CustomerPicker.d.ts +19 -0
- package/dist/movethewheels/components/DataTable.d.ts +5 -1
- package/dist/movethewheels/hooks/useCustomers.d.ts +0 -1
- package/dist/movethewheels/hooks/useDebouncedValue.d.ts +1 -0
- package/dist/movethewheels/hooks/useEntityDocuments.d.ts +2 -0
- package/dist/movethewheels/hooks/useEpod.d.ts +6 -1
- package/dist/movethewheels/hooks/useFleet.d.ts +49 -5
- package/dist/movethewheels/hooks/useInventory.d.ts +24 -17
- package/dist/movethewheels/hooks/useMultiModalShipments.d.ts +6 -1
- package/dist/movethewheels/hooks/useOrders.d.ts +33 -5
- package/dist/movethewheels/hooks/useRoutes.d.ts +2 -1
- package/dist/movethewheels/hooks/useTracking.d.ts +12 -11
- package/dist/movethewheels/types/index.d.ts +7 -5
- package/dist/movethewheels/utils/navigation.d.ts +14 -0
- package/dist/pages/CreateOrderPage.js +228 -217
- package/dist/pages/CustomerDetailsPage.js +220 -220
- package/dist/pages/CustomersPage.js +61 -54
- package/dist/pages/DeliveryCapturePage.js +131 -109
- package/dist/pages/DriversPage.js +100 -92
- package/dist/pages/InventoryPage.js +239 -236
- package/dist/pages/MultiModalShipmentPage.js +163 -130
- package/dist/pages/OrderDetailsPage.js +224 -208
- package/dist/pages/OrdersListPage.js +66 -60
- package/dist/pages/ProductDetailsPage.js +241 -200
- package/dist/pages/ProductsPage.js +59 -52
- package/dist/pages/RouteDetailsPage.js +343 -321
- package/dist/pages/RoutesPage.js +55 -48
- package/dist/pages/VehiclesPage.js +63 -56
- package/dist/pages/WarehousesPage.js +60 -53
- package/dist/utils/navigation.js +22 -1
- package/package.json +1 -1
- package/src/generated/wspace-types.ts +1188 -5
- package/src/movethewheels/components/CarbonFootprintPanel.tsx +0 -1
- package/src/movethewheels/components/CustomerPicker.tsx +321 -0
- package/src/movethewheels/components/DataTable.tsx +34 -18
- package/src/movethewheels/components/DocumentsCard.tsx +6 -1
- package/src/movethewheels/components/maps/leaflet-setup.ts +1 -8
- package/src/movethewheels/hooks/useAnalytics.ts +27 -0
- package/src/movethewheels/hooks/useCustomers.ts +64 -24
- package/src/movethewheels/hooks/useDebouncedValue.ts +12 -0
- package/src/movethewheels/hooks/useEntityDocuments.ts +43 -25
- package/src/movethewheels/hooks/useEpod.ts +14 -7
- package/src/movethewheels/hooks/useFleet.ts +58 -37
- package/src/movethewheels/hooks/useFleetIntelligence.ts +1 -4
- package/src/movethewheels/hooks/useInventory.ts +211 -90
- package/src/movethewheels/hooks/useMultiModalShipments.ts +15 -11
- package/src/movethewheels/hooks/useOrders.ts +13 -14
- package/src/movethewheels/hooks/useRoutes.ts +80 -23
- package/src/movethewheels/hooks/useTracking.ts +130 -98
- package/src/movethewheels/pages/CreateOrderPage.tsx +92 -31
- package/src/movethewheels/pages/CustomerDetailsPage.tsx +15 -6
- package/src/movethewheels/pages/CustomersPage.tsx +11 -1
- package/src/movethewheels/pages/DeliveryCapturePage.tsx +31 -1
- package/src/movethewheels/pages/DriversPage.tsx +24 -12
- package/src/movethewheels/pages/InventoryPage.tsx +57 -32
- package/src/movethewheels/pages/MultiModalShipmentPage.tsx +52 -3
- package/src/movethewheels/pages/OrderDetailsPage.tsx +17 -1
- package/src/movethewheels/pages/OrdersListPage.tsx +20 -3
- package/src/movethewheels/pages/ProductDetailsPage.tsx +52 -3
- package/src/movethewheels/pages/ProductsPage.tsx +11 -1
- package/src/movethewheels/pages/RouteDetailsPage.tsx +35 -5
- package/src/movethewheels/pages/RoutesPage.tsx +11 -1
- package/src/movethewheels/pages/VehiclesPage.tsx +19 -2
- package/src/movethewheels/pages/WarehousesPage.tsx +11 -1
- package/src/movethewheels/types/index.ts +7 -5
- package/src/movethewheels/utils/navigation.ts +32 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { useState, type ChangeEvent, type FormEvent } from 'react';
|
|
2
|
+
import { Plus, RefreshCw, UserPlus } from 'lucide-react';
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogDescription,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
Input,
|
|
12
|
+
Label,
|
|
13
|
+
Select,
|
|
14
|
+
SelectContent,
|
|
15
|
+
SelectItem,
|
|
16
|
+
SelectTrigger,
|
|
17
|
+
SelectValue,
|
|
18
|
+
} from './ui';
|
|
19
|
+
import type { Customer } from '../types';
|
|
20
|
+
import { FormError } from './FormError';
|
|
21
|
+
|
|
22
|
+
interface CustomerPickerProps {
|
|
23
|
+
customers: Customer[];
|
|
24
|
+
value: string;
|
|
25
|
+
onValueChange: (value: string) => void;
|
|
26
|
+
onCreated: (customer: Customer) => void;
|
|
27
|
+
createCustomer: (customer: Partial<Customer>) => Promise<Customer>;
|
|
28
|
+
isLoading: boolean;
|
|
29
|
+
error: Error | null;
|
|
30
|
+
onRetry: () => void;
|
|
31
|
+
onOpenCustomers?: () => void;
|
|
32
|
+
selectedCustomer?: Customer | null;
|
|
33
|
+
searchQuery?: string;
|
|
34
|
+
onSearchQueryChange?: (query: string) => void;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface CustomerFormState {
|
|
39
|
+
name: string;
|
|
40
|
+
type: Customer['type'];
|
|
41
|
+
email: string;
|
|
42
|
+
phone: string;
|
|
43
|
+
company: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const EMPTY_FORM: CustomerFormState = {
|
|
47
|
+
name: '',
|
|
48
|
+
type: 'b2c',
|
|
49
|
+
email: '',
|
|
50
|
+
phone: '',
|
|
51
|
+
company: '',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function mergeCustomers(customers: Customer[], selectedCustomer?: Customer | null): Customer[] {
|
|
55
|
+
if (!selectedCustomer || customers.some((customer) => customer.id === selectedCustomer.id)) {
|
|
56
|
+
return customers;
|
|
57
|
+
}
|
|
58
|
+
return [selectedCustomer, ...customers];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function CustomerPicker({
|
|
62
|
+
customers,
|
|
63
|
+
value,
|
|
64
|
+
onValueChange,
|
|
65
|
+
onCreated,
|
|
66
|
+
createCustomer,
|
|
67
|
+
isLoading,
|
|
68
|
+
error,
|
|
69
|
+
onRetry,
|
|
70
|
+
onOpenCustomers,
|
|
71
|
+
selectedCustomer,
|
|
72
|
+
searchQuery = '',
|
|
73
|
+
onSearchQueryChange,
|
|
74
|
+
disabled = false,
|
|
75
|
+
}: CustomerPickerProps) {
|
|
76
|
+
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
|
77
|
+
const [form, setForm] = useState<CustomerFormState>(EMPTY_FORM);
|
|
78
|
+
const [isCreating, setIsCreating] = useState(false);
|
|
79
|
+
const [createError, setCreateError] = useState<string | null>(null);
|
|
80
|
+
const options = mergeCustomers(customers, selectedCustomer);
|
|
81
|
+
|
|
82
|
+
const openCreateDialog = () => {
|
|
83
|
+
setCreateError(null);
|
|
84
|
+
setForm(EMPTY_FORM);
|
|
85
|
+
setIsCreateOpen(true);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const closeCreateDialog = () => {
|
|
89
|
+
if (isCreating) return;
|
|
90
|
+
setIsCreateOpen(false);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const handleCreate = async (event: FormEvent<HTMLFormElement>) => {
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
event.stopPropagation();
|
|
96
|
+
const name = form.name.trim();
|
|
97
|
+
if (!name) {
|
|
98
|
+
setCreateError('Customer name is required.');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
setIsCreating(true);
|
|
103
|
+
setCreateError(null);
|
|
104
|
+
try {
|
|
105
|
+
const created = await createCustomer({
|
|
106
|
+
name,
|
|
107
|
+
type: form.type,
|
|
108
|
+
email: form.email.trim() || undefined,
|
|
109
|
+
phone: form.phone.trim() || undefined,
|
|
110
|
+
company: form.company.trim() || undefined,
|
|
111
|
+
});
|
|
112
|
+
onCreated(created);
|
|
113
|
+
setIsCreateOpen(false);
|
|
114
|
+
setForm(EMPTY_FORM);
|
|
115
|
+
} catch (creationError) {
|
|
116
|
+
setCreateError(
|
|
117
|
+
creationError instanceof Error
|
|
118
|
+
? creationError.message
|
|
119
|
+
: 'Could not create the customer. Please try again.'
|
|
120
|
+
);
|
|
121
|
+
} finally {
|
|
122
|
+
setIsCreating(false);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="space-y-2">
|
|
128
|
+
<div className="flex items-center justify-between gap-2">
|
|
129
|
+
<Label htmlFor="customer">Customer</Label>
|
|
130
|
+
<Button
|
|
131
|
+
type="button"
|
|
132
|
+
variant="ghost"
|
|
133
|
+
size="sm"
|
|
134
|
+
onClick={openCreateDialog}
|
|
135
|
+
disabled={disabled || isLoading}
|
|
136
|
+
>
|
|
137
|
+
<Plus size={14} className="mr-1" />
|
|
138
|
+
Add customer
|
|
139
|
+
</Button>
|
|
140
|
+
</div>
|
|
141
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
|
|
142
|
+
{onSearchQueryChange && (
|
|
143
|
+
<Input
|
|
144
|
+
value={searchQuery}
|
|
145
|
+
onChange={(event) => onSearchQueryChange(event.target.value)}
|
|
146
|
+
placeholder="Search customers"
|
|
147
|
+
aria-label="Search customers"
|
|
148
|
+
disabled={disabled}
|
|
149
|
+
className="sm:max-w-56"
|
|
150
|
+
/>
|
|
151
|
+
)}
|
|
152
|
+
<Select
|
|
153
|
+
value={value}
|
|
154
|
+
onValueChange={onValueChange}
|
|
155
|
+
disabled={disabled || isLoading || Boolean(error)}
|
|
156
|
+
>
|
|
157
|
+
<SelectTrigger id="customer" className="flex-1">
|
|
158
|
+
<SelectValue placeholder={isLoading ? 'Loading customers...' : 'Select customer'} />
|
|
159
|
+
</SelectTrigger>
|
|
160
|
+
<SelectContent>
|
|
161
|
+
{options.map((customer) => (
|
|
162
|
+
<SelectItem key={customer.id} value={customer.id}>
|
|
163
|
+
{customer.name} {customer.email ? `(${customer.email})` : ''}
|
|
164
|
+
</SelectItem>
|
|
165
|
+
))}
|
|
166
|
+
</SelectContent>
|
|
167
|
+
</Select>
|
|
168
|
+
{error && (
|
|
169
|
+
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
|
170
|
+
<RefreshCw size={14} className="mr-1" />
|
|
171
|
+
Retry
|
|
172
|
+
</Button>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
{error ? (
|
|
176
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
177
|
+
<p role="alert" className="text-sm text-status-error-text">
|
|
178
|
+
Customers could not be loaded. Retry or add a customer to continue.
|
|
179
|
+
</p>
|
|
180
|
+
{onOpenCustomers && (
|
|
181
|
+
<Button type="button" variant="outline" size="sm" onClick={onOpenCustomers}>
|
|
182
|
+
Open customer page
|
|
183
|
+
</Button>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
) : !isLoading && options.length === 0 ? (
|
|
187
|
+
<div className="rounded-lg border border-border-seam bg-bg-sunken/40 p-3">
|
|
188
|
+
<div className="flex items-start gap-2">
|
|
189
|
+
<UserPlus size={16} className="mt-0.5 shrink-0 text-text-secondary" />
|
|
190
|
+
<div>
|
|
191
|
+
<p className="text-sm font-medium text-text-primary">Add a customer to continue</p>
|
|
192
|
+
<p className="text-sm text-text-secondary">
|
|
193
|
+
The customer will be selected automatically and your order details will stay here.
|
|
194
|
+
</p>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
<Button
|
|
198
|
+
type="button"
|
|
199
|
+
size="sm"
|
|
200
|
+
className="mt-3"
|
|
201
|
+
onClick={openCreateDialog}
|
|
202
|
+
disabled={disabled}
|
|
203
|
+
>
|
|
204
|
+
<Plus size={14} className="mr-1.5" />
|
|
205
|
+
Create customer
|
|
206
|
+
</Button>
|
|
207
|
+
</div>
|
|
208
|
+
) : null}
|
|
209
|
+
|
|
210
|
+
<Dialog
|
|
211
|
+
open={isCreateOpen}
|
|
212
|
+
onOpenChange={(open: boolean) => (open ? setIsCreateOpen(true) : closeCreateDialog())}
|
|
213
|
+
>
|
|
214
|
+
<DialogContent>
|
|
215
|
+
<DialogHeader>
|
|
216
|
+
<DialogTitle>Create customer</DialogTitle>
|
|
217
|
+
<DialogDescription>
|
|
218
|
+
Add the customer without leaving this order. They will be selected automatically.
|
|
219
|
+
</DialogDescription>
|
|
220
|
+
</DialogHeader>
|
|
221
|
+
<form onSubmit={handleCreate} className="space-y-4">
|
|
222
|
+
<FormError message={createError} />
|
|
223
|
+
<div className="space-y-2">
|
|
224
|
+
<Label htmlFor="quick-customer-name">Name</Label>
|
|
225
|
+
<Input
|
|
226
|
+
id="quick-customer-name"
|
|
227
|
+
value={form.name}
|
|
228
|
+
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
|
229
|
+
setForm((previous) => ({ ...previous, name: event.target.value }))
|
|
230
|
+
}
|
|
231
|
+
placeholder="Acme Corp or Jane Doe"
|
|
232
|
+
autoFocus
|
|
233
|
+
required
|
|
234
|
+
/>
|
|
235
|
+
</div>
|
|
236
|
+
<div className="space-y-2">
|
|
237
|
+
<Label htmlFor="quick-customer-type">Customer type</Label>
|
|
238
|
+
<Select
|
|
239
|
+
value={form.type}
|
|
240
|
+
onValueChange={(value: string) =>
|
|
241
|
+
setForm((previous) => ({ ...previous, type: value as Customer['type'] }))
|
|
242
|
+
}
|
|
243
|
+
>
|
|
244
|
+
<SelectTrigger id="quick-customer-type">
|
|
245
|
+
<SelectValue />
|
|
246
|
+
</SelectTrigger>
|
|
247
|
+
<SelectContent>
|
|
248
|
+
<SelectItem value="b2c">B2C (Individual)</SelectItem>
|
|
249
|
+
<SelectItem value="b2b">B2B (Business)</SelectItem>
|
|
250
|
+
</SelectContent>
|
|
251
|
+
</Select>
|
|
252
|
+
</div>
|
|
253
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
254
|
+
<div className="space-y-2">
|
|
255
|
+
<Label htmlFor="quick-customer-email">Email</Label>
|
|
256
|
+
<Input
|
|
257
|
+
id="quick-customer-email"
|
|
258
|
+
type="email"
|
|
259
|
+
value={form.email}
|
|
260
|
+
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
|
261
|
+
setForm((previous) => ({ ...previous, email: event.target.value }))
|
|
262
|
+
}
|
|
263
|
+
placeholder="customer@example.com"
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
<div className="space-y-2">
|
|
267
|
+
<Label htmlFor="quick-customer-phone">Phone</Label>
|
|
268
|
+
<Input
|
|
269
|
+
id="quick-customer-phone"
|
|
270
|
+
value={form.phone}
|
|
271
|
+
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
|
272
|
+
setForm((previous) => ({ ...previous, phone: event.target.value }))
|
|
273
|
+
}
|
|
274
|
+
placeholder="+1-555-0100"
|
|
275
|
+
/>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
<div className="space-y-2">
|
|
279
|
+
<Label htmlFor="quick-customer-company">Company</Label>
|
|
280
|
+
<Input
|
|
281
|
+
id="quick-customer-company"
|
|
282
|
+
value={form.company}
|
|
283
|
+
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
|
284
|
+
setForm((previous) => ({ ...previous, company: event.target.value }))
|
|
285
|
+
}
|
|
286
|
+
placeholder="Company name (optional)"
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
<DialogFooter>
|
|
290
|
+
{onOpenCustomers && (
|
|
291
|
+
<Button
|
|
292
|
+
type="button"
|
|
293
|
+
variant="outline"
|
|
294
|
+
onClick={() => {
|
|
295
|
+
closeCreateDialog();
|
|
296
|
+
onOpenCustomers();
|
|
297
|
+
}}
|
|
298
|
+
disabled={isCreating}
|
|
299
|
+
>
|
|
300
|
+
Use customer page
|
|
301
|
+
</Button>
|
|
302
|
+
)}
|
|
303
|
+
<Button
|
|
304
|
+
type="button"
|
|
305
|
+
variant="outline"
|
|
306
|
+
onClick={closeCreateDialog}
|
|
307
|
+
disabled={isCreating}
|
|
308
|
+
>
|
|
309
|
+
Cancel
|
|
310
|
+
</Button>
|
|
311
|
+
<Button type="submit" disabled={isCreating}>
|
|
312
|
+
<Plus size={16} />
|
|
313
|
+
{isCreating ? 'Creating...' : 'Create and select'}
|
|
314
|
+
</Button>
|
|
315
|
+
</DialogFooter>
|
|
316
|
+
</form>
|
|
317
|
+
</DialogContent>
|
|
318
|
+
</Dialog>
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useMemo, type ReactNode } from 'react';
|
|
1
|
+
import { useState, useMemo, type ReactNode, type ChangeEvent } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
Table,
|
|
4
4
|
TableBody,
|
|
@@ -56,6 +56,10 @@ interface DataTableProps<T> {
|
|
|
56
56
|
searchable?: boolean;
|
|
57
57
|
searchPlaceholder?: string;
|
|
58
58
|
searchFields?: (keyof T)[];
|
|
59
|
+
/** Controlled search query for server-side filtering. */
|
|
60
|
+
searchQuery?: string;
|
|
61
|
+
/** Called when the search input changes; disables current-page filtering. */
|
|
62
|
+
onSearchQueryChange?: (query: string) => void;
|
|
59
63
|
pagination?: boolean;
|
|
60
64
|
pageSize?: number;
|
|
61
65
|
pageSizeOptions?: number[];
|
|
@@ -106,6 +110,8 @@ export function DataTable<T extends object>({
|
|
|
106
110
|
searchable = false,
|
|
107
111
|
searchPlaceholder = 'Search...',
|
|
108
112
|
searchFields,
|
|
113
|
+
searchQuery: controlledSearchQuery,
|
|
114
|
+
onSearchQueryChange,
|
|
109
115
|
pagination = true,
|
|
110
116
|
pageSize: pageSizeProp = 10,
|
|
111
117
|
pageSizeOptions = [10, 25, 50, 100],
|
|
@@ -128,6 +134,8 @@ export function DataTable<T extends object>({
|
|
|
128
134
|
const controlledPageIndex = pageIndex ?? 0;
|
|
129
135
|
|
|
130
136
|
const [searchQuery, setSearchQuery] = useState('');
|
|
137
|
+
const isServerSearch = typeof onSearchQueryChange === 'function';
|
|
138
|
+
const activeSearchQuery = isServerSearch ? (controlledSearchQuery ?? '') : searchQuery;
|
|
131
139
|
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
|
132
140
|
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
|
|
133
141
|
const [currentPage, setCurrentPage] = useState(1);
|
|
@@ -135,25 +143,26 @@ export function DataTable<T extends object>({
|
|
|
135
143
|
|
|
136
144
|
const activePageSize = isControlled ? pageSizeProp : internalPageSize;
|
|
137
145
|
|
|
138
|
-
//
|
|
139
|
-
// the current page
|
|
146
|
+
// In server-search mode, `data` is already filtered by the backend. Never
|
|
147
|
+
// filter just the current page again, or matching rows on other pages vanish.
|
|
140
148
|
const filteredData = useMemo(() => {
|
|
141
|
-
if (!
|
|
149
|
+
if (isServerSearch || !activeSearchQuery || !searchFields || searchFields.length === 0) {
|
|
142
150
|
return data;
|
|
143
151
|
}
|
|
144
152
|
|
|
145
|
-
const query =
|
|
153
|
+
const query = activeSearchQuery.toLowerCase();
|
|
146
154
|
return data.filter((row) =>
|
|
147
155
|
searchFields.some((field) => {
|
|
148
156
|
const value = row[field] as unknown;
|
|
149
157
|
return value != null && String(value).toLowerCase().includes(query);
|
|
150
158
|
})
|
|
151
159
|
);
|
|
152
|
-
}, [data,
|
|
160
|
+
}, [data, activeSearchQuery, searchFields, isServerSearch]);
|
|
153
161
|
|
|
154
|
-
//
|
|
162
|
+
// Controlled pages use the backend's stable order. Local sorting a fetched
|
|
163
|
+
// page would produce a misleading partial-workspace sort.
|
|
155
164
|
const sortedData = useMemo(() => {
|
|
156
|
-
if (!sortColumn || !sortDirection) {
|
|
165
|
+
if (isControlled || !sortColumn || !sortDirection) {
|
|
157
166
|
return filteredData;
|
|
158
167
|
}
|
|
159
168
|
|
|
@@ -201,6 +210,7 @@ export function DataTable<T extends object>({
|
|
|
201
210
|
|
|
202
211
|
// Handle sort
|
|
203
212
|
const handleSort = (columnId: string) => {
|
|
213
|
+
if (isControlled) return;
|
|
204
214
|
if (sortColumn === columnId) {
|
|
205
215
|
if (sortDirection === 'asc') {
|
|
206
216
|
setSortDirection('desc');
|
|
@@ -320,11 +330,11 @@ export function DataTable<T extends object>({
|
|
|
320
330
|
|
|
321
331
|
const renderEmpty = () =>
|
|
322
332
|
emptyIllustration ? (
|
|
323
|
-
|
|
333
|
+
activeSearchQuery ? (
|
|
324
334
|
<IllustratedEmptyState
|
|
325
335
|
illustration="empty-search"
|
|
326
336
|
title="No matches"
|
|
327
|
-
description={`Nothing matches “${
|
|
337
|
+
description={`Nothing matches “${activeSearchQuery}”. Try a different search.`}
|
|
328
338
|
/>
|
|
329
339
|
) : (
|
|
330
340
|
<IllustratedEmptyState
|
|
@@ -352,10 +362,14 @@ export function DataTable<T extends object>({
|
|
|
352
362
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-secondary" />
|
|
353
363
|
<Input
|
|
354
364
|
placeholder={searchPlaceholder}
|
|
355
|
-
value={
|
|
356
|
-
onChange={(e) => {
|
|
357
|
-
|
|
358
|
-
|
|
365
|
+
value={activeSearchQuery}
|
|
366
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
367
|
+
if (isServerSearch) {
|
|
368
|
+
onSearchQueryChange?.(e.target.value);
|
|
369
|
+
} else {
|
|
370
|
+
setSearchQuery(e.target.value);
|
|
371
|
+
setCurrentPage(1);
|
|
372
|
+
}
|
|
359
373
|
}}
|
|
360
374
|
className="pl-9"
|
|
361
375
|
/>
|
|
@@ -372,14 +386,16 @@ export function DataTable<T extends object>({
|
|
|
372
386
|
<TableHead
|
|
373
387
|
key={column.id}
|
|
374
388
|
className={cn(
|
|
375
|
-
|
|
389
|
+
!isControlled &&
|
|
390
|
+
column.sortable &&
|
|
391
|
+
'cursor-pointer select-none hover:bg-bg-sunken/50',
|
|
376
392
|
column.headerClassName
|
|
377
393
|
)}
|
|
378
|
-
onClick={() => column.sortable && handleSort(column.id)}
|
|
394
|
+
onClick={() => !isControlled && column.sortable && handleSort(column.id)}
|
|
379
395
|
>
|
|
380
396
|
<div className="flex items-center gap-2">
|
|
381
397
|
{column.header}
|
|
382
|
-
{column.sortable && getSortIcon(column.id)}
|
|
398
|
+
{!isControlled && column.sortable && getSortIcon(column.id)}
|
|
383
399
|
</div>
|
|
384
400
|
</TableHead>
|
|
385
401
|
))}
|
|
@@ -481,7 +497,7 @@ export function DataTable<T extends object>({
|
|
|
481
497
|
)}
|
|
482
498
|
<Select
|
|
483
499
|
value={String(activePageSize)}
|
|
484
|
-
onValueChange={(value) => {
|
|
500
|
+
onValueChange={(value: string) => {
|
|
485
501
|
const next = Number(value);
|
|
486
502
|
if (isControlled) {
|
|
487
503
|
onPageSizeChange?.(next);
|
|
@@ -33,7 +33,7 @@ function formatFileSize(bytes: number): string {
|
|
|
33
33
|
|
|
34
34
|
export function DocumentsCard({ entityType, entityId }: DocumentsCardProps) {
|
|
35
35
|
const { navigate } = useMoveTheWheels();
|
|
36
|
-
const { documents, isLoading } = useEntityDocuments(entityType, entityId);
|
|
36
|
+
const { documents, hasMore, isLoading, loadMore } = useEntityDocuments(entityType, entityId);
|
|
37
37
|
|
|
38
38
|
// Deep-link to the host Files module (mounted at /files in the app shell).
|
|
39
39
|
// This is a host-absolute path, so it uses the raw navigate — not the
|
|
@@ -82,6 +82,11 @@ export function DocumentsCard({ entityType, entityId }: DocumentsCardProps) {
|
|
|
82
82
|
)}
|
|
83
83
|
</div>
|
|
84
84
|
))}
|
|
85
|
+
{hasMore && (
|
|
86
|
+
<Button variant="outline" size="sm" onClick={loadMore} disabled={isLoading}>
|
|
87
|
+
Load more documents
|
|
88
|
+
</Button>
|
|
89
|
+
)}
|
|
85
90
|
</div>
|
|
86
91
|
) : (
|
|
87
92
|
<div className="text-center py-6">
|
|
@@ -13,14 +13,7 @@ export const OSM_ATTRIBUTION =
|
|
|
13
13
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
|
14
14
|
|
|
15
15
|
export type MapMarkerStatus =
|
|
16
|
-
| '
|
|
17
|
-
| 'in_transit'
|
|
18
|
-
| 'idle'
|
|
19
|
-
| 'delayed'
|
|
20
|
-
| 'delivered'
|
|
21
|
-
| 'origin'
|
|
22
|
-
| 'destination'
|
|
23
|
-
| 'default';
|
|
16
|
+
'active' | 'in_transit' | 'idle' | 'delayed' | 'delivered' | 'origin' | 'destination' | 'default';
|
|
24
17
|
|
|
25
18
|
/**
|
|
26
19
|
* A coordinate is "real" only when it is finite and not the null-island (0,0)
|
|
@@ -67,10 +67,15 @@ const DASHBOARD_QUERY = `
|
|
|
67
67
|
deliveryJobStats {
|
|
68
68
|
total
|
|
69
69
|
pending
|
|
70
|
+
confirmed
|
|
71
|
+
assigned
|
|
72
|
+
pickedUp
|
|
70
73
|
inTransit
|
|
74
|
+
outForDelivery
|
|
71
75
|
delivered
|
|
72
76
|
cancelled
|
|
73
77
|
failed
|
|
78
|
+
returned
|
|
74
79
|
}
|
|
75
80
|
fleetStats {
|
|
76
81
|
totalVehicles
|
|
@@ -94,10 +99,15 @@ const ORDER_ANALYTICS_QUERY = `
|
|
|
94
99
|
query GetOrderAnalytics {
|
|
95
100
|
deliveryJobStats {
|
|
96
101
|
pending
|
|
102
|
+
confirmed
|
|
103
|
+
assigned
|
|
104
|
+
pickedUp
|
|
97
105
|
inTransit
|
|
106
|
+
outForDelivery
|
|
98
107
|
delivered
|
|
99
108
|
cancelled
|
|
100
109
|
failed
|
|
110
|
+
returned
|
|
101
111
|
}
|
|
102
112
|
analytics {
|
|
103
113
|
orderTrend {
|
|
@@ -307,8 +317,15 @@ export function useDashboardMetrics() {
|
|
|
307
317
|
deliveryJobStats: {
|
|
308
318
|
total: number;
|
|
309
319
|
pending: number;
|
|
320
|
+
confirmed: number;
|
|
321
|
+
assigned: number;
|
|
322
|
+
pickedUp: number;
|
|
310
323
|
inTransit: number;
|
|
324
|
+
outForDelivery: number;
|
|
311
325
|
delivered: number;
|
|
326
|
+
cancelled: number;
|
|
327
|
+
failed: number;
|
|
328
|
+
returned: number;
|
|
312
329
|
};
|
|
313
330
|
fleetStats: {
|
|
314
331
|
totalVehicles: number;
|
|
@@ -416,10 +433,15 @@ export function useOrderAnalytics() {
|
|
|
416
433
|
const data = await executeGraphQL<{
|
|
417
434
|
deliveryJobStats: {
|
|
418
435
|
pending: number;
|
|
436
|
+
confirmed: number;
|
|
437
|
+
assigned: number;
|
|
438
|
+
pickedUp: number;
|
|
419
439
|
inTransit: number;
|
|
440
|
+
outForDelivery: number;
|
|
420
441
|
delivered: number;
|
|
421
442
|
cancelled: number;
|
|
422
443
|
failed: number;
|
|
444
|
+
returned: number;
|
|
423
445
|
};
|
|
424
446
|
analytics: {
|
|
425
447
|
orderTrend: Array<{ label: string; value: number }>;
|
|
@@ -429,10 +451,15 @@ export function useOrderAnalytics() {
|
|
|
429
451
|
setOrderData({
|
|
430
452
|
byStatus: [
|
|
431
453
|
{ status: 'pending', count: data.deliveryJobStats.pending },
|
|
454
|
+
{ status: 'confirmed', count: data.deliveryJobStats.confirmed },
|
|
455
|
+
{ status: 'assigned', count: data.deliveryJobStats.assigned },
|
|
456
|
+
{ status: 'picked_up', count: data.deliveryJobStats.pickedUp },
|
|
432
457
|
{ status: 'in_transit', count: data.deliveryJobStats.inTransit },
|
|
458
|
+
{ status: 'out_for_delivery', count: data.deliveryJobStats.outForDelivery },
|
|
433
459
|
{ status: 'delivered', count: data.deliveryJobStats.delivered },
|
|
434
460
|
{ status: 'cancelled', count: data.deliveryJobStats.cancelled },
|
|
435
461
|
{ status: 'failed', count: data.deliveryJobStats.failed },
|
|
462
|
+
{ status: 'returned', count: data.deliveryJobStats.returned },
|
|
436
463
|
],
|
|
437
464
|
byType: [],
|
|
438
465
|
byPriority: [],
|