@burdenoff/microfe-movethewheels 2026.1017.16 → 2026.1017.18
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/MoveTheWheelsRoot.js +5 -0
- package/dist/MoveTheWheelsRoutes.js +8 -4
- package/dist/generated/consumed-fuel-preview-operations.d.ts +132 -0
- package/dist/hooks/useConsumedFuelPreview.js +90 -0
- package/dist/movethewheels/hooks/useConsumedFuelPreview.d.ts +33 -0
- package/dist/movethewheels/pages/ConsumedFuelPreviewPage.d.ts +1 -0
- package/dist/movethewheels/utils/consumedFuelInput.d.ts +27 -0
- package/dist/movethewheels/utils/consumedFuelResponse.d.ts +4 -0
- package/dist/pages/ConsumedFuelPreviewPage.js +267 -0
- package/dist/src/generated/consumed-fuel-preview-operations.js +213 -0
- package/dist/utils/consumedFuelInput.js +110 -0
- package/dist/utils/consumedFuelResponse.js +89 -0
- package/package.json +5 -4
- package/src/generated/consumed-fuel-preview-operations.ts +126 -0
- package/src/movethewheels/MoveTheWheelsRoot.tsx +6 -0
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +2 -0
- package/src/movethewheels/hooks/useConsumedFuelPreview.ts +114 -0
- package/src/movethewheels/pages/ConsumedFuelPreviewPage.tsx +389 -0
- package/src/movethewheels/utils/consumedFuelInput.ts +148 -0
- package/src/movethewheels/utils/consumedFuelResponse.ts +140 -0
- package/src/operations/consumed-fuel-preview/preview.graphql +1 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { print } from 'graphql';
|
|
3
|
+
import {
|
|
4
|
+
MtwConsumedFuelPreviewDocument,
|
|
5
|
+
type MtwConsumedFuelPreviewInput,
|
|
6
|
+
} from '../../generated/consumed-fuel-preview-operations';
|
|
7
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
8
|
+
import { executeGraphQL } from '../utils/graphqlClient';
|
|
9
|
+
import {
|
|
10
|
+
parseConsumedFuelPreview,
|
|
11
|
+
validateConsumedFuelEnvelope,
|
|
12
|
+
type ConsumedFuelPreview,
|
|
13
|
+
} from '../utils/consumedFuelResponse';
|
|
14
|
+
|
|
15
|
+
const query = print(MtwConsumedFuelPreviewDocument);
|
|
16
|
+
export function useConsumedFuelPreview() {
|
|
17
|
+
const { workspaceId, tenantId, currentUser, apiGatewayUrl, authToken, workspaceToken } =
|
|
18
|
+
useMoveTheWheels();
|
|
19
|
+
const context = useMemo(
|
|
20
|
+
() => ({
|
|
21
|
+
workspaceId,
|
|
22
|
+
tenantId,
|
|
23
|
+
actorId: currentUser?.id,
|
|
24
|
+
apiGatewayUrl,
|
|
25
|
+
authToken,
|
|
26
|
+
workspaceToken,
|
|
27
|
+
}),
|
|
28
|
+
[workspaceId, tenantId, currentUser?.id, apiGatewayUrl, authToken, workspaceToken]
|
|
29
|
+
);
|
|
30
|
+
const active = useRef(context);
|
|
31
|
+
useLayoutEffect(() => {
|
|
32
|
+
active.current = context;
|
|
33
|
+
}, [context]);
|
|
34
|
+
const generation = useRef(0);
|
|
35
|
+
const pending = useRef<AbortController | null>(null);
|
|
36
|
+
const [state, setState] = useState<{
|
|
37
|
+
context: typeof context;
|
|
38
|
+
generation: number;
|
|
39
|
+
loading: boolean;
|
|
40
|
+
result: ConsumedFuelPreview | null;
|
|
41
|
+
input: MtwConsumedFuelPreviewInput | null;
|
|
42
|
+
failed: boolean;
|
|
43
|
+
} | null>(null);
|
|
44
|
+
const cancel = useCallback(() => {
|
|
45
|
+
generation.current++;
|
|
46
|
+
pending.current?.abort();
|
|
47
|
+
pending.current = null;
|
|
48
|
+
}, []);
|
|
49
|
+
const invalidate = useCallback(() => {
|
|
50
|
+
cancel();
|
|
51
|
+
setState(null);
|
|
52
|
+
}, [cancel]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
invalidate();
|
|
55
|
+
return cancel;
|
|
56
|
+
}, [context, cancel, invalidate]);
|
|
57
|
+
const run = useCallback(
|
|
58
|
+
async (input: MtwConsumedFuelPreviewInput) => {
|
|
59
|
+
if (active.current !== context || pending.current || !context.workspaceId || !context.actorId)
|
|
60
|
+
return;
|
|
61
|
+
input = structuredClone(input);
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
pending.current = controller;
|
|
64
|
+
const ticket = ++generation.current;
|
|
65
|
+
setState({ context, generation: ticket, loading: true, result: null, input, failed: false });
|
|
66
|
+
try {
|
|
67
|
+
const data = await executeGraphQL<unknown>(
|
|
68
|
+
{
|
|
69
|
+
...context,
|
|
70
|
+
signal: controller.signal,
|
|
71
|
+
validateResponse: validateConsumedFuelEnvelope,
|
|
72
|
+
},
|
|
73
|
+
query,
|
|
74
|
+
{ input },
|
|
75
|
+
'SINGLE_ATTEMPT'
|
|
76
|
+
);
|
|
77
|
+
const result = parseConsumedFuelPreview(data, input);
|
|
78
|
+
if (
|
|
79
|
+
!controller.signal.aborted &&
|
|
80
|
+
active.current === context &&
|
|
81
|
+
generation.current === ticket
|
|
82
|
+
)
|
|
83
|
+
setState({ context, generation: ticket, loading: false, result, input, failed: false });
|
|
84
|
+
} catch {
|
|
85
|
+
if (
|
|
86
|
+
!controller.signal.aborted &&
|
|
87
|
+
active.current === context &&
|
|
88
|
+
generation.current === ticket
|
|
89
|
+
)
|
|
90
|
+
setState({
|
|
91
|
+
context,
|
|
92
|
+
generation: ticket,
|
|
93
|
+
loading: false,
|
|
94
|
+
result: null,
|
|
95
|
+
input,
|
|
96
|
+
failed: true,
|
|
97
|
+
});
|
|
98
|
+
} finally {
|
|
99
|
+
if (pending.current === controller) pending.current = null;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
[context]
|
|
103
|
+
);
|
|
104
|
+
const current = state?.context === context;
|
|
105
|
+
return {
|
|
106
|
+
run,
|
|
107
|
+
invalidate,
|
|
108
|
+
ready: !!context.workspaceId && !!context.actorId,
|
|
109
|
+
loading: current ? state.loading : false,
|
|
110
|
+
result: current ? state.result : null,
|
|
111
|
+
input: current ? state.input : null,
|
|
112
|
+
failed: current ? state.failed : false,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
import { Gauge } from 'lucide-react';
|
|
3
|
+
import { Button, Input, Label } from '../components/ui';
|
|
4
|
+
import { PageLayout } from '../components/PageLayout';
|
|
5
|
+
import { useTr } from '../hooks';
|
|
6
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
7
|
+
import { useConsumedFuelPreview } from '../hooks/useConsumedFuelPreview';
|
|
8
|
+
import {
|
|
9
|
+
consumedFuelInput,
|
|
10
|
+
newConsumedFuelDraft,
|
|
11
|
+
ConsumedFuelInputError,
|
|
12
|
+
formatFuelRatio,
|
|
13
|
+
type FuelObservationDraft,
|
|
14
|
+
} from '../utils/consumedFuelInput';
|
|
15
|
+
import type { ConsumedFuelPreview } from '../utils/consumedFuelResponse';
|
|
16
|
+
|
|
17
|
+
const reasonCopy = {
|
|
18
|
+
DISTANCE_UNKNOWN: 'Distance is missing.',
|
|
19
|
+
FUEL_UNKNOWN: 'Consumed volume is missing.',
|
|
20
|
+
SOURCE_UNKNOWN: 'Source reference or revision is missing.',
|
|
21
|
+
NON_MEASURED_DISTANCE: 'Distance is not declared measured.',
|
|
22
|
+
UNSUPPORTED_FUEL_BASIS: 'Fuel basis is estimated, refuelled or unknown.',
|
|
23
|
+
INTERVAL_MISMATCH: 'The observation intervals differ.',
|
|
24
|
+
PARTIAL_COVERAGE: 'Coverage is declared partial.',
|
|
25
|
+
ZERO_DISTANCE: 'L/100 km is undefined for zero distance.',
|
|
26
|
+
};
|
|
27
|
+
function ObservationFields({
|
|
28
|
+
kind,
|
|
29
|
+
value,
|
|
30
|
+
prefix,
|
|
31
|
+
error,
|
|
32
|
+
onChange,
|
|
33
|
+
}: {
|
|
34
|
+
kind: 'distance' | 'fuel';
|
|
35
|
+
value: FuelObservationDraft;
|
|
36
|
+
prefix: string;
|
|
37
|
+
error: ConsumedFuelInputError | null;
|
|
38
|
+
onChange: (patch: Partial<FuelObservationDraft>) => void;
|
|
39
|
+
}) {
|
|
40
|
+
const tr = useTr();
|
|
41
|
+
const fields = [
|
|
42
|
+
['amount', kind === 'distance' ? 'Distance (km)' : 'Reported fuel volume (L)'],
|
|
43
|
+
['intervalFrom', 'Interval start (UTC)'],
|
|
44
|
+
['intervalUntil', 'Interval end (UTC)'],
|
|
45
|
+
['sourceReference', 'Source reference'],
|
|
46
|
+
['sourceRevision', 'Source revision'],
|
|
47
|
+
] as const;
|
|
48
|
+
const basis =
|
|
49
|
+
kind === 'distance'
|
|
50
|
+
? [
|
|
51
|
+
['MEASURED', 'Measured'],
|
|
52
|
+
['PROVIDER_ESTIMATE', 'Provider estimate'],
|
|
53
|
+
['OPERATOR_ESTIMATE', 'Operator estimate'],
|
|
54
|
+
]
|
|
55
|
+
: [
|
|
56
|
+
['MEASURED_CONSUMPTION', 'Measured consumption'],
|
|
57
|
+
['ESTIMATED_CONSUMPTION', 'Estimated consumption'],
|
|
58
|
+
['PURCHASED_OR_REFUELLED', 'Purchased or refuelled'],
|
|
59
|
+
['UNKNOWN', 'Unknown'],
|
|
60
|
+
];
|
|
61
|
+
const heading =
|
|
62
|
+
kind === 'distance'
|
|
63
|
+
? tr('movethewheels.fuelPreview.distance', 'Distance observation')
|
|
64
|
+
: tr('movethewheels.fuelPreview.fuel', 'Fuel observation');
|
|
65
|
+
return (
|
|
66
|
+
<fieldset className="min-w-0 rounded-xl border border-outline p-4 space-y-4">
|
|
67
|
+
<legend className="px-1 font-semibold">{heading}</legend>
|
|
68
|
+
<Label htmlFor={`${prefix}-${kind}-included`} className="flex items-center gap-2 min-h-11">
|
|
69
|
+
<input
|
|
70
|
+
id={`${prefix}-${kind}-included`}
|
|
71
|
+
type="checkbox"
|
|
72
|
+
checked={value.included}
|
|
73
|
+
onChange={(e) => onChange({ included: e.target.checked })}
|
|
74
|
+
/>
|
|
75
|
+
{tr('movethewheels.fuelPreview.include', 'Include this observation')}
|
|
76
|
+
</Label>
|
|
77
|
+
{!value.included ? (
|
|
78
|
+
<p className="text-sm text-text-secondary">
|
|
79
|
+
{tr(
|
|
80
|
+
'movethewheels.fuelPreview.absent',
|
|
81
|
+
'Not supplied. This observation will remain unknown.'
|
|
82
|
+
)}
|
|
83
|
+
</p>
|
|
84
|
+
) : (
|
|
85
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
86
|
+
{fields.map(([field, label]) => (
|
|
87
|
+
<div key={field} className="min-w-0 space-y-1">
|
|
88
|
+
<Label htmlFor={`${prefix}-${kind}-${field}`}>
|
|
89
|
+
{tr(`movethewheels.fuelPreview.${kind}.${field}`, label)}
|
|
90
|
+
</Label>
|
|
91
|
+
<Input
|
|
92
|
+
id={`${prefix}-${kind}-${field}`}
|
|
93
|
+
type="text"
|
|
94
|
+
inputMode={field === 'amount' ? 'decimal' : 'text'}
|
|
95
|
+
autoComplete="off"
|
|
96
|
+
value={value[field]}
|
|
97
|
+
onChange={(e) => onChange({ [field]: e.target.value })}
|
|
98
|
+
required={field === 'intervalFrom' || field === 'intervalUntil'}
|
|
99
|
+
maxLength={field === 'amount' ? 32 : field.startsWith('interval') ? 32 : 255}
|
|
100
|
+
aria-invalid={error?.field === `${kind}-${field}`}
|
|
101
|
+
aria-describedby={
|
|
102
|
+
error?.field === `${kind}-${field}` ? `${prefix}-error` : undefined
|
|
103
|
+
}
|
|
104
|
+
className="min-h-11"
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
))}
|
|
108
|
+
{(
|
|
109
|
+
[
|
|
110
|
+
['basis', tr('movethewheels.fuelPreview.basis', 'Observation basis'), basis],
|
|
111
|
+
[
|
|
112
|
+
'coverage',
|
|
113
|
+
tr('movethewheels.fuelPreview.coverage', 'Declared interval coverage'),
|
|
114
|
+
[
|
|
115
|
+
['COMPLETE', tr('movethewheels.fuelPreview.complete', 'Complete')],
|
|
116
|
+
['PARTIAL', tr('movethewheels.fuelPreview.partial', 'Partial')],
|
|
117
|
+
],
|
|
118
|
+
],
|
|
119
|
+
] as const
|
|
120
|
+
).map(([field, label, choices]) => (
|
|
121
|
+
<div key={field} className="space-y-1">
|
|
122
|
+
<Label htmlFor={`${prefix}-${kind}-${field}`}>{label}</Label>
|
|
123
|
+
<select
|
|
124
|
+
id={`${prefix}-${kind}-${field}`}
|
|
125
|
+
required
|
|
126
|
+
value={value[field]}
|
|
127
|
+
onChange={(e) => onChange({ [field]: e.target.value })}
|
|
128
|
+
aria-invalid={error?.field === `${kind}-${field}`}
|
|
129
|
+
aria-describedby={
|
|
130
|
+
error?.field === `${kind}-${field}` ? `${prefix}-error` : undefined
|
|
131
|
+
}
|
|
132
|
+
className="min-h-11 w-full rounded-md border border-outline bg-surface px-3"
|
|
133
|
+
>
|
|
134
|
+
<option value="">
|
|
135
|
+
{tr('movethewheels.fuelPreview.choose', 'Choose a declared basis or coverage')}
|
|
136
|
+
</option>
|
|
137
|
+
{choices.map(([choice, label]) => (
|
|
138
|
+
<option key={choice} value={choice}>
|
|
139
|
+
{tr(`movethewheels.fuelPreview.choices.${choice}`, label)}
|
|
140
|
+
</option>
|
|
141
|
+
))}
|
|
142
|
+
</select>
|
|
143
|
+
</div>
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
</fieldset>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
function FuelResults({ result }: { result: ConsumedFuelPreview }) {
|
|
151
|
+
const tr = useTr();
|
|
152
|
+
return (
|
|
153
|
+
<section
|
|
154
|
+
aria-label={tr('movethewheels.fuelPreview.result', 'Consumption preview result')}
|
|
155
|
+
className="rounded-xl border border-outline p-4 space-y-3"
|
|
156
|
+
data-testid="fuel-preview-result"
|
|
157
|
+
>
|
|
158
|
+
<h2 className="text-xl font-semibold">
|
|
159
|
+
{result.status === 'KNOWN' && result.litresPer100Km !== null
|
|
160
|
+
? formatFuelRatio(result.litresPer100Km)
|
|
161
|
+
: tr('movethewheels.fuelPreview.unknown', 'Unknown — no ratio available')}
|
|
162
|
+
</h2>
|
|
163
|
+
<p>
|
|
164
|
+
{tr(
|
|
165
|
+
'movethewheels.fuelPreview.unverified',
|
|
166
|
+
'Operator reported · unverified. Source labels do not authenticate measurements.'
|
|
167
|
+
)}
|
|
168
|
+
</p>
|
|
169
|
+
{result.reasons.length > 0 && (
|
|
170
|
+
<ul className="list-disc ps-5">
|
|
171
|
+
{result.reasons.map((reason) => (
|
|
172
|
+
<li key={reason}>
|
|
173
|
+
{tr(`movethewheels.fuelPreview.reasons.${reason}`, reasonCopy[reason])}
|
|
174
|
+
</li>
|
|
175
|
+
))}
|
|
176
|
+
</ul>
|
|
177
|
+
)}
|
|
178
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
179
|
+
{(['distance', 'fuel'] as const).map((kind) => {
|
|
180
|
+
const observation = result[kind];
|
|
181
|
+
return (
|
|
182
|
+
<div key={kind} className="min-w-0 break-words text-sm">
|
|
183
|
+
<h3 className="font-semibold">
|
|
184
|
+
{kind === 'distance'
|
|
185
|
+
? tr('movethewheels.fuelPreview.distance', 'Distance observation')
|
|
186
|
+
: tr('movethewheels.fuelPreview.fuel', 'Fuel observation')}
|
|
187
|
+
</h3>
|
|
188
|
+
{!observation ? (
|
|
189
|
+
<p>{tr('movethewheels.fuelPreview.notSupplied', 'Not supplied')}</p>
|
|
190
|
+
) : (
|
|
191
|
+
<>
|
|
192
|
+
<p>
|
|
193
|
+
{kind === 'distance'
|
|
194
|
+
? `${result.distance?.metres ?? tr('movethewheels.fuelPreview.missing', 'Unknown')} m`
|
|
195
|
+
: `${result.fuel?.millilitres ?? tr('movethewheels.fuelPreview.missing', 'Unknown')} mL`}
|
|
196
|
+
</p>
|
|
197
|
+
<p>
|
|
198
|
+
{observation.intervalFrom} → {observation.intervalUntil}
|
|
199
|
+
</p>
|
|
200
|
+
<p>
|
|
201
|
+
{tr(
|
|
202
|
+
`movethewheels.fuelPreview.choices.${observation.basis}`,
|
|
203
|
+
observation.basis
|
|
204
|
+
)}{' '}
|
|
205
|
+
·{' '}
|
|
206
|
+
{tr(
|
|
207
|
+
`movethewheels.fuelPreview.choices.${observation.coverage}`,
|
|
208
|
+
observation.coverage
|
|
209
|
+
)}
|
|
210
|
+
</p>
|
|
211
|
+
<p>
|
|
212
|
+
{observation.sourceReference ??
|
|
213
|
+
tr('movethewheels.fuelPreview.noSource', 'No source reference')}{' '}
|
|
214
|
+
/{' '}
|
|
215
|
+
{observation.sourceRevision ??
|
|
216
|
+
tr('movethewheels.fuelPreview.noRevision', 'No source revision')}
|
|
217
|
+
</p>
|
|
218
|
+
</>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
})}
|
|
223
|
+
</div>
|
|
224
|
+
</section>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
export default function ConsumedFuelPreviewPage() {
|
|
228
|
+
const { workspaceId, tenantId, currentUser, apiGatewayUrl } = useMoveTheWheels();
|
|
229
|
+
return (
|
|
230
|
+
<ConsumedFuelWorkbench
|
|
231
|
+
key={JSON.stringify([workspaceId, tenantId, currentUser?.id, apiGatewayUrl])}
|
|
232
|
+
/>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
function fuelInputErrorCopy(
|
|
236
|
+
error: ConsumedFuelInputError | null,
|
|
237
|
+
tr: ReturnType<typeof useTr>
|
|
238
|
+
): string {
|
|
239
|
+
return error?.kind === 'QUANTITY'
|
|
240
|
+
? tr(
|
|
241
|
+
'movethewheels.fuelPreview.quantityError',
|
|
242
|
+
'Use a nonnegative decimal with at most three fractional digits, up to 2147483.647 km or L. No separators, signs or exponents.'
|
|
243
|
+
)
|
|
244
|
+
: error?.kind === 'INSTANT'
|
|
245
|
+
? tr(
|
|
246
|
+
'movethewheels.fuelPreview.instantError',
|
|
247
|
+
'Use a real UTC timestamp such as YYYY-MM-DDTHH:mm:ssZ, with at most three fractional digits.'
|
|
248
|
+
)
|
|
249
|
+
: error?.kind === 'INTERVAL'
|
|
250
|
+
? tr(
|
|
251
|
+
'movethewheels.fuelPreview.intervalError',
|
|
252
|
+
'Start must precede end; end must not be in the future.'
|
|
253
|
+
)
|
|
254
|
+
: error?.kind === 'SOURCE'
|
|
255
|
+
? tr(
|
|
256
|
+
'movethewheels.fuelPreview.sourceError',
|
|
257
|
+
'Leave a source label blank, or shorten it and include at least one non-space character.'
|
|
258
|
+
)
|
|
259
|
+
: tr('movethewheels.fuelPreview.choiceError', 'Choose the declared basis and coverage.');
|
|
260
|
+
}
|
|
261
|
+
function ConsumedFuelWorkbench() {
|
|
262
|
+
const tr = useTr(),
|
|
263
|
+
prefix = useId();
|
|
264
|
+
const [draft, setDraft] = useState(newConsumedFuelDraft);
|
|
265
|
+
const [error, setError] = useState<ConsumedFuelInputError | null>(null);
|
|
266
|
+
const [edited, setEdited] = useState(false);
|
|
267
|
+
const [unexpectedInputFailure, setUnexpectedInputFailure] = useState(false);
|
|
268
|
+
const preview = useConsumedFuelPreview();
|
|
269
|
+
const errorRef = useRef<HTMLDivElement>(null);
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
if (error) {
|
|
272
|
+
const field = document.getElementById(`${prefix}-${error.field}`);
|
|
273
|
+
if (field instanceof HTMLElement) field.focus();
|
|
274
|
+
else errorRef.current?.focus();
|
|
275
|
+
} else if (preview.failed || unexpectedInputFailure) errorRef.current?.focus();
|
|
276
|
+
}, [error, prefix, preview.failed, unexpectedInputFailure]);
|
|
277
|
+
const edit = (kind: 'distance' | 'fuel', patch: Partial<FuelObservationDraft>) => {
|
|
278
|
+
preview.invalidate();
|
|
279
|
+
setEdited(true);
|
|
280
|
+
setError(null);
|
|
281
|
+
setUnexpectedInputFailure(false);
|
|
282
|
+
setDraft((old) => ({ ...old, [kind]: { ...old[kind], ...patch } }));
|
|
283
|
+
};
|
|
284
|
+
const copy = fuelInputErrorCopy(error, tr);
|
|
285
|
+
return (
|
|
286
|
+
<PageLayout
|
|
287
|
+
title={tr('movethewheels.fuelPreview.title', 'Consumed fuel preview')}
|
|
288
|
+
subtitle={tr(
|
|
289
|
+
'movethewheels.fuelPreview.subtitle',
|
|
290
|
+
'Compare sourced consumption and distance for the same interval.'
|
|
291
|
+
)}
|
|
292
|
+
icon={<Gauge aria-hidden="true" />}
|
|
293
|
+
>
|
|
294
|
+
<p className="mb-4 text-sm">
|
|
295
|
+
{tr(
|
|
296
|
+
'movethewheels.fuelPreview.limits',
|
|
297
|
+
'Reported interval arithmetic only. No measurement verification, savings, emissions, backhaul suitability or safety approval. Nothing is saved.'
|
|
298
|
+
)}
|
|
299
|
+
</p>
|
|
300
|
+
<form
|
|
301
|
+
noValidate
|
|
302
|
+
onSubmit={(e) => {
|
|
303
|
+
e.preventDefault();
|
|
304
|
+
if (preview.loading || !preview.ready) return;
|
|
305
|
+
setUnexpectedInputFailure(false);
|
|
306
|
+
try {
|
|
307
|
+
const input = consumedFuelInput(draft);
|
|
308
|
+
setError(null);
|
|
309
|
+
setEdited(false);
|
|
310
|
+
void preview.run(input);
|
|
311
|
+
} catch (cause) {
|
|
312
|
+
if (cause instanceof ConsumedFuelInputError) setError(cause);
|
|
313
|
+
else {
|
|
314
|
+
preview.invalidate();
|
|
315
|
+
setError(null);
|
|
316
|
+
setUnexpectedInputFailure(true);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}}
|
|
320
|
+
className="space-y-4"
|
|
321
|
+
>
|
|
322
|
+
<p className="text-sm text-text-secondary">
|
|
323
|
+
{tr(
|
|
324
|
+
'movethewheels.fuelPreview.precision',
|
|
325
|
+
'Blank quantities stay unknown; explicit zero stays zero. Enter km and litres with at most three decimal places. COMPLETE is your declaration for that interval, not a trip certificate.'
|
|
326
|
+
)}
|
|
327
|
+
</p>
|
|
328
|
+
<div className="grid gap-4 lg:grid-cols-2">
|
|
329
|
+
{(['distance', 'fuel'] as const).map((kind) => (
|
|
330
|
+
<ObservationFields
|
|
331
|
+
key={kind}
|
|
332
|
+
kind={kind}
|
|
333
|
+
value={draft[kind]}
|
|
334
|
+
prefix={prefix}
|
|
335
|
+
error={error}
|
|
336
|
+
onChange={(patch) => edit(kind, patch)}
|
|
337
|
+
/>
|
|
338
|
+
))}
|
|
339
|
+
</div>
|
|
340
|
+
{(error || preview.failed || unexpectedInputFailure) && (
|
|
341
|
+
<div
|
|
342
|
+
id={`${prefix}-error`}
|
|
343
|
+
ref={errorRef}
|
|
344
|
+
tabIndex={-1}
|
|
345
|
+
role="alert"
|
|
346
|
+
className="rounded-md border border-outline p-3"
|
|
347
|
+
>
|
|
348
|
+
{error
|
|
349
|
+
? copy
|
|
350
|
+
: unexpectedInputFailure
|
|
351
|
+
? tr(
|
|
352
|
+
'movethewheels.fuelPreview.inputUnavailable',
|
|
353
|
+
'The observations could not be prepared. Review the form before trying this read again.'
|
|
354
|
+
)
|
|
355
|
+
: tr(
|
|
356
|
+
'movethewheels.fuelPreview.unavailable',
|
|
357
|
+
'Preview unavailable. No result is accepted; check your connection and access before trying this read again.'
|
|
358
|
+
)}
|
|
359
|
+
</div>
|
|
360
|
+
)}
|
|
361
|
+
{!preview.ready && (
|
|
362
|
+
<p role="status">
|
|
363
|
+
{tr(
|
|
364
|
+
'movethewheels.fuelPreview.contextRequired',
|
|
365
|
+
'Choose an active workspace and sign in before previewing.'
|
|
366
|
+
)}
|
|
367
|
+
</p>
|
|
368
|
+
)}
|
|
369
|
+
<Button type="submit" disabled={preview.loading || !preview.ready} className="min-h-11">
|
|
370
|
+
{preview.loading
|
|
371
|
+
? tr('movethewheels.fuelPreview.loading', 'Previewing…')
|
|
372
|
+
: tr('movethewheels.fuelPreview.submit', 'Preview consumption')}
|
|
373
|
+
</Button>
|
|
374
|
+
</form>
|
|
375
|
+
<div aria-live="polite" className="mt-5">
|
|
376
|
+
{preview.result ? (
|
|
377
|
+
<FuelResults result={preview.result} />
|
|
378
|
+
) : edited ? (
|
|
379
|
+
<p>
|
|
380
|
+
{tr(
|
|
381
|
+
'movethewheels.fuelPreview.stale',
|
|
382
|
+
'Inputs changed. Preview again for these observations; the previous result is hidden.'
|
|
383
|
+
)}
|
|
384
|
+
</p>
|
|
385
|
+
) : null}
|
|
386
|
+
</div>
|
|
387
|
+
</PageLayout>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MtwConsumedFuelPreviewInput,
|
|
3
|
+
MtwConsumedFuelBasis,
|
|
4
|
+
MtwUtilizationDistanceBasis,
|
|
5
|
+
MtwTripUtilizationCoverage,
|
|
6
|
+
} from '../../generated/consumed-fuel-preview-operations';
|
|
7
|
+
|
|
8
|
+
export interface FuelObservationDraft {
|
|
9
|
+
included: boolean;
|
|
10
|
+
amount: string;
|
|
11
|
+
intervalFrom: string;
|
|
12
|
+
intervalUntil: string;
|
|
13
|
+
basis: string;
|
|
14
|
+
coverage: string;
|
|
15
|
+
sourceReference: string;
|
|
16
|
+
sourceRevision: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ConsumedFuelDraft {
|
|
19
|
+
distance: FuelObservationDraft;
|
|
20
|
+
fuel: FuelObservationDraft;
|
|
21
|
+
}
|
|
22
|
+
export function newConsumedFuelDraft(): ConsumedFuelDraft {
|
|
23
|
+
const blank = (): FuelObservationDraft => ({
|
|
24
|
+
included: false,
|
|
25
|
+
amount: '',
|
|
26
|
+
intervalFrom: '',
|
|
27
|
+
intervalUntil: '',
|
|
28
|
+
basis: '',
|
|
29
|
+
coverage: '',
|
|
30
|
+
sourceReference: '',
|
|
31
|
+
sourceRevision: '',
|
|
32
|
+
});
|
|
33
|
+
return { distance: blank(), fuel: blank() };
|
|
34
|
+
}
|
|
35
|
+
export class ConsumedFuelInputError extends Error {
|
|
36
|
+
constructor(
|
|
37
|
+
readonly field: string,
|
|
38
|
+
readonly kind: 'QUANTITY' | 'INSTANT' | 'INTERVAL' | 'SOURCE' | 'CHOICE'
|
|
39
|
+
) {
|
|
40
|
+
super('Review the supplied interval observations.');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Parse decimal km/L text directly into whole m/mL; no floating multiplication or rounding. */
|
|
44
|
+
export function exactMilliUnits(value: string, field: string): number | null {
|
|
45
|
+
if (value === '') return null;
|
|
46
|
+
if (value.length > 11 || !/^(0|[1-9]\d*)(\.\d{1,3})?$/.test(value) || value.trim() !== value)
|
|
47
|
+
throw new ConsumedFuelInputError(field, 'QUANTITY');
|
|
48
|
+
const [whole, fraction = ''] = value.split('.');
|
|
49
|
+
const units = Number(whole) * 1000 + Number(fraction.padEnd(3, '0'));
|
|
50
|
+
if (!Number.isSafeInteger(units) || units > 2147483647)
|
|
51
|
+
throw new ConsumedFuelInputError(field, 'QUANTITY');
|
|
52
|
+
return units;
|
|
53
|
+
}
|
|
54
|
+
export function strictFuelInstant(value: string, field: string): string {
|
|
55
|
+
if (value.length < 20 || value.length > 24) throw new ConsumedFuelInputError(field, 'INSTANT');
|
|
56
|
+
const clock = value.slice(0, 19),
|
|
57
|
+
suffix = value.slice(19);
|
|
58
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/.exec(clock);
|
|
59
|
+
if (!match || match[0] !== clock || match[1] === '0000' || !/^(Z|\.\d{1,3}Z)$/.test(suffix))
|
|
60
|
+
throw new ConsumedFuelInputError(field, 'INSTANT');
|
|
61
|
+
const year = Number(match[1]),
|
|
62
|
+
month = Number(match[2]),
|
|
63
|
+
day = Number(match[3]);
|
|
64
|
+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
65
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
66
|
+
if (
|
|
67
|
+
month < 1 ||
|
|
68
|
+
month > 12 ||
|
|
69
|
+
day < 1 ||
|
|
70
|
+
day > (days[month - 1] ?? 0) ||
|
|
71
|
+
Number(match[4]) > 23 ||
|
|
72
|
+
Number(match[5]) > 59 ||
|
|
73
|
+
Number(match[6]) > 59
|
|
74
|
+
)
|
|
75
|
+
throw new ConsumedFuelInputError(field, 'INSTANT');
|
|
76
|
+
const canonical = `${clock}.${(suffix === 'Z' ? '' : suffix.slice(1, -1)).padEnd(3, '0')}Z`;
|
|
77
|
+
const parsed = Date.parse(canonical);
|
|
78
|
+
if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== canonical)
|
|
79
|
+
throw new ConsumedFuelInputError(field, 'INSTANT');
|
|
80
|
+
return canonical;
|
|
81
|
+
}
|
|
82
|
+
function label(value: string, field: string): string | null {
|
|
83
|
+
if (value === '') return null;
|
|
84
|
+
const trimmed = value.trim();
|
|
85
|
+
if (!trimmed || trimmed.length > 255) throw new ConsumedFuelInputError(field, 'SOURCE');
|
|
86
|
+
return trimmed;
|
|
87
|
+
}
|
|
88
|
+
function coverage(value: string, field: string): MtwTripUtilizationCoverage {
|
|
89
|
+
if (value === 'COMPLETE' || value === 'PARTIAL') return value;
|
|
90
|
+
throw new ConsumedFuelInputError(field, 'CHOICE');
|
|
91
|
+
}
|
|
92
|
+
function distanceBasis(value: string): MtwUtilizationDistanceBasis {
|
|
93
|
+
if (value === 'MEASURED' || value === 'PROVIDER_ESTIMATE' || value === 'OPERATOR_ESTIMATE')
|
|
94
|
+
return value;
|
|
95
|
+
throw new ConsumedFuelInputError('distance-basis', 'CHOICE');
|
|
96
|
+
}
|
|
97
|
+
function fuelBasis(value: string): MtwConsumedFuelBasis {
|
|
98
|
+
if (
|
|
99
|
+
value === 'MEASURED_CONSUMPTION' ||
|
|
100
|
+
value === 'ESTIMATED_CONSUMPTION' ||
|
|
101
|
+
value === 'PURCHASED_OR_REFUELLED' ||
|
|
102
|
+
value === 'UNKNOWN'
|
|
103
|
+
)
|
|
104
|
+
return value;
|
|
105
|
+
throw new ConsumedFuelInputError('fuel-basis', 'CHOICE');
|
|
106
|
+
}
|
|
107
|
+
function observation(value: FuelObservationDraft, kind: 'distance' | 'fuel', now: number) {
|
|
108
|
+
const intervalFrom = strictFuelInstant(value.intervalFrom, `${kind}-intervalFrom`);
|
|
109
|
+
const intervalUntil = strictFuelInstant(value.intervalUntil, `${kind}-intervalUntil`);
|
|
110
|
+
if (Date.parse(intervalFrom) >= Date.parse(intervalUntil) || Date.parse(intervalUntil) > now)
|
|
111
|
+
throw new ConsumedFuelInputError(`${kind}-intervalUntil`, 'INTERVAL');
|
|
112
|
+
return {
|
|
113
|
+
intervalFrom,
|
|
114
|
+
intervalUntil,
|
|
115
|
+
coverage: coverage(value.coverage, `${kind}-coverage`),
|
|
116
|
+
sourceReference: label(value.sourceReference, `${kind}-sourceReference`),
|
|
117
|
+
sourceRevision: label(value.sourceRevision, `${kind}-sourceRevision`),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export function consumedFuelInput(
|
|
121
|
+
draft: ConsumedFuelDraft,
|
|
122
|
+
now = Date.now()
|
|
123
|
+
): MtwConsumedFuelPreviewInput {
|
|
124
|
+
if (!Number.isFinite(now)) throw new ConsumedFuelInputError('distance-intervalUntil', 'INSTANT');
|
|
125
|
+
return {
|
|
126
|
+
distance: draft.distance.included
|
|
127
|
+
? {
|
|
128
|
+
...observation(draft.distance, 'distance', now),
|
|
129
|
+
metres: exactMilliUnits(draft.distance.amount, 'distance-amount'),
|
|
130
|
+
basis: distanceBasis(draft.distance.basis),
|
|
131
|
+
}
|
|
132
|
+
: null,
|
|
133
|
+
fuel: draft.fuel.included
|
|
134
|
+
? {
|
|
135
|
+
...observation(draft.fuel, 'fuel', now),
|
|
136
|
+
millilitres: exactMilliUnits(draft.fuel.amount, 'fuel-amount'),
|
|
137
|
+
basis: fuelBasis(draft.fuel.basis),
|
|
138
|
+
}
|
|
139
|
+
: null,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** Display precision is not sensor precision; positive values must never appear as zero. */
|
|
143
|
+
export function formatFuelRatio(value: number, locale?: string): string {
|
|
144
|
+
if (!Number.isFinite(value) || value < 0) throw new Error('Invalid ratio');
|
|
145
|
+
if (value > 0 && value < 0.01)
|
|
146
|
+
return `<${new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(0.01)} L/100 km`;
|
|
147
|
+
return `${value > 0 ? '≈ ' : ''}${new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value)} L/100 km`;
|
|
148
|
+
}
|