@burdenoff/microfe-movethewheels 2026.830.4 → 2026.918.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/AssignmentSuggestions.js +135 -0
- package/dist/components/FleetAlertsPanel.js +114 -0
- package/dist/components/VehicleServiceCard.js +176 -0
- package/dist/hooks/useFleet.js +4 -3
- package/dist/hooks/useFleetIntelligence.js +91 -0
- package/dist/movethewheels/components/AssignmentSuggestions.d.ts +17 -0
- package/dist/movethewheels/components/FleetAlertsPanel.d.ts +14 -0
- package/dist/movethewheels/components/VehicleServiceCard.d.ts +10 -0
- package/dist/movethewheels/hooks/useFleetIntelligence.d.ts +64 -0
- package/dist/movethewheels/types/index.d.ts +2 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/sparkles.js +26 -0
- package/dist/pages/FleetPage.js +156 -146
- package/dist/pages/RouteDetailsPage.js +202 -184
- package/dist/pages/VehicleDetailsPage.js +149 -131
- package/dist/utils/graphqlClient.js +14 -5
- package/package.json +1 -1
- package/src/movethewheels/components/AssignmentSuggestions.tsx +234 -0
- package/src/movethewheels/components/FleetAlertsPanel.tsx +148 -0
- package/src/movethewheels/components/VehicleServiceCard.tsx +220 -0
- package/src/movethewheels/hooks/useFleet.ts +12 -2
- package/src/movethewheels/hooks/useFleetIntelligence.ts +226 -0
- package/src/movethewheels/pages/FleetPage.tsx +21 -0
- package/src/movethewheels/pages/RouteDetailsPage.tsx +33 -1
- package/src/movethewheels/pages/VehicleDetailsPage.tsx +27 -0
- package/src/movethewheels/types/index.ts +2 -0
- package/src/movethewheels/utils/graphqlClient.ts +20 -1
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AssignmentSuggestions
|
|
3
|
+
*
|
|
4
|
+
* Ranked vehicles and drivers that can take a route right now, each with the
|
|
5
|
+
* reasons behind the ranking, and a one-click assign. Ranking and eligibility
|
|
6
|
+
* (in service, licence valid, not already on a live route) are decided
|
|
7
|
+
* server-side by `assignmentSuggestions`; the server also re-checks the same
|
|
8
|
+
* guardrails when the assignment is saved, so a stale suggestion is rejected
|
|
9
|
+
* with a clear message rather than silently applied.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useState } from 'react';
|
|
13
|
+
import { Sparkles } from 'lucide-react';
|
|
14
|
+
|
|
15
|
+
import { FormError } from './FormError';
|
|
16
|
+
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from './ui';
|
|
17
|
+
|
|
18
|
+
import type { AssignmentSuggestions as Suggestions } from '../hooks/useFleetIntelligence';
|
|
19
|
+
|
|
20
|
+
interface AssignmentSuggestionsProps {
|
|
21
|
+
suggestions: Suggestions;
|
|
22
|
+
isLoading: boolean;
|
|
23
|
+
error: Error | null;
|
|
24
|
+
onRetry: () => void;
|
|
25
|
+
assignedVehicleId?: string;
|
|
26
|
+
assignedDriverId?: string;
|
|
27
|
+
onAssign: (patch: { vehicleId?: string; driverId?: string }) => Promise<void>;
|
|
28
|
+
t: (key: string, fallback: string) => string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RowProps {
|
|
32
|
+
id: string;
|
|
33
|
+
label: string;
|
|
34
|
+
reasons: string[];
|
|
35
|
+
isBest: boolean;
|
|
36
|
+
isAssigned: boolean;
|
|
37
|
+
isPending: boolean;
|
|
38
|
+
disabled: boolean;
|
|
39
|
+
onAssign: () => void;
|
|
40
|
+
t: (key: string, fallback: string) => string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function SuggestionRow({
|
|
44
|
+
id,
|
|
45
|
+
label,
|
|
46
|
+
reasons,
|
|
47
|
+
isBest,
|
|
48
|
+
isAssigned,
|
|
49
|
+
isPending,
|
|
50
|
+
disabled,
|
|
51
|
+
onAssign,
|
|
52
|
+
t,
|
|
53
|
+
}: RowProps) {
|
|
54
|
+
return (
|
|
55
|
+
<li className="flex items-start justify-between gap-3 py-3" data-suggestion-id={id}>
|
|
56
|
+
<div className="flex-1 min-w-0 space-y-1">
|
|
57
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
58
|
+
<span className="text-sm font-medium break-words">{label}</span>
|
|
59
|
+
{isBest && (
|
|
60
|
+
<Badge variant="secondary">
|
|
61
|
+
{t('movethewheels.routes.suggest.best', 'Best match')}
|
|
62
|
+
</Badge>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
<ul className="space-y-0.5">
|
|
66
|
+
{reasons.map((reason) => (
|
|
67
|
+
<li key={reason} className="text-xs text-text-secondary">
|
|
68
|
+
{reason}
|
|
69
|
+
</li>
|
|
70
|
+
))}
|
|
71
|
+
</ul>
|
|
72
|
+
</div>
|
|
73
|
+
<Button
|
|
74
|
+
size="sm"
|
|
75
|
+
className="shrink-0"
|
|
76
|
+
variant={isAssigned ? 'ghost' : 'outline'}
|
|
77
|
+
disabled={disabled || isAssigned}
|
|
78
|
+
onClick={onAssign}
|
|
79
|
+
>
|
|
80
|
+
{isAssigned
|
|
81
|
+
? t('movethewheels.routes.suggest.assigned', 'Assigned')
|
|
82
|
+
: isPending
|
|
83
|
+
? t('movethewheels.routes.suggest.assigning', 'Assigning…')
|
|
84
|
+
: t('movethewheels.routes.suggest.assign', 'Assign')}
|
|
85
|
+
</Button>
|
|
86
|
+
</li>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function AssignmentSuggestions({
|
|
91
|
+
suggestions,
|
|
92
|
+
isLoading,
|
|
93
|
+
error,
|
|
94
|
+
onRetry,
|
|
95
|
+
assignedVehicleId,
|
|
96
|
+
assignedDriverId,
|
|
97
|
+
onAssign,
|
|
98
|
+
t,
|
|
99
|
+
}: AssignmentSuggestionsProps) {
|
|
100
|
+
const [pendingId, setPendingId] = useState<string | null>(null);
|
|
101
|
+
const [assignError, setAssignError] = useState<string | null>(null);
|
|
102
|
+
const title = t('movethewheels.routes.suggest.title', 'Suggested assignment');
|
|
103
|
+
|
|
104
|
+
const assign = async (id: string, patch: { vehicleId?: string; driverId?: string }) => {
|
|
105
|
+
setPendingId(id);
|
|
106
|
+
setAssignError(null);
|
|
107
|
+
try {
|
|
108
|
+
await onAssign(patch);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
setAssignError(e instanceof Error ? e.message : 'Could not assign.');
|
|
111
|
+
} finally {
|
|
112
|
+
setPendingId(null);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (error && !isLoading) {
|
|
117
|
+
return (
|
|
118
|
+
<Card className="border-status-error-border">
|
|
119
|
+
<CardContent className="p-6 text-center space-y-3">
|
|
120
|
+
<p className="text-status-error-text font-medium">
|
|
121
|
+
{t('movethewheels.routes.suggest.error', "Couldn't load suggestions")}
|
|
122
|
+
</p>
|
|
123
|
+
<p className="text-sm text-text-secondary">{error.message}</p>
|
|
124
|
+
<Button variant="outline" onClick={onRetry}>
|
|
125
|
+
{t('movethewheels.fleet.error.retry', 'Retry')}
|
|
126
|
+
</Button>
|
|
127
|
+
</CardContent>
|
|
128
|
+
</Card>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isLoading) {
|
|
133
|
+
return (
|
|
134
|
+
<Card>
|
|
135
|
+
<CardHeader>
|
|
136
|
+
<CardTitle>{title}</CardTitle>
|
|
137
|
+
</CardHeader>
|
|
138
|
+
<CardContent className="space-y-3">
|
|
139
|
+
<Skeleton className="h-14 w-full" />
|
|
140
|
+
<Skeleton className="h-14 w-full" />
|
|
141
|
+
</CardContent>
|
|
142
|
+
</Card>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const { vehicles, drivers } = suggestions;
|
|
147
|
+
const nothing = vehicles.length === 0 && drivers.length === 0;
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<Card>
|
|
151
|
+
<CardHeader>
|
|
152
|
+
<CardTitle className="flex items-center gap-2">
|
|
153
|
+
<Sparkles size={18} aria-hidden="true" />
|
|
154
|
+
{title}
|
|
155
|
+
</CardTitle>
|
|
156
|
+
</CardHeader>
|
|
157
|
+
<CardContent className="space-y-4">
|
|
158
|
+
<FormError message={assignError} />
|
|
159
|
+
|
|
160
|
+
{nothing ? (
|
|
161
|
+
<p className="text-sm text-text-secondary">
|
|
162
|
+
{t(
|
|
163
|
+
'movethewheels.routes.suggest.none',
|
|
164
|
+
'No vehicle or driver is free right now. Vehicles must be in service and drivers must hold a valid licence and not be on another live route.'
|
|
165
|
+
)}
|
|
166
|
+
</p>
|
|
167
|
+
) : (
|
|
168
|
+
<>
|
|
169
|
+
<section aria-label={t('movethewheels.routes.suggest.vehicles', 'Vehicles')}>
|
|
170
|
+
<h4 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
|
171
|
+
{t('movethewheels.routes.suggest.vehicles', 'Vehicles')}
|
|
172
|
+
</h4>
|
|
173
|
+
{vehicles.length === 0 ? (
|
|
174
|
+
<p className="py-3 text-sm text-text-secondary">
|
|
175
|
+
{t('movethewheels.routes.suggest.noVehicles', 'No vehicle is free right now.')}
|
|
176
|
+
</p>
|
|
177
|
+
) : (
|
|
178
|
+
<ul className="divide-y">
|
|
179
|
+
{vehicles.map((s, index) => (
|
|
180
|
+
<SuggestionRow
|
|
181
|
+
key={s.vehicle.id}
|
|
182
|
+
id={s.vehicle.id}
|
|
183
|
+
label={[
|
|
184
|
+
s.vehicle.vehicleNumber,
|
|
185
|
+
[s.vehicle.make, s.vehicle.model].filter(Boolean).join(' '),
|
|
186
|
+
]
|
|
187
|
+
.filter(Boolean)
|
|
188
|
+
.join(' — ')}
|
|
189
|
+
reasons={s.reasons}
|
|
190
|
+
isBest={index === 0}
|
|
191
|
+
isAssigned={assignedVehicleId === s.vehicle.id}
|
|
192
|
+
isPending={pendingId === s.vehicle.id}
|
|
193
|
+
disabled={pendingId !== null}
|
|
194
|
+
onAssign={() => assign(s.vehicle.id, { vehicleId: s.vehicle.id })}
|
|
195
|
+
t={t}
|
|
196
|
+
/>
|
|
197
|
+
))}
|
|
198
|
+
</ul>
|
|
199
|
+
)}
|
|
200
|
+
</section>
|
|
201
|
+
|
|
202
|
+
<section aria-label={t('movethewheels.routes.suggest.drivers', 'Drivers')}>
|
|
203
|
+
<h4 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
|
204
|
+
{t('movethewheels.routes.suggest.drivers', 'Drivers')}
|
|
205
|
+
</h4>
|
|
206
|
+
{drivers.length === 0 ? (
|
|
207
|
+
<p className="py-3 text-sm text-text-secondary">
|
|
208
|
+
{t('movethewheels.routes.suggest.noDrivers', 'No driver is free right now.')}
|
|
209
|
+
</p>
|
|
210
|
+
) : (
|
|
211
|
+
<ul className="divide-y">
|
|
212
|
+
{drivers.map((s, index) => (
|
|
213
|
+
<SuggestionRow
|
|
214
|
+
key={s.driver.id}
|
|
215
|
+
id={s.driver.id}
|
|
216
|
+
label={`${s.driver.firstName} ${s.driver.lastName}`.trim()}
|
|
217
|
+
reasons={s.reasons}
|
|
218
|
+
isBest={index === 0}
|
|
219
|
+
isAssigned={assignedDriverId === s.driver.id}
|
|
220
|
+
isPending={pendingId === s.driver.id}
|
|
221
|
+
disabled={pendingId !== null}
|
|
222
|
+
onAssign={() => assign(s.driver.id, { driverId: s.driver.id })}
|
|
223
|
+
t={t}
|
|
224
|
+
/>
|
|
225
|
+
))}
|
|
226
|
+
</ul>
|
|
227
|
+
)}
|
|
228
|
+
</section>
|
|
229
|
+
</>
|
|
230
|
+
)}
|
|
231
|
+
</CardContent>
|
|
232
|
+
</Card>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FleetAlertsPanel
|
|
3
|
+
*
|
|
4
|
+
* "Needs attention" list for the fleet: licences that have lapsed or are about
|
|
5
|
+
* to, and vehicles that are due or overdue for service. The rules are computed
|
|
6
|
+
* server-side (`fleetAlerts`); this is a read-side view that links each alert
|
|
7
|
+
* to the vehicle or driver that needs action.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { AlertTriangle, CheckCircle, ChevronRight } from 'lucide-react';
|
|
11
|
+
|
|
12
|
+
import { StatusBadge } from './StatusBadge';
|
|
13
|
+
import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from './ui';
|
|
14
|
+
|
|
15
|
+
import type { FleetAlert, FleetAlertSeverity } from '../hooks/useFleetIntelligence';
|
|
16
|
+
|
|
17
|
+
interface FleetAlertsPanelProps {
|
|
18
|
+
alerts: FleetAlert[];
|
|
19
|
+
isLoading: boolean;
|
|
20
|
+
error: Error | null;
|
|
21
|
+
onRetry: () => void;
|
|
22
|
+
onOpen: (alert: FleetAlert) => void;
|
|
23
|
+
t: (key: string, fallback: string) => string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SEVERITY_COLORS: Record<FleetAlertSeverity, { bg: string; text: string }> = {
|
|
27
|
+
CRITICAL: { bg: 'bg-status-error-bg-subtle', text: 'text-status-error-text' },
|
|
28
|
+
WARNING: { bg: 'bg-status-warning-bg-subtle', text: 'text-status-warning-text' },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const plural = (count: number, unit: string): string => `${count} ${unit}${count === 1 ? '' : 's'}`;
|
|
32
|
+
|
|
33
|
+
/** Human-readable "how close / how late" for an alert, from its structured due data. */
|
|
34
|
+
export function describeDue(alert: Pick<FleetAlert, 'daysUntilDue' | 'kmUntilDue'>): string {
|
|
35
|
+
const parts: string[] = [];
|
|
36
|
+
const { daysUntilDue, kmUntilDue } = alert;
|
|
37
|
+
if (daysUntilDue !== null) {
|
|
38
|
+
if (daysUntilDue < 0) parts.push(`${plural(-daysUntilDue, 'day')} overdue`);
|
|
39
|
+
else if (daysUntilDue === 0) parts.push('due today');
|
|
40
|
+
else parts.push(`${plural(daysUntilDue, 'day')} left`);
|
|
41
|
+
}
|
|
42
|
+
if (kmUntilDue !== null) {
|
|
43
|
+
const km = Math.round(Math.abs(kmUntilDue));
|
|
44
|
+
parts.push(kmUntilDue < 0 ? `${km} km overdue` : `${km} km left`);
|
|
45
|
+
}
|
|
46
|
+
return parts.join(' · ');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function FleetAlertsPanel({
|
|
50
|
+
alerts,
|
|
51
|
+
isLoading,
|
|
52
|
+
error,
|
|
53
|
+
onRetry,
|
|
54
|
+
onOpen,
|
|
55
|
+
t,
|
|
56
|
+
}: FleetAlertsPanelProps) {
|
|
57
|
+
const title = t('movethewheels.fleet.alerts.title', 'Needs attention');
|
|
58
|
+
|
|
59
|
+
if (error && !isLoading) {
|
|
60
|
+
return (
|
|
61
|
+
<Card className="border-status-error-border">
|
|
62
|
+
<CardContent className="p-6 text-center space-y-3">
|
|
63
|
+
<p className="text-status-error-text font-medium">
|
|
64
|
+
{t('movethewheels.fleet.alerts.error', "Couldn't load fleet alerts")}
|
|
65
|
+
</p>
|
|
66
|
+
<p className="text-sm text-text-secondary">{error.message}</p>
|
|
67
|
+
<Button variant="outline" onClick={onRetry}>
|
|
68
|
+
{t('movethewheels.fleet.error.retry', 'Retry')}
|
|
69
|
+
</Button>
|
|
70
|
+
</CardContent>
|
|
71
|
+
</Card>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (isLoading) {
|
|
76
|
+
return (
|
|
77
|
+
<Card>
|
|
78
|
+
<CardHeader>
|
|
79
|
+
<CardTitle>{title}</CardTitle>
|
|
80
|
+
</CardHeader>
|
|
81
|
+
<CardContent className="space-y-3">
|
|
82
|
+
<Skeleton className="h-12 w-full" />
|
|
83
|
+
<Skeleton className="h-12 w-full" />
|
|
84
|
+
</CardContent>
|
|
85
|
+
</Card>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (alerts.length === 0) {
|
|
90
|
+
return (
|
|
91
|
+
<Card>
|
|
92
|
+
<CardHeader>
|
|
93
|
+
<CardTitle className="flex items-center gap-2">
|
|
94
|
+
<CheckCircle size={18} aria-hidden="true" className="text-status-success-text" />
|
|
95
|
+
{title}
|
|
96
|
+
</CardTitle>
|
|
97
|
+
</CardHeader>
|
|
98
|
+
<CardContent>
|
|
99
|
+
<p className="text-sm text-text-secondary">
|
|
100
|
+
{t(
|
|
101
|
+
'movethewheels.fleet.alerts.allClear',
|
|
102
|
+
'All clear — every licence is valid and no vehicle is due for service.'
|
|
103
|
+
)}
|
|
104
|
+
</p>
|
|
105
|
+
</CardContent>
|
|
106
|
+
</Card>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<Card>
|
|
112
|
+
<CardHeader>
|
|
113
|
+
<CardTitle className="flex items-center gap-2">
|
|
114
|
+
<AlertTriangle size={18} aria-hidden="true" className="text-status-warning-text" />
|
|
115
|
+
{title} ({alerts.length})
|
|
116
|
+
</CardTitle>
|
|
117
|
+
</CardHeader>
|
|
118
|
+
<CardContent className="p-0">
|
|
119
|
+
<ul className="divide-y" aria-label={title}>
|
|
120
|
+
{alerts.map((alert) => {
|
|
121
|
+
const detail = describeDue(alert);
|
|
122
|
+
return (
|
|
123
|
+
<li key={alert.id}>
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
onClick={() => onOpen(alert)}
|
|
127
|
+
className="flex w-full items-center gap-3 px-6 py-3 text-left hover:bg-bg-sunken/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-action-primary-bg"
|
|
128
|
+
>
|
|
129
|
+
<StatusBadge
|
|
130
|
+
status={alert.severity === 'CRITICAL' ? 'Critical' : 'Warning'}
|
|
131
|
+
customColors={SEVERITY_COLORS[alert.severity]}
|
|
132
|
+
size="sm"
|
|
133
|
+
showDot={false}
|
|
134
|
+
/>
|
|
135
|
+
<span className="flex-1 min-w-0">
|
|
136
|
+
<span className="block text-sm font-medium truncate">{alert.message}</span>
|
|
137
|
+
{detail && <span className="block text-xs text-text-secondary">{detail}</span>}
|
|
138
|
+
</span>
|
|
139
|
+
<ChevronRight size={16} aria-hidden="true" className="shrink-0" />
|
|
140
|
+
</button>
|
|
141
|
+
</li>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</ul>
|
|
145
|
+
</CardContent>
|
|
146
|
+
</Card>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VehicleServiceCard
|
|
3
|
+
*
|
|
4
|
+
* Shows a vehicle's service schedule (last service, next due date/mileage) and
|
|
5
|
+
* lets an operator log a completed service. Logging a service advances the
|
|
6
|
+
* odometer, sets the next due markers from the intervals given, and returns a
|
|
7
|
+
* vehicle that was in the workshop to service — so the "due" alert clears
|
|
8
|
+
* instead of lingering.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useState } from 'react';
|
|
12
|
+
import { Wrench } from 'lucide-react';
|
|
13
|
+
|
|
14
|
+
import { FormError } from './FormError';
|
|
15
|
+
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from './ui';
|
|
16
|
+
|
|
17
|
+
import type { RecordVehicleServiceInput } from '../hooks/useFleetIntelligence';
|
|
18
|
+
import type { Vehicle } from '../types';
|
|
19
|
+
|
|
20
|
+
interface VehicleServiceCardProps {
|
|
21
|
+
vehicle: Vehicle;
|
|
22
|
+
onRecord: (input: RecordVehicleServiceInput) => Promise<void>;
|
|
23
|
+
t: (key: string, fallback: string) => string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_INTERVAL_DAYS = 90;
|
|
27
|
+
const DEFAULT_INTERVAL_KM = 10000;
|
|
28
|
+
|
|
29
|
+
const formatDay = (date: Date | undefined): string | null =>
|
|
30
|
+
date
|
|
31
|
+
? date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
32
|
+
: null;
|
|
33
|
+
|
|
34
|
+
const toDateInput = (date: Date): string => date.toISOString().slice(0, 10);
|
|
35
|
+
|
|
36
|
+
/** Empty string -> undefined; otherwise a finite non-negative number, or NaN when invalid. */
|
|
37
|
+
const parseOptionalNumber = (value: string): number | undefined => {
|
|
38
|
+
if (value.trim() === '') return undefined;
|
|
39
|
+
const parsed = Number(value);
|
|
40
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : Number.NaN;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function VehicleServiceCard({ vehicle, onRecord, t }: VehicleServiceCardProps) {
|
|
44
|
+
const [open, setOpen] = useState(false);
|
|
45
|
+
const [saving, setSaving] = useState(false);
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
const [form, setForm] = useState({
|
|
48
|
+
serviceDate: toDateInput(new Date()),
|
|
49
|
+
mileage: vehicle.mileage !== undefined ? String(vehicle.mileage) : '',
|
|
50
|
+
intervalDays: String(DEFAULT_INTERVAL_DAYS),
|
|
51
|
+
intervalKm: String(DEFAULT_INTERVAL_KM),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const lastService = formatDay(vehicle.lastMaintenance);
|
|
55
|
+
const nextDate = formatDay(vehicle.nextMaintenance);
|
|
56
|
+
const nextMileage = vehicle.nextMaintenanceMileage;
|
|
57
|
+
const hasSchedule = nextDate !== null || nextMileage !== undefined;
|
|
58
|
+
|
|
59
|
+
const submit = async (event: React.FormEvent) => {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
const mileage = parseOptionalNumber(form.mileage);
|
|
62
|
+
const intervalDays = parseOptionalNumber(form.intervalDays);
|
|
63
|
+
const intervalKm = parseOptionalNumber(form.intervalKm);
|
|
64
|
+
if ([mileage, intervalDays, intervalKm].some((n) => n !== undefined && Number.isNaN(n))) {
|
|
65
|
+
setError(t('movethewheels.vehicles.service.invalid', 'Enter valid, non-negative numbers.'));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (intervalKm !== undefined && mileage === undefined && vehicle.mileage === undefined) {
|
|
69
|
+
setError(
|
|
70
|
+
t(
|
|
71
|
+
'movethewheels.vehicles.service.needsMileage',
|
|
72
|
+
'Enter the odometer reading to set a service-by-distance interval.'
|
|
73
|
+
)
|
|
74
|
+
);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setSaving(true);
|
|
79
|
+
setError(null);
|
|
80
|
+
try {
|
|
81
|
+
await onRecord({
|
|
82
|
+
// Noon UTC keeps the calendar day stable in any timezone.
|
|
83
|
+
serviceDate: `${form.serviceDate}T12:00:00.000Z`,
|
|
84
|
+
...(mileage !== undefined ? { mileage } : {}),
|
|
85
|
+
...(intervalDays !== undefined ? { intervalDays: Math.round(intervalDays) } : {}),
|
|
86
|
+
...(intervalKm !== undefined ? { intervalKm } : {}),
|
|
87
|
+
});
|
|
88
|
+
setOpen(false);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
setError(e instanceof Error ? e.message : 'Could not record the service.');
|
|
91
|
+
} finally {
|
|
92
|
+
setSaving(false);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<Card>
|
|
98
|
+
<CardHeader>
|
|
99
|
+
<CardTitle className="flex items-center gap-2">
|
|
100
|
+
<Wrench size={18} aria-hidden="true" />
|
|
101
|
+
{t('movethewheels.vehicles.service.title', 'Service schedule')}
|
|
102
|
+
</CardTitle>
|
|
103
|
+
</CardHeader>
|
|
104
|
+
<CardContent className="space-y-4">
|
|
105
|
+
<dl className="grid grid-cols-2 gap-4 text-sm">
|
|
106
|
+
<div>
|
|
107
|
+
<dt className="text-text-secondary">
|
|
108
|
+
{t('movethewheels.vehicles.service.last', 'Last service')}
|
|
109
|
+
</dt>
|
|
110
|
+
<dd className="font-medium">
|
|
111
|
+
{lastService ?? t('movethewheels.vehicles.service.none', 'Not recorded')}
|
|
112
|
+
</dd>
|
|
113
|
+
</div>
|
|
114
|
+
<div>
|
|
115
|
+
<dt className="text-text-secondary">
|
|
116
|
+
{t('movethewheels.vehicles.service.next', 'Next service due')}
|
|
117
|
+
</dt>
|
|
118
|
+
<dd className="font-medium">
|
|
119
|
+
{hasSchedule
|
|
120
|
+
? [
|
|
121
|
+
nextDate,
|
|
122
|
+
nextMileage !== undefined ? `${nextMileage.toLocaleString()} km` : null,
|
|
123
|
+
]
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.join(' / ')
|
|
126
|
+
: t('movethewheels.vehicles.service.noSchedule', 'No schedule set')}
|
|
127
|
+
</dd>
|
|
128
|
+
</div>
|
|
129
|
+
</dl>
|
|
130
|
+
|
|
131
|
+
{!open ? (
|
|
132
|
+
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
|
133
|
+
{t('movethewheels.vehicles.service.record', 'Record service')}
|
|
134
|
+
</Button>
|
|
135
|
+
) : (
|
|
136
|
+
<form onSubmit={submit} className="space-y-3">
|
|
137
|
+
<FormError message={error} />
|
|
138
|
+
<div className="grid grid-cols-2 gap-3">
|
|
139
|
+
<div className="space-y-1">
|
|
140
|
+
<Label htmlFor="service-date">
|
|
141
|
+
{t('movethewheels.vehicles.service.date', 'Service date')}
|
|
142
|
+
</Label>
|
|
143
|
+
<Input
|
|
144
|
+
id="service-date"
|
|
145
|
+
type="date"
|
|
146
|
+
required
|
|
147
|
+
value={form.serviceDate}
|
|
148
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
149
|
+
setForm((f) => ({ ...f, serviceDate: e.target.value }))
|
|
150
|
+
}
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
<div className="space-y-1">
|
|
154
|
+
<Label htmlFor="service-mileage">
|
|
155
|
+
{t('movethewheels.vehicles.service.mileage', 'Odometer (km)')}
|
|
156
|
+
</Label>
|
|
157
|
+
<Input
|
|
158
|
+
id="service-mileage"
|
|
159
|
+
type="number"
|
|
160
|
+
min={0}
|
|
161
|
+
value={form.mileage}
|
|
162
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
163
|
+
setForm((f) => ({ ...f, mileage: e.target.value }))
|
|
164
|
+
}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
<div className="space-y-1">
|
|
168
|
+
<Label htmlFor="service-interval-days">
|
|
169
|
+
{t('movethewheels.vehicles.service.intervalDays', 'Next service in (days)')}
|
|
170
|
+
</Label>
|
|
171
|
+
<Input
|
|
172
|
+
id="service-interval-days"
|
|
173
|
+
type="number"
|
|
174
|
+
min={0}
|
|
175
|
+
value={form.intervalDays}
|
|
176
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
177
|
+
setForm((f) => ({ ...f, intervalDays: e.target.value }))
|
|
178
|
+
}
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
<div className="space-y-1">
|
|
182
|
+
<Label htmlFor="service-interval-km">
|
|
183
|
+
{t('movethewheels.vehicles.service.intervalKm', 'Next service in (km)')}
|
|
184
|
+
</Label>
|
|
185
|
+
<Input
|
|
186
|
+
id="service-interval-km"
|
|
187
|
+
type="number"
|
|
188
|
+
min={0}
|
|
189
|
+
value={form.intervalKm}
|
|
190
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
191
|
+
setForm((f) => ({ ...f, intervalKm: e.target.value }))
|
|
192
|
+
}
|
|
193
|
+
/>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
<div className="flex gap-2">
|
|
197
|
+
<Button type="submit" size="sm" disabled={saving}>
|
|
198
|
+
{saving
|
|
199
|
+
? t('movethewheels.vehicles.service.saving', 'Saving…')
|
|
200
|
+
: t('movethewheels.vehicles.service.save', 'Save service')}
|
|
201
|
+
</Button>
|
|
202
|
+
<Button
|
|
203
|
+
type="button"
|
|
204
|
+
variant="ghost"
|
|
205
|
+
size="sm"
|
|
206
|
+
disabled={saving}
|
|
207
|
+
onClick={() => {
|
|
208
|
+
setOpen(false);
|
|
209
|
+
setError(null);
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
{t('movethewheels.vehicles.service.cancel', 'Cancel')}
|
|
213
|
+
</Button>
|
|
214
|
+
</div>
|
|
215
|
+
</form>
|
|
216
|
+
)}
|
|
217
|
+
</CardContent>
|
|
218
|
+
</Card>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
@@ -53,6 +53,9 @@ interface ApiVehicle {
|
|
|
53
53
|
currentLocation?: Record<string, unknown> | null;
|
|
54
54
|
fuelLevel?: number | null;
|
|
55
55
|
mileage?: number | null;
|
|
56
|
+
lastServiceDate?: string | null;
|
|
57
|
+
nextServiceDueDate?: string | null;
|
|
58
|
+
nextServiceDueMileage?: number | null;
|
|
56
59
|
createdAt: string;
|
|
57
60
|
updatedAt?: string;
|
|
58
61
|
}
|
|
@@ -91,6 +94,9 @@ const LIST_VEHICLES_QUERY = `
|
|
|
91
94
|
currentLocation
|
|
92
95
|
fuelLevel
|
|
93
96
|
mileage
|
|
97
|
+
lastServiceDate
|
|
98
|
+
nextServiceDueDate
|
|
99
|
+
nextServiceDueMileage
|
|
94
100
|
createdAt
|
|
95
101
|
updatedAt
|
|
96
102
|
}
|
|
@@ -135,6 +141,9 @@ const GET_VEHICLE_QUERY = `
|
|
|
135
141
|
currentLocation
|
|
136
142
|
fuelLevel
|
|
137
143
|
mileage
|
|
144
|
+
lastServiceDate
|
|
145
|
+
nextServiceDueDate
|
|
146
|
+
nextServiceDueMileage
|
|
138
147
|
createdAt
|
|
139
148
|
updatedAt
|
|
140
149
|
}
|
|
@@ -424,8 +433,9 @@ function mapVehicle(vehicle: ApiVehicle): Vehicle {
|
|
|
424
433
|
timestamp: new Date(),
|
|
425
434
|
}
|
|
426
435
|
: undefined,
|
|
427
|
-
lastMaintenance: undefined,
|
|
428
|
-
nextMaintenance: undefined,
|
|
436
|
+
lastMaintenance: vehicle.lastServiceDate ? new Date(vehicle.lastServiceDate) : undefined,
|
|
437
|
+
nextMaintenance: vehicle.nextServiceDueDate ? new Date(vehicle.nextServiceDueDate) : undefined,
|
|
438
|
+
nextMaintenanceMileage: vehicle.nextServiceDueMileage ?? undefined,
|
|
429
439
|
fuelLevel: vehicle.fuelLevel ?? undefined,
|
|
430
440
|
mileage: vehicle.mileage ?? undefined,
|
|
431
441
|
documents: [],
|