@burdenoff/microfe-movethewheels 2026.925.1 → 2026.925.2
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/StatusBadge.js +31 -31
- package/dist/constants/index.js +10 -3
- package/dist/hooks/useFleet.js +85 -76
- package/dist/movethewheels/components/StatusBadge.d.ts +3 -1
- package/dist/movethewheels/constants/index.d.ts +1 -0
- package/dist/movethewheels/hooks/useFleet.d.ts +41 -1
- package/dist/movethewheels/types/index.d.ts +16 -38
- package/dist/node_modules/lucide-react/dist/esm/icons/sticky-note.js +11 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/user-x.js +30 -0
- package/dist/pages/DriverDetailsPage.js +702 -421
- package/dist/pages/DriversPage.js +106 -117
- package/dist/pages/FleetPage.js +4 -10
- package/dist/pages/OrderDetailsPage.js +279 -274
- package/dist/pages/RouteDetailsPage.js +264 -252
- package/dist/pages/VehicleDetailsPage.js +403 -243
- package/package.json +1 -1
- package/src/movethewheels/components/StatusBadge.tsx +11 -2
- package/src/movethewheels/constants/index.ts +12 -0
- package/src/movethewheels/hooks/useFleet.ts +163 -44
- package/src/movethewheels/pages/DriverDetailsPage.tsx +516 -156
- package/src/movethewheels/pages/DriversPage.tsx +28 -11
- package/src/movethewheels/pages/FleetPage.tsx +7 -10
- package/src/movethewheels/pages/OrderDetailsPage.tsx +12 -7
- package/src/movethewheels/pages/RouteDetailsPage.tsx +15 -7
- package/src/movethewheels/pages/VehicleDetailsPage.tsx +137 -15
- package/src/movethewheels/types/index.ts +16 -38
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
Edit,
|
|
15
15
|
MapPin,
|
|
16
16
|
Save,
|
|
17
|
+
UserX,
|
|
18
|
+
Truck,
|
|
19
|
+
StickyNote,
|
|
17
20
|
} from 'lucide-react';
|
|
18
21
|
import { PageLayout, LoadingState, ErrorState } from '../components/PageLayout';
|
|
19
22
|
import { StatusBadge } from '../components/StatusBadge';
|
|
@@ -23,34 +26,116 @@ import {
|
|
|
23
26
|
CardHeader,
|
|
24
27
|
CardTitle,
|
|
25
28
|
Button,
|
|
26
|
-
Badge,
|
|
27
|
-
Progress,
|
|
28
29
|
Input,
|
|
29
30
|
Label,
|
|
31
|
+
Textarea,
|
|
32
|
+
Select,
|
|
33
|
+
SelectContent,
|
|
34
|
+
SelectItem,
|
|
35
|
+
SelectTrigger,
|
|
36
|
+
SelectValue,
|
|
30
37
|
} from '../components/ui';
|
|
31
|
-
import {
|
|
32
|
-
|
|
38
|
+
import {
|
|
39
|
+
EmphasisPanel,
|
|
40
|
+
NextSteps,
|
|
41
|
+
PagePurpose,
|
|
42
|
+
ResponsiveDialog,
|
|
43
|
+
ResponsiveDialogContent,
|
|
44
|
+
ResponsiveDialogHeader,
|
|
45
|
+
ResponsiveDialogFooter,
|
|
46
|
+
ResponsiveDialogTitle,
|
|
47
|
+
ResponsiveDialogDescription,
|
|
48
|
+
} from '@burdenoff/fe-libs/ui';
|
|
49
|
+
import { EntityCombobox } from '../components/EntityCombobox';
|
|
50
|
+
import {
|
|
51
|
+
useDriver,
|
|
52
|
+
useFleet,
|
|
53
|
+
useVehicle,
|
|
54
|
+
useVehicles,
|
|
55
|
+
toApiDriverStatus,
|
|
56
|
+
type UpdateDriverInput,
|
|
57
|
+
} from '../hooks/useFleet';
|
|
58
|
+
import { useDebouncedValue } from '../hooks/useDebouncedValue';
|
|
33
59
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
34
60
|
import { formatCurrency, formatDate } from '../utils/formatters';
|
|
35
61
|
import { joinPath } from '../utils/navigation';
|
|
36
62
|
import { FormError } from '../components/FormError';
|
|
37
63
|
import { DocumentsCard } from '../components/DocumentsCard';
|
|
64
|
+
import { DRIVER_STATUS_LABELS } from '../constants';
|
|
65
|
+
import type { DriverStatus } from '../types';
|
|
66
|
+
|
|
67
|
+
const STATUS_SELECT_OPTIONS: Array<{ value: DriverStatus; label: string }> = (
|
|
68
|
+
Object.entries(DRIVER_STATUS_LABELS) as Array<[DriverStatus, string]>
|
|
69
|
+
).map(([value, label]) => ({ value, label }));
|
|
70
|
+
|
|
71
|
+
const toDateInput = (date: Date | undefined): string =>
|
|
72
|
+
date ? date.toISOString().slice(0, 10) : '';
|
|
73
|
+
/** Noon UTC avoids the date shifting a day in either direction across timezones. */
|
|
74
|
+
const fromDateInput = (value: string): string | undefined =>
|
|
75
|
+
value ? `${value}T12:00:00.000Z` : undefined;
|
|
76
|
+
|
|
77
|
+
interface DriverFormState {
|
|
78
|
+
employeeId: string;
|
|
79
|
+
firstName: string;
|
|
80
|
+
lastName: string;
|
|
81
|
+
email: string;
|
|
82
|
+
phone: string;
|
|
83
|
+
licenseNumber: string;
|
|
84
|
+
licenseClass: string;
|
|
85
|
+
licenseExpiry: string;
|
|
86
|
+
status: DriverStatus;
|
|
87
|
+
vehicleId: string;
|
|
88
|
+
rating: string;
|
|
89
|
+
notes: string;
|
|
90
|
+
hireDate: string;
|
|
91
|
+
baseSalary: string;
|
|
92
|
+
bonusYtd: string;
|
|
93
|
+
payCurrency: string;
|
|
94
|
+
onLeaveUntil: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const emptyForm: DriverFormState = {
|
|
98
|
+
employeeId: '',
|
|
99
|
+
firstName: '',
|
|
100
|
+
lastName: '',
|
|
101
|
+
email: '',
|
|
102
|
+
phone: '',
|
|
103
|
+
licenseNumber: '',
|
|
104
|
+
licenseClass: '',
|
|
105
|
+
licenseExpiry: '',
|
|
106
|
+
status: 'available',
|
|
107
|
+
vehicleId: '',
|
|
108
|
+
rating: '',
|
|
109
|
+
notes: '',
|
|
110
|
+
hireDate: '',
|
|
111
|
+
baseSalary: '',
|
|
112
|
+
bonusYtd: '',
|
|
113
|
+
payCurrency: '',
|
|
114
|
+
onLeaveUntil: '',
|
|
115
|
+
};
|
|
38
116
|
|
|
39
117
|
function NewDriverForm({ editId }: { editId?: string }) {
|
|
40
118
|
const { basePath, navigate } = useMoveTheWheels();
|
|
41
119
|
const { createDriver, updateDriver } = useFleet();
|
|
42
|
-
const { driver } = useDriver(editId);
|
|
120
|
+
const { driver, refetch: refetchDriver } = useDriver(editId);
|
|
43
121
|
const isEditing = !!editId;
|
|
44
|
-
const [form, setForm] = useState(
|
|
45
|
-
employeeId: '',
|
|
46
|
-
firstName: '',
|
|
47
|
-
lastName: '',
|
|
48
|
-
email: '',
|
|
49
|
-
phone: '',
|
|
50
|
-
licenseNumber: '',
|
|
51
|
-
});
|
|
122
|
+
const [form, setForm] = useState<DriverFormState>(emptyForm);
|
|
52
123
|
const [submitting, setSubmitting] = useState(false);
|
|
53
124
|
const [error, setError] = useState<string | null>(null);
|
|
125
|
+
const [dismissOpen, setDismissOpen] = useState(false);
|
|
126
|
+
const [dismissReason, setDismissReason] = useState('');
|
|
127
|
+
const [dismissing, setDismissing] = useState(false);
|
|
128
|
+
const [dismissError, setDismissError] = useState<string | null>(null);
|
|
129
|
+
|
|
130
|
+
const [vehicleSearch, setVehicleSearch] = useState('');
|
|
131
|
+
const debouncedVehicleSearch = useDebouncedValue(vehicleSearch);
|
|
132
|
+
const { vehicles, isLoading: vehiclesLoading } = useVehicles({
|
|
133
|
+
limit: 20,
|
|
134
|
+
search: debouncedVehicleSearch || undefined,
|
|
135
|
+
});
|
|
136
|
+
const { vehicle: selectedVehicle, isLoading: selectedVehicleLoading } = useVehicle(
|
|
137
|
+
form.vehicleId || undefined
|
|
138
|
+
);
|
|
54
139
|
|
|
55
140
|
useEffect(() => {
|
|
56
141
|
if (!editId || !driver) return;
|
|
@@ -62,39 +147,59 @@ function NewDriverForm({ editId }: { editId?: string }) {
|
|
|
62
147
|
email: driver.email || '',
|
|
63
148
|
phone: driver.phone || '',
|
|
64
149
|
licenseNumber: driver.licenseNumber || '',
|
|
150
|
+
licenseClass: driver.licenseType || '',
|
|
151
|
+
licenseExpiry: toDateInput(driver.licenseExpiry),
|
|
152
|
+
status: driver.status,
|
|
153
|
+
vehicleId: driver.assignedVehicle || '',
|
|
154
|
+
rating: driver.rating ? String(driver.rating) : '',
|
|
155
|
+
notes: driver.notes || '',
|
|
156
|
+
hireDate: toDateInput(driver.hireDate),
|
|
157
|
+
baseSalary: driver.baseSalary !== undefined ? String(driver.baseSalary) : '',
|
|
158
|
+
bonusYtd: driver.bonusYtd !== undefined ? String(driver.bonusYtd) : '',
|
|
159
|
+
payCurrency: driver.payCurrency || '',
|
|
160
|
+
onLeaveUntil: toDateInput(driver.onLeaveUntil),
|
|
65
161
|
});
|
|
66
162
|
}, [editId, driver]);
|
|
67
163
|
|
|
68
164
|
const goBack = () => navigate?.(joinPath(basePath, 'fleet/drivers'));
|
|
69
165
|
|
|
166
|
+
const buildPayload = (): UpdateDriverInput => ({
|
|
167
|
+
employeeId: form.employeeId.trim(),
|
|
168
|
+
firstName: form.firstName.trim(),
|
|
169
|
+
lastName: form.lastName.trim(),
|
|
170
|
+
email: form.email.trim() || undefined,
|
|
171
|
+
phone: form.phone.trim() || undefined,
|
|
172
|
+
licenseNumber: form.licenseNumber.trim() || undefined,
|
|
173
|
+
licenseClass: form.licenseClass.trim() || undefined,
|
|
174
|
+
licenseExpiry: fromDateInput(form.licenseExpiry),
|
|
175
|
+
status: toApiDriverStatus(form.status),
|
|
176
|
+
vehicleId: form.vehicleId || undefined,
|
|
177
|
+
rating: form.rating ? Number(form.rating) : undefined,
|
|
178
|
+
notes: form.notes.trim() || undefined,
|
|
179
|
+
hireDate: fromDateInput(form.hireDate),
|
|
180
|
+
baseSalary: form.baseSalary ? Number(form.baseSalary) : undefined,
|
|
181
|
+
bonusYtd: form.bonusYtd ? Number(form.bonusYtd) : undefined,
|
|
182
|
+
payCurrency: form.payCurrency.trim() || undefined,
|
|
183
|
+
});
|
|
184
|
+
|
|
70
185
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
71
186
|
e.preventDefault();
|
|
72
187
|
if (!form.employeeId.trim() || !form.firstName.trim() || !form.lastName.trim()) {
|
|
73
188
|
setError('Employee ID, first name and last name are required.');
|
|
74
189
|
return;
|
|
75
190
|
}
|
|
191
|
+
if (!form.phone.trim()) {
|
|
192
|
+
setError('Phone is required so this driver can be reached.');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
76
195
|
setSubmitting(true);
|
|
77
196
|
setError(null);
|
|
78
197
|
try {
|
|
79
198
|
if (editId) {
|
|
80
|
-
const updated = await updateDriver(editId,
|
|
81
|
-
employeeId: form.employeeId.trim(),
|
|
82
|
-
firstName: form.firstName.trim(),
|
|
83
|
-
lastName: form.lastName.trim(),
|
|
84
|
-
email: form.email.trim() || undefined,
|
|
85
|
-
phone: form.phone.trim() || undefined,
|
|
86
|
-
licenseNumber: form.licenseNumber.trim() || undefined,
|
|
87
|
-
});
|
|
199
|
+
const updated = await updateDriver(editId, buildPayload());
|
|
88
200
|
navigate?.(joinPath(basePath, `fleet/drivers/${updated.id}`));
|
|
89
201
|
} else {
|
|
90
|
-
const created = await createDriver(
|
|
91
|
-
employeeId: form.employeeId.trim(),
|
|
92
|
-
firstName: form.firstName.trim(),
|
|
93
|
-
lastName: form.lastName.trim(),
|
|
94
|
-
email: form.email.trim() || undefined,
|
|
95
|
-
phone: form.phone.trim() || undefined,
|
|
96
|
-
licenseNumber: form.licenseNumber.trim() || undefined,
|
|
97
|
-
});
|
|
202
|
+
const created = await createDriver(buildPayload());
|
|
98
203
|
navigate?.(joinPath(basePath, `fleet/drivers/${created.id}`));
|
|
99
204
|
}
|
|
100
205
|
} catch (err) {
|
|
@@ -104,6 +209,31 @@ function NewDriverForm({ editId }: { editId?: string }) {
|
|
|
104
209
|
}
|
|
105
210
|
};
|
|
106
211
|
|
|
212
|
+
const handleDismiss = async () => {
|
|
213
|
+
if (!editId || !dismissReason.trim()) {
|
|
214
|
+
setDismissError('A reason is required to dismiss a driver.');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
setDismissing(true);
|
|
218
|
+
setDismissError(null);
|
|
219
|
+
try {
|
|
220
|
+
await updateDriver(editId, {
|
|
221
|
+
...buildPayload(),
|
|
222
|
+
terminatedAt: new Date().toISOString(),
|
|
223
|
+
terminationReason: dismissReason.trim(),
|
|
224
|
+
});
|
|
225
|
+
setDismissOpen(false);
|
|
226
|
+
await refetchDriver();
|
|
227
|
+
navigate?.(joinPath(basePath, `fleet/drivers/${editId}`));
|
|
228
|
+
} catch (err) {
|
|
229
|
+
setDismissError(err instanceof Error ? err.message : 'Failed to dismiss driver.');
|
|
230
|
+
} finally {
|
|
231
|
+
setDismissing(false);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const alreadyDismissed = isEditing && Boolean(driver?.terminatedAt);
|
|
236
|
+
|
|
107
237
|
return (
|
|
108
238
|
<PageLayout
|
|
109
239
|
title={isEditing ? 'Edit Driver' : 'Add Driver'}
|
|
@@ -128,11 +258,21 @@ function NewDriverForm({ editId }: { editId?: string }) {
|
|
|
128
258
|
}
|
|
129
259
|
>
|
|
130
260
|
<PagePurpose icon={<Users size={18} />}>
|
|
131
|
-
Add a driver so they can be assigned to orders and routes. An employee ID
|
|
132
|
-
on the roster — add
|
|
261
|
+
Add a driver so they can be assigned to orders and routes. An employee ID, name and phone
|
|
262
|
+
get them on the roster — add licensing, a vehicle and HR details to keep the record
|
|
263
|
+
complete.
|
|
133
264
|
</PagePurpose>
|
|
134
265
|
|
|
135
|
-
|
|
266
|
+
{alreadyDismissed && driver?.terminatedAt && (
|
|
267
|
+
<div className="rounded-card border border-status-error-border bg-status-error-bg-subtle p-4 text-sm text-status-error-text">
|
|
268
|
+
<p className="font-medium">
|
|
269
|
+
This driver was dismissed on {formatDate(driver.terminatedAt, 'full')}.
|
|
270
|
+
</p>
|
|
271
|
+
{driver.terminationReason && <p className="mt-1">Reason: {driver.terminationReason}</p>}
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
274
|
+
|
|
275
|
+
<form onSubmit={handleSubmit} className="max-w-3xl space-y-6">
|
|
136
276
|
<FormError message={error} />
|
|
137
277
|
<EmphasisPanel>
|
|
138
278
|
<h2 className="text-base font-semibold text-text-primary mb-4">Driver Details</h2>
|
|
@@ -187,14 +327,39 @@ function NewDriverForm({ editId }: { editId?: string }) {
|
|
|
187
327
|
/>
|
|
188
328
|
</div>
|
|
189
329
|
<div className="space-y-2">
|
|
190
|
-
<Label htmlFor="phone">
|
|
330
|
+
<Label htmlFor="phone">
|
|
331
|
+
Phone <span className="text-status-error-text">*</span>
|
|
332
|
+
</Label>
|
|
191
333
|
<Input
|
|
192
334
|
id="phone"
|
|
193
335
|
value={form.phone}
|
|
194
336
|
onChange={(e) => setForm((prev) => ({ ...prev, phone: e.target.value }))}
|
|
195
337
|
placeholder="+1-555-0100"
|
|
338
|
+
required
|
|
196
339
|
/>
|
|
197
340
|
</div>
|
|
341
|
+
<div className="space-y-2">
|
|
342
|
+
<Label htmlFor="status">Status</Label>
|
|
343
|
+
<Select
|
|
344
|
+
value={form.status}
|
|
345
|
+
onValueChange={(value) =>
|
|
346
|
+
setForm((prev) => ({ ...prev, status: value as DriverStatus }))
|
|
347
|
+
}
|
|
348
|
+
>
|
|
349
|
+
<SelectTrigger id="status">
|
|
350
|
+
<SelectValue />
|
|
351
|
+
</SelectTrigger>
|
|
352
|
+
<SelectContent>
|
|
353
|
+
{STATUS_SELECT_OPTIONS.map((option) => (
|
|
354
|
+
<SelectItem key={option.value} value={option.value}>
|
|
355
|
+
{option.label}
|
|
356
|
+
</SelectItem>
|
|
357
|
+
))}
|
|
358
|
+
</SelectContent>
|
|
359
|
+
</Select>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
198
363
|
<div className="space-y-2">
|
|
199
364
|
<Label htmlFor="licenseNumber">License Number</Label>
|
|
200
365
|
<Input
|
|
@@ -204,10 +369,218 @@ function NewDriverForm({ editId }: { editId?: string }) {
|
|
|
204
369
|
placeholder="D1234567"
|
|
205
370
|
/>
|
|
206
371
|
</div>
|
|
372
|
+
<div className="space-y-2">
|
|
373
|
+
<Label htmlFor="licenseClass">License Class</Label>
|
|
374
|
+
<Input
|
|
375
|
+
id="licenseClass"
|
|
376
|
+
value={form.licenseClass}
|
|
377
|
+
onChange={(e) => setForm((prev) => ({ ...prev, licenseClass: e.target.value }))}
|
|
378
|
+
placeholder="Class A"
|
|
379
|
+
/>
|
|
380
|
+
</div>
|
|
381
|
+
<div className="space-y-2">
|
|
382
|
+
<Label htmlFor="licenseExpiry">License Expiry</Label>
|
|
383
|
+
<Input
|
|
384
|
+
id="licenseExpiry"
|
|
385
|
+
type="date"
|
|
386
|
+
value={form.licenseExpiry}
|
|
387
|
+
onChange={(e) => setForm((prev) => ({ ...prev, licenseExpiry: e.target.value }))}
|
|
388
|
+
/>
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
392
|
+
<div className="space-y-2">
|
|
393
|
+
<Label htmlFor="vehicleSearch">Search vehicles</Label>
|
|
394
|
+
<Input
|
|
395
|
+
id="vehicleSearch"
|
|
396
|
+
value={vehicleSearch}
|
|
397
|
+
onChange={(e) => setVehicleSearch(e.target.value)}
|
|
398
|
+
placeholder="Vehicle number or type"
|
|
399
|
+
/>
|
|
400
|
+
</div>
|
|
401
|
+
<div className="space-y-2">
|
|
402
|
+
<Label htmlFor="vehicle">Assigned Vehicle</Label>
|
|
403
|
+
<EntityCombobox
|
|
404
|
+
id="vehicle"
|
|
405
|
+
value={form.vehicleId}
|
|
406
|
+
onChange={(value) => setForm((prev) => ({ ...prev, vehicleId: value }))}
|
|
407
|
+
options={vehicles}
|
|
408
|
+
loading={vehiclesLoading}
|
|
409
|
+
getId={(vehicle) => vehicle.id}
|
|
410
|
+
getLabel={(vehicle) => `${vehicle.vehicleNumber} (${vehicle.type})`}
|
|
411
|
+
renderOption={(vehicle) => (
|
|
412
|
+
<>
|
|
413
|
+
<Truck size={14} className="mr-2 inline text-text-secondary" />
|
|
414
|
+
{vehicle.vehicleNumber} ({vehicle.type})
|
|
415
|
+
</>
|
|
416
|
+
)}
|
|
417
|
+
selectedOption={selectedVehicle}
|
|
418
|
+
selectedLoading={selectedVehicleLoading}
|
|
419
|
+
placeholder="Unassigned"
|
|
420
|
+
emptyText="No vehicles match that search."
|
|
421
|
+
/>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
425
|
+
<div className="space-y-2">
|
|
426
|
+
<Label htmlFor="rating">Rating (0–5)</Label>
|
|
427
|
+
<Input
|
|
428
|
+
id="rating"
|
|
429
|
+
type="number"
|
|
430
|
+
min={0}
|
|
431
|
+
max={5}
|
|
432
|
+
step={0.1}
|
|
433
|
+
value={form.rating}
|
|
434
|
+
onChange={(e) => setForm((prev) => ({ ...prev, rating: e.target.value }))}
|
|
435
|
+
placeholder="4.8"
|
|
436
|
+
/>
|
|
437
|
+
</div>
|
|
438
|
+
<div className="space-y-2">
|
|
439
|
+
<Label htmlFor="hireDate">Hire Date</Label>
|
|
440
|
+
<Input
|
|
441
|
+
id="hireDate"
|
|
442
|
+
type="date"
|
|
443
|
+
value={form.hireDate}
|
|
444
|
+
onChange={(e) => setForm((prev) => ({ ...prev, hireDate: e.target.value }))}
|
|
445
|
+
/>
|
|
446
|
+
</div>
|
|
447
|
+
<div className="space-y-2">
|
|
448
|
+
<Label htmlFor="onLeaveUntil">On Leave Until</Label>
|
|
449
|
+
<Input
|
|
450
|
+
id="onLeaveUntil"
|
|
451
|
+
type="date"
|
|
452
|
+
value={form.onLeaveUntil}
|
|
453
|
+
onChange={(e) => setForm((prev) => ({ ...prev, onLeaveUntil: e.target.value }))}
|
|
454
|
+
/>
|
|
455
|
+
</div>
|
|
207
456
|
</div>
|
|
208
457
|
</div>
|
|
209
458
|
</EmphasisPanel>
|
|
459
|
+
|
|
460
|
+
<Card>
|
|
461
|
+
<CardHeader>
|
|
462
|
+
<CardTitle className="flex items-center gap-2">
|
|
463
|
+
<DollarSign size={18} /> Compensation
|
|
464
|
+
</CardTitle>
|
|
465
|
+
</CardHeader>
|
|
466
|
+
<CardContent>
|
|
467
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
468
|
+
<div className="space-y-2">
|
|
469
|
+
<Label htmlFor="baseSalary">Base Salary (annual)</Label>
|
|
470
|
+
<Input
|
|
471
|
+
id="baseSalary"
|
|
472
|
+
type="number"
|
|
473
|
+
min={0}
|
|
474
|
+
step={0.01}
|
|
475
|
+
value={form.baseSalary}
|
|
476
|
+
onChange={(e) => setForm((prev) => ({ ...prev, baseSalary: e.target.value }))}
|
|
477
|
+
placeholder="55000"
|
|
478
|
+
/>
|
|
479
|
+
</div>
|
|
480
|
+
<div className="space-y-2">
|
|
481
|
+
<Label htmlFor="bonusYtd">Bonus (YTD)</Label>
|
|
482
|
+
<Input
|
|
483
|
+
id="bonusYtd"
|
|
484
|
+
type="number"
|
|
485
|
+
min={0}
|
|
486
|
+
step={0.01}
|
|
487
|
+
value={form.bonusYtd}
|
|
488
|
+
onChange={(e) => setForm((prev) => ({ ...prev, bonusYtd: e.target.value }))}
|
|
489
|
+
placeholder="1200"
|
|
490
|
+
/>
|
|
491
|
+
</div>
|
|
492
|
+
<div className="space-y-2">
|
|
493
|
+
<Label htmlFor="payCurrency">Currency</Label>
|
|
494
|
+
<Input
|
|
495
|
+
id="payCurrency"
|
|
496
|
+
value={form.payCurrency}
|
|
497
|
+
onChange={(e) => setForm((prev) => ({ ...prev, payCurrency: e.target.value }))}
|
|
498
|
+
placeholder="USD"
|
|
499
|
+
maxLength={10}
|
|
500
|
+
/>
|
|
501
|
+
</div>
|
|
502
|
+
</div>
|
|
503
|
+
</CardContent>
|
|
504
|
+
</Card>
|
|
505
|
+
|
|
506
|
+
<Card>
|
|
507
|
+
<CardHeader>
|
|
508
|
+
<CardTitle className="flex items-center gap-2">
|
|
509
|
+
<StickyNote size={18} /> HR Notes
|
|
510
|
+
</CardTitle>
|
|
511
|
+
</CardHeader>
|
|
512
|
+
<CardContent>
|
|
513
|
+
<Textarea
|
|
514
|
+
value={form.notes}
|
|
515
|
+
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
|
|
516
|
+
placeholder="Performance notes, disciplinary history, anything worth tracking for this driver..."
|
|
517
|
+
rows={4}
|
|
518
|
+
/>
|
|
519
|
+
</CardContent>
|
|
520
|
+
</Card>
|
|
521
|
+
|
|
522
|
+
{isEditing && !alreadyDismissed && (
|
|
523
|
+
<Card className="border-status-error-border">
|
|
524
|
+
<CardHeader>
|
|
525
|
+
<CardTitle className="flex items-center gap-2 text-status-error-text">
|
|
526
|
+
<UserX size={18} /> Dismiss Driver
|
|
527
|
+
</CardTitle>
|
|
528
|
+
</CardHeader>
|
|
529
|
+
<CardContent>
|
|
530
|
+
<p className="mb-3 text-sm text-text-secondary">
|
|
531
|
+
Dismissing a driver marks them as no longer employed. Their delivery and route
|
|
532
|
+
history stays visible; they can no longer be assigned new work. This cannot be
|
|
533
|
+
undone from here.
|
|
534
|
+
</p>
|
|
535
|
+
<Button
|
|
536
|
+
type="button"
|
|
537
|
+
variant="outline"
|
|
538
|
+
className="border-status-error-border text-status-error-text hover:bg-status-error-bg-subtle"
|
|
539
|
+
onClick={() => setDismissOpen(true)}
|
|
540
|
+
>
|
|
541
|
+
<UserX size={16} /> Dismiss this driver
|
|
542
|
+
</Button>
|
|
543
|
+
</CardContent>
|
|
544
|
+
</Card>
|
|
545
|
+
)}
|
|
210
546
|
</form>
|
|
547
|
+
|
|
548
|
+
<ResponsiveDialog open={dismissOpen} onOpenChange={setDismissOpen}>
|
|
549
|
+
<ResponsiveDialogContent>
|
|
550
|
+
<ResponsiveDialogHeader>
|
|
551
|
+
<ResponsiveDialogTitle>Dismiss this driver?</ResponsiveDialogTitle>
|
|
552
|
+
<ResponsiveDialogDescription>
|
|
553
|
+
This records the driver as dismissed and requires a reason. It cannot be undone from
|
|
554
|
+
here.
|
|
555
|
+
</ResponsiveDialogDescription>
|
|
556
|
+
</ResponsiveDialogHeader>
|
|
557
|
+
<div className="space-y-2 px-1">
|
|
558
|
+
<Label htmlFor="dismissReason">
|
|
559
|
+
Reason <span className="text-status-error-text">*</span>
|
|
560
|
+
</Label>
|
|
561
|
+
<Textarea
|
|
562
|
+
id="dismissReason"
|
|
563
|
+
value={dismissReason}
|
|
564
|
+
onChange={(e) => setDismissReason(e.target.value)}
|
|
565
|
+
placeholder="e.g. Repeated policy violations, voluntary resignation, performance..."
|
|
566
|
+
rows={3}
|
|
567
|
+
/>
|
|
568
|
+
<FormError message={dismissError} />
|
|
569
|
+
</div>
|
|
570
|
+
<ResponsiveDialogFooter>
|
|
571
|
+
<Button variant="outline" onClick={() => setDismissOpen(false)} disabled={dismissing}>
|
|
572
|
+
Cancel
|
|
573
|
+
</Button>
|
|
574
|
+
<Button
|
|
575
|
+
className="bg-status-error-text text-text-inverse hover:opacity-90"
|
|
576
|
+
onClick={handleDismiss}
|
|
577
|
+
disabled={dismissing}
|
|
578
|
+
>
|
|
579
|
+
{dismissing ? 'Dismissing...' : 'Confirm dismissal'}
|
|
580
|
+
</Button>
|
|
581
|
+
</ResponsiveDialogFooter>
|
|
582
|
+
</ResponsiveDialogContent>
|
|
583
|
+
</ResponsiveDialog>
|
|
211
584
|
</PageLayout>
|
|
212
585
|
);
|
|
213
586
|
}
|
|
@@ -217,6 +590,7 @@ export default function DriverDetailsPage() {
|
|
|
217
590
|
const isEditRoute = useLocation().pathname.endsWith('/edit');
|
|
218
591
|
const { basePath, navigate } = useMoveTheWheels();
|
|
219
592
|
const { driver, isLoading, error, refetch } = useDriver(driverId);
|
|
593
|
+
const { vehicle: assignedVehicle } = useVehicle(driver?.assignedVehicle);
|
|
220
594
|
|
|
221
595
|
const goBack = () => navigate?.(joinPath(basePath, 'fleet/drivers'));
|
|
222
596
|
const goTo = (path: string) => navigate?.(joinPath(basePath, path));
|
|
@@ -226,6 +600,13 @@ export default function DriverDetailsPage() {
|
|
|
226
600
|
if (isLoading) return <LoadingState message="Loading driver details..." />;
|
|
227
601
|
if (error || !driver) return <ErrorState title="Driver not found" onRetry={refetch} />;
|
|
228
602
|
|
|
603
|
+
const isDismissed = Boolean(driver.terminatedAt);
|
|
604
|
+
const onLeaveUntil =
|
|
605
|
+
driver.onLeaveUntil && driver.onLeaveUntil.getTime() > new Date().getTime()
|
|
606
|
+
? driver.onLeaveUntil
|
|
607
|
+
: undefined;
|
|
608
|
+
const hasCompensation = driver.baseSalary !== undefined || driver.bonusYtd !== undefined;
|
|
609
|
+
|
|
229
610
|
return (
|
|
230
611
|
<PageLayout
|
|
231
612
|
title={driver.fullName}
|
|
@@ -244,20 +625,34 @@ export default function DriverDetailsPage() {
|
|
|
244
625
|
</div>
|
|
245
626
|
}
|
|
246
627
|
headerContent={
|
|
247
|
-
<div className="flex items-center gap-4">
|
|
628
|
+
<div className="flex flex-wrap items-center gap-4">
|
|
248
629
|
<StatusBadge status={driver.status} type="driver" />
|
|
249
|
-
|
|
250
|
-
<
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
630
|
+
{isDismissed && (
|
|
631
|
+
<StatusBadge
|
|
632
|
+
status="dismissed"
|
|
633
|
+
label="Dismissed"
|
|
634
|
+
customColors={{ bg: 'bg-status-error-bg-subtle', text: 'text-status-error-text' }}
|
|
635
|
+
/>
|
|
636
|
+
)}
|
|
637
|
+
{onLeaveUntil && !isDismissed && (
|
|
638
|
+
<StatusBadge
|
|
639
|
+
status="on_leave"
|
|
640
|
+
label={`On leave until ${formatDate(onLeaveUntil, 'short')}`}
|
|
641
|
+
customColors={{ bg: 'bg-status-warning-bg-subtle', text: 'text-status-warning-text' }}
|
|
642
|
+
/>
|
|
643
|
+
)}
|
|
644
|
+
{driver.rating > 0 && (
|
|
645
|
+
<div className="flex items-center gap-1">
|
|
646
|
+
<Star size={16} className="text-status-warning-text fill-current" />
|
|
647
|
+
<span className="font-medium">{driver.rating.toFixed(1)}</span>
|
|
648
|
+
</div>
|
|
649
|
+
)}
|
|
254
650
|
</div>
|
|
255
651
|
}
|
|
256
652
|
>
|
|
257
653
|
<PagePurpose icon={<Users size={18} />}>
|
|
258
|
-
A driver's full profile —
|
|
259
|
-
|
|
260
|
-
an order or route.
|
|
654
|
+
A driver's full profile — licensing, assigned vehicle, documents and HR record. Use it
|
|
655
|
+
to confirm someone is road-ready and compliant before assigning them an order or route.
|
|
261
656
|
</PagePurpose>
|
|
262
657
|
|
|
263
658
|
<NextSteps
|
|
@@ -280,75 +675,24 @@ export default function DriverDetailsPage() {
|
|
|
280
675
|
{
|
|
281
676
|
id: 'edit',
|
|
282
677
|
label: 'Update driver record',
|
|
283
|
-
description: 'Refresh documents,
|
|
678
|
+
description: 'Refresh documents, license or HR details.',
|
|
284
679
|
icon: <Edit size={16} />,
|
|
285
680
|
onClick: () => {},
|
|
286
681
|
},
|
|
287
682
|
]}
|
|
288
683
|
/>
|
|
289
684
|
|
|
685
|
+
{isDismissed && (
|
|
686
|
+
<div className="rounded-card border border-status-error-border bg-status-error-bg-subtle p-4 text-sm text-status-error-text">
|
|
687
|
+
<p className="font-medium">
|
|
688
|
+
Dismissed {driver.terminatedAt && formatDate(driver.terminatedAt, 'full')}
|
|
689
|
+
</p>
|
|
690
|
+
{driver.terminationReason && <p className="mt-1">Reason: {driver.terminationReason}</p>}
|
|
691
|
+
</div>
|
|
692
|
+
)}
|
|
693
|
+
|
|
290
694
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
291
695
|
<div className="lg:col-span-2 space-y-6">
|
|
292
|
-
{/* Ratings — the page's single emphasis zone */}
|
|
293
|
-
<EmphasisPanel>
|
|
294
|
-
<h2 className="flex items-center gap-2 text-base font-semibold text-text-primary mb-4">
|
|
295
|
-
<Star size={18} /> Ratings
|
|
296
|
-
</h2>
|
|
297
|
-
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
298
|
-
{[
|
|
299
|
-
{ label: 'Punctuality', value: driver.ratings.punctuality },
|
|
300
|
-
{ label: 'Communication', value: driver.ratings.communication },
|
|
301
|
-
{ label: 'Handling', value: driver.ratings.carefulHandling },
|
|
302
|
-
{ label: 'Professionalism', value: driver.ratings.professionalism },
|
|
303
|
-
{ label: 'On-Time Delivery', value: driver.ratings.onTimeDelivery },
|
|
304
|
-
].map((rating) => (
|
|
305
|
-
<div key={rating.label}>
|
|
306
|
-
<p className="text-sm text-text-secondary">{rating.label}</p>
|
|
307
|
-
<div className="flex items-center gap-2 mt-1">
|
|
308
|
-
<Progress value={rating.value * 20} className="flex-1 h-2" />
|
|
309
|
-
<span className="text-sm font-medium">{rating.value.toFixed(1)}</span>
|
|
310
|
-
</div>
|
|
311
|
-
</div>
|
|
312
|
-
))}
|
|
313
|
-
</div>
|
|
314
|
-
</EmphasisPanel>
|
|
315
|
-
|
|
316
|
-
{/* Working Hours */}
|
|
317
|
-
<Card>
|
|
318
|
-
<CardHeader>
|
|
319
|
-
<CardTitle className="flex items-center gap-2">
|
|
320
|
-
<Clock size={18} /> Working Hours
|
|
321
|
-
</CardTitle>
|
|
322
|
-
</CardHeader>
|
|
323
|
-
<CardContent>
|
|
324
|
-
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
325
|
-
<div>
|
|
326
|
-
<p className="text-sm text-text-secondary">Shift</p>
|
|
327
|
-
<p className="font-medium">
|
|
328
|
-
{driver.workingHours.startTime} - {driver.workingHours.endTime}
|
|
329
|
-
</p>
|
|
330
|
-
</div>
|
|
331
|
-
<div>
|
|
332
|
-
<p className="text-sm text-text-secondary">Working Days</p>
|
|
333
|
-
<p className="font-medium">{driver.workingHours.workingDays.length} days/week</p>
|
|
334
|
-
</div>
|
|
335
|
-
<div>
|
|
336
|
-
<p className="text-sm text-text-secondary">Hours Today</p>
|
|
337
|
-
<p className="font-medium">
|
|
338
|
-
{driver.workingHours.currentHoursToday}h / {driver.workingHours.maxHoursPerDay}h
|
|
339
|
-
</p>
|
|
340
|
-
</div>
|
|
341
|
-
<div>
|
|
342
|
-
<p className="text-sm text-text-secondary">Hours This Week</p>
|
|
343
|
-
<p className="font-medium">
|
|
344
|
-
{driver.workingHours.currentHoursThisWeek}h /{' '}
|
|
345
|
-
{driver.workingHours.maxHoursPerWeek}h
|
|
346
|
-
</p>
|
|
347
|
-
</div>
|
|
348
|
-
</div>
|
|
349
|
-
</CardContent>
|
|
350
|
-
</Card>
|
|
351
|
-
|
|
352
696
|
{/* License — real driver credential fields */}
|
|
353
697
|
<Card>
|
|
354
698
|
<CardHeader>
|
|
@@ -372,60 +716,95 @@ export default function DriverDetailsPage() {
|
|
|
372
716
|
</CardContent>
|
|
373
717
|
</Card>
|
|
374
718
|
|
|
719
|
+
{driver.notes && (
|
|
720
|
+
<Card>
|
|
721
|
+
<CardHeader>
|
|
722
|
+
<CardTitle className="flex items-center gap-2">
|
|
723
|
+
<StickyNote size={18} /> HR Notes
|
|
724
|
+
</CardTitle>
|
|
725
|
+
</CardHeader>
|
|
726
|
+
<CardContent>
|
|
727
|
+
<p className="text-sm whitespace-pre-wrap">{driver.notes}</p>
|
|
728
|
+
</CardContent>
|
|
729
|
+
</Card>
|
|
730
|
+
)}
|
|
731
|
+
|
|
375
732
|
{/* Documents — real files via wspace-files-svc */}
|
|
376
733
|
<DocumentsCard entityType="driver" entityId={driver.id} />
|
|
377
734
|
</div>
|
|
378
735
|
|
|
379
736
|
<div className="space-y-6">
|
|
380
|
-
{/*
|
|
737
|
+
{/* Assigned Vehicle */}
|
|
738
|
+
<Card>
|
|
739
|
+
<CardHeader>
|
|
740
|
+
<CardTitle className="flex items-center gap-2">
|
|
741
|
+
<Truck size={18} /> Assigned Vehicle
|
|
742
|
+
</CardTitle>
|
|
743
|
+
</CardHeader>
|
|
744
|
+
<CardContent>
|
|
745
|
+
{driver.assignedVehicle ? (
|
|
746
|
+
assignedVehicle ? (
|
|
747
|
+
<button
|
|
748
|
+
type="button"
|
|
749
|
+
className="text-sm font-medium text-primary hover:underline"
|
|
750
|
+
onClick={() => goTo(`fleet/vehicles/${assignedVehicle.id}`)}
|
|
751
|
+
>
|
|
752
|
+
{assignedVehicle.vehicleNumber} ({assignedVehicle.type})
|
|
753
|
+
</button>
|
|
754
|
+
) : (
|
|
755
|
+
<p className="text-sm text-text-secondary">Loading…</p>
|
|
756
|
+
)
|
|
757
|
+
) : (
|
|
758
|
+
<p className="text-sm text-text-secondary">No vehicle assigned</p>
|
|
759
|
+
)}
|
|
760
|
+
</CardContent>
|
|
761
|
+
</Card>
|
|
762
|
+
|
|
763
|
+
{/* Record */}
|
|
381
764
|
<Card>
|
|
382
765
|
<CardHeader>
|
|
383
|
-
<CardTitle>
|
|
766
|
+
<CardTitle>Record</CardTitle>
|
|
384
767
|
</CardHeader>
|
|
385
768
|
<CardContent className="space-y-4">
|
|
386
769
|
<div>
|
|
387
|
-
<p className="text-sm text-text-secondary">
|
|
388
|
-
<p className="
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
<p className="text-sm text-text-secondary">Today's Deliveries</p>
|
|
392
|
-
<p className="text-2xl font-bold">{driver.todayOrders}</p>
|
|
393
|
-
</div>
|
|
394
|
-
<div>
|
|
395
|
-
<p className="text-sm text-text-secondary">Join Date</p>
|
|
396
|
-
<p className="font-medium">{formatDate(driver.joinDate)}</p>
|
|
770
|
+
<p className="text-sm text-text-secondary">Hire Date</p>
|
|
771
|
+
<p className="font-medium">
|
|
772
|
+
{driver.hireDate ? formatDate(driver.hireDate) : 'Not recorded'}
|
|
773
|
+
</p>
|
|
397
774
|
</div>
|
|
398
775
|
</CardContent>
|
|
399
776
|
</Card>
|
|
400
777
|
|
|
401
|
-
{/*
|
|
778
|
+
{/* Compensation — real fields, no fabricated earnings breakdown */}
|
|
402
779
|
<Card>
|
|
403
780
|
<CardHeader>
|
|
404
781
|
<CardTitle className="flex items-center gap-2">
|
|
405
|
-
<DollarSign size={18} />
|
|
782
|
+
<DollarSign size={18} /> Compensation
|
|
406
783
|
</CardTitle>
|
|
407
784
|
</CardHeader>
|
|
408
785
|
<CardContent className="space-y-3">
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
786
|
+
{hasCompensation ? (
|
|
787
|
+
<>
|
|
788
|
+
{driver.baseSalary !== undefined && (
|
|
789
|
+
<div>
|
|
790
|
+
<p className="text-sm text-text-secondary">Base Salary</p>
|
|
791
|
+
<p className="font-medium">
|
|
792
|
+
{formatCurrency(driver.baseSalary, driver.payCurrency || undefined)}/year
|
|
793
|
+
</p>
|
|
794
|
+
</div>
|
|
795
|
+
)}
|
|
796
|
+
{driver.bonusYtd !== undefined && (
|
|
797
|
+
<div>
|
|
798
|
+
<p className="text-sm text-text-secondary">Bonus (YTD)</p>
|
|
799
|
+
<p className="font-medium">
|
|
800
|
+
{formatCurrency(driver.bonusYtd, driver.payCurrency || undefined)}
|
|
801
|
+
</p>
|
|
802
|
+
</div>
|
|
803
|
+
)}
|
|
804
|
+
</>
|
|
805
|
+
) : (
|
|
806
|
+
<p className="text-sm text-text-secondary">Not recorded</p>
|
|
807
|
+
)}
|
|
429
808
|
</CardContent>
|
|
430
809
|
</Card>
|
|
431
810
|
|
|
@@ -447,25 +826,6 @@ export default function DriverDetailsPage() {
|
|
|
447
826
|
</CardContent>
|
|
448
827
|
</Card>
|
|
449
828
|
)}
|
|
450
|
-
|
|
451
|
-
{/* Compliance */}
|
|
452
|
-
<Card>
|
|
453
|
-
<CardHeader>
|
|
454
|
-
<CardTitle>Compliance Status</CardTitle>
|
|
455
|
-
</CardHeader>
|
|
456
|
-
<CardContent className="space-y-2">
|
|
457
|
-
{Object.entries(driver.compliance)
|
|
458
|
-
.filter(([k]) => k !== 'lastUpdate')
|
|
459
|
-
.map(([key, value]) => (
|
|
460
|
-
<div key={key} className="flex items-center justify-between">
|
|
461
|
-
<span className="text-sm capitalize">{key.replace(/([A-Z])/g, ' $1')}</span>
|
|
462
|
-
<Badge variant={value ? 'default' : 'destructive'}>
|
|
463
|
-
{value ? 'Compliant' : 'Non-compliant'}
|
|
464
|
-
</Badge>
|
|
465
|
-
</div>
|
|
466
|
-
))}
|
|
467
|
-
</CardContent>
|
|
468
|
-
</Card>
|
|
469
829
|
</div>
|
|
470
830
|
</div>
|
|
471
831
|
</PageLayout>
|