@burdenoff/microfe-movethewheels 2026.1011.1 → 2026.1011.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/MoveTheWheelsRoot.js +5 -0
- package/dist/MoveTheWheelsRoutes.js +54 -50
- package/dist/generated/shipment-status-authority-operations.d.ts +4473 -0
- package/dist/hooks/useEpod.js +85 -77
- package/dist/hooks/useShipmentStatusAuthority.js +127 -0
- package/dist/hooks/useTracking.js +15 -14
- package/dist/hooks/useTrackingEventQuality.js +43 -17
- package/dist/movethewheels/hooks/useEpod.d.ts +23 -0
- package/dist/movethewheels/hooks/useShipmentStatusAuthority.d.ts +23 -0
- package/dist/movethewheels/hooks/useTrackingEventQuality.d.ts +5 -0
- package/dist/movethewheels/pages/ShipmentStatusAuthorityPage.d.ts +1 -0
- package/dist/movethewheels/utils/shipmentStatusDraft.d.ts +45 -0
- package/dist/pages/DeliveryCapturePage.js +181 -116
- package/dist/pages/ShipmentStatusAuthorityPage.js +563 -0
- package/dist/pages/TrackingEventQualityPage.js +6 -1
- package/dist/src/generated/shipment-status-authority-operations.js +86 -0
- package/dist/utils/shipmentStatusDraft.js +41 -0
- package/package.json +5 -4
- package/src/generated/shipment-status-authority-operations.ts +5503 -0
- package/src/movethewheels/MoveTheWheelsRoot.tsx +6 -0
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +2 -0
- package/src/movethewheels/hooks/useEpod.ts +47 -12
- package/src/movethewheels/hooks/useShipmentStatusAuthority.ts +179 -0
- package/src/movethewheels/hooks/useTracking.ts +27 -12
- package/src/movethewheels/hooks/useTrackingEventQuality.ts +42 -0
- package/src/movethewheels/pages/DeliveryCapturePage.tsx +113 -6
- package/src/movethewheels/pages/ShipmentStatusAuthorityPage.tsx +639 -0
- package/src/movethewheels/pages/TrackingEventQualityPage.tsx +13 -2
- package/src/movethewheels/utils/shipmentStatusDraft.ts +118 -0
- package/src/operations/shipment-status-authority/status.graphql +80 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ClipboardList } from 'lucide-react';
|
|
3
|
+
import { PermissionProvider, usePermissions } from '@burdenoff/fe-libs/shared/providers/shell';
|
|
4
|
+
import { PageLayout } from '../components/PageLayout';
|
|
5
|
+
import { Button, Input, Label, Textarea } from '../components/ui';
|
|
6
|
+
import { reuseCard, reuseSelect, ReuseState } from '../components/ReuseControls';
|
|
7
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
8
|
+
import {
|
|
9
|
+
useShipmentStatusCommand,
|
|
10
|
+
useShipmentStatusQuery,
|
|
11
|
+
} from '../hooks/useShipmentStatusAuthority';
|
|
12
|
+
import {
|
|
13
|
+
shipmentSourceInput,
|
|
14
|
+
sourceStatusAcknowledged,
|
|
15
|
+
manualStatusAcknowledged,
|
|
16
|
+
statusDecisionReason,
|
|
17
|
+
type ShipmentSourceDraft,
|
|
18
|
+
} from '../utils/shipmentStatusDraft';
|
|
19
|
+
import {
|
|
20
|
+
StatusAuthorityShipmentsDocument,
|
|
21
|
+
StatusAuthorityDetailDocument,
|
|
22
|
+
StatusAuthoritySourcesDocument,
|
|
23
|
+
StatusAuthorityConfigureDocument,
|
|
24
|
+
StatusAuthorityManualDocument,
|
|
25
|
+
} from '../../generated/shipment-status-authority-operations';
|
|
26
|
+
import type {
|
|
27
|
+
StatusAuthorityShipmentsQuery,
|
|
28
|
+
StatusAuthorityDetailQuery,
|
|
29
|
+
StatusAuthoritySourcesQuery,
|
|
30
|
+
StatusAuthorityConfigureMutation,
|
|
31
|
+
StatusAuthorityManualMutation,
|
|
32
|
+
ShipmentStatus,
|
|
33
|
+
} from '../../generated/shipment-status-authority-operations';
|
|
34
|
+
|
|
35
|
+
const LIMIT = 20;
|
|
36
|
+
const STATUSES: ShipmentStatus[] = [
|
|
37
|
+
'PLANNED',
|
|
38
|
+
'PICKED_UP',
|
|
39
|
+
'IN_TRANSIT',
|
|
40
|
+
'DELIVERED',
|
|
41
|
+
'FAILED',
|
|
42
|
+
'RETURNED',
|
|
43
|
+
];
|
|
44
|
+
const EMPTY_SOURCE: ShipmentSourceDraft = {
|
|
45
|
+
integrationId: '',
|
|
46
|
+
basisTrackingEventId: '',
|
|
47
|
+
maxSourceAgeSeconds: '',
|
|
48
|
+
maxFutureSkewSeconds: '',
|
|
49
|
+
reason: '',
|
|
50
|
+
};
|
|
51
|
+
function label(value: string | null | undefined) {
|
|
52
|
+
return value ? value.toLowerCase().replaceAll('_', ' ') : 'Not recorded';
|
|
53
|
+
}
|
|
54
|
+
function time(value: string | null | undefined) {
|
|
55
|
+
if (!value) return 'Not recorded';
|
|
56
|
+
const parsed = new Date(value);
|
|
57
|
+
return Number.isFinite(parsed.getTime()) ? parsed.toLocaleString() : 'Unverified time';
|
|
58
|
+
}
|
|
59
|
+
function Pager({
|
|
60
|
+
offset,
|
|
61
|
+
length,
|
|
62
|
+
busy,
|
|
63
|
+
change,
|
|
64
|
+
}: {
|
|
65
|
+
offset: number;
|
|
66
|
+
length: number | undefined;
|
|
67
|
+
busy: boolean;
|
|
68
|
+
change: (offset: number) => void;
|
|
69
|
+
}) {
|
|
70
|
+
return (
|
|
71
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
72
|
+
<Button
|
|
73
|
+
variant="outline"
|
|
74
|
+
disabled={busy || offset === 0}
|
|
75
|
+
onClick={() => change(Math.max(0, offset - LIMIT))}
|
|
76
|
+
>
|
|
77
|
+
Previous page
|
|
78
|
+
</Button>
|
|
79
|
+
<span>{length === undefined ? 'Page unavailable' : `${length} records on this page`}</span>
|
|
80
|
+
<Button
|
|
81
|
+
variant="outline"
|
|
82
|
+
disabled={busy || length !== LIMIT || offset >= 9980}
|
|
83
|
+
onClick={() => change(offset + LIMIT)}
|
|
84
|
+
>
|
|
85
|
+
Next page
|
|
86
|
+
</Button>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export default function ShipmentStatusAuthorityPage() {
|
|
92
|
+
const { workspaceId, tenantId, currentUser, apiGatewayUrl } = useMoveTheWheels();
|
|
93
|
+
return (
|
|
94
|
+
<PageLayout
|
|
95
|
+
title="Shipment status authority"
|
|
96
|
+
subtitle="Keep recorded status separate from incoming tracking evidence."
|
|
97
|
+
icon={<ClipboardList aria-hidden="true" />}
|
|
98
|
+
>
|
|
99
|
+
<PermissionProvider scopeId={workspaceId ?? null} gateway="workspace">
|
|
100
|
+
<StatusWorkbench key={`${workspaceId}:${tenantId}:${currentUser?.id}:${apiGatewayUrl}`} />
|
|
101
|
+
</PermissionProvider>
|
|
102
|
+
</PageLayout>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function StatusWorkbench() {
|
|
107
|
+
const permissions = usePermissions();
|
|
108
|
+
const [offset, setOffset] = useState(0);
|
|
109
|
+
const [selected, setSelected] = useState('');
|
|
110
|
+
const [search, setSearch] = useState('');
|
|
111
|
+
const [filter, setFilter] = useState('');
|
|
112
|
+
const shipments = useShipmentStatusQuery<StatusAuthorityShipmentsQuery>(
|
|
113
|
+
StatusAuthorityShipmentsDocument,
|
|
114
|
+
permissions.hasPermission('shipment:read')
|
|
115
|
+
? { filter: { search: filter || undefined }, pagination: { limit: LIMIT, offset } }
|
|
116
|
+
: null
|
|
117
|
+
);
|
|
118
|
+
return (
|
|
119
|
+
<div className="space-y-6">
|
|
120
|
+
<p>
|
|
121
|
+
Incoming evidence can be accepted while its status change is held. A recorded software
|
|
122
|
+
status is not physical proof of delivery.
|
|
123
|
+
</p>
|
|
124
|
+
<ReuseState loading={permissions.isLoading} error={permissions.error?.message ?? null} />
|
|
125
|
+
{!permissions.isLoading && !permissions.hasPermission('shipment:read') && (
|
|
126
|
+
<p role="status">Shipment read permission is required.</p>
|
|
127
|
+
)}
|
|
128
|
+
{permissions.hasPermission('shipment:read') && (
|
|
129
|
+
<section className={reuseCard} aria-label="Shipment selection">
|
|
130
|
+
<form
|
|
131
|
+
className="flex flex-wrap gap-3"
|
|
132
|
+
onSubmit={(event) => {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
setFilter(search.trim());
|
|
135
|
+
setOffset(0);
|
|
136
|
+
setSelected('');
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<Label htmlFor="status-search">Shipment search</Label>
|
|
140
|
+
<Input
|
|
141
|
+
id="status-search"
|
|
142
|
+
value={search}
|
|
143
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
144
|
+
maxLength={200}
|
|
145
|
+
/>
|
|
146
|
+
<Button type="submit">Search shipments</Button>
|
|
147
|
+
</form>
|
|
148
|
+
<ReuseState loading={shipments.loading} error={shipments.error} />
|
|
149
|
+
<label className="block">
|
|
150
|
+
Shipment
|
|
151
|
+
<select
|
|
152
|
+
className={reuseSelect}
|
|
153
|
+
value={selected}
|
|
154
|
+
disabled={shipments.loading || !shipments.data}
|
|
155
|
+
onChange={(event) => setSelected(event.target.value)}
|
|
156
|
+
>
|
|
157
|
+
<option value="">Choose a shipment</option>
|
|
158
|
+
{shipments.data?.shipments.map((shipment) => (
|
|
159
|
+
<option key={shipment.id} value={shipment.id}>
|
|
160
|
+
{shipment.shipmentNumber} · {label(shipment.status)}
|
|
161
|
+
</option>
|
|
162
|
+
))}
|
|
163
|
+
</select>
|
|
164
|
+
</label>
|
|
165
|
+
{shipments.data?.shipments.length === 0 && <p>No shipments on this page.</p>}
|
|
166
|
+
<Pager
|
|
167
|
+
offset={offset}
|
|
168
|
+
length={shipments.data?.shipments.length}
|
|
169
|
+
busy={shipments.loading}
|
|
170
|
+
change={(next) => {
|
|
171
|
+
setOffset(next);
|
|
172
|
+
setSelected('');
|
|
173
|
+
}}
|
|
174
|
+
/>
|
|
175
|
+
</section>
|
|
176
|
+
)}
|
|
177
|
+
{selected && permissions.hasPermission('shipment:read') && (
|
|
178
|
+
<StatusDetail
|
|
179
|
+
key={selected}
|
|
180
|
+
id={selected}
|
|
181
|
+
canUpdate={permissions.hasPermission('shipment:update')}
|
|
182
|
+
canReadSources={permissions.hasPermission('integration:read')}
|
|
183
|
+
/>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function StatusDetail({
|
|
190
|
+
id,
|
|
191
|
+
canUpdate,
|
|
192
|
+
canReadSources,
|
|
193
|
+
}: {
|
|
194
|
+
id: string;
|
|
195
|
+
canUpdate: boolean;
|
|
196
|
+
canReadSources: boolean;
|
|
197
|
+
}) {
|
|
198
|
+
const [refresh, setRefresh] = useState(0);
|
|
199
|
+
const [offset, setOffset] = useState(0);
|
|
200
|
+
const [sourceOffset, setSourceOffset] = useState(0);
|
|
201
|
+
const [evidenceOffset, setEvidenceOffset] = useState(0);
|
|
202
|
+
const action = useShipmentStatusCommand();
|
|
203
|
+
const detail = useShipmentStatusQuery<StatusAuthorityDetailQuery>(
|
|
204
|
+
StatusAuthorityDetailDocument,
|
|
205
|
+
{
|
|
206
|
+
id,
|
|
207
|
+
pagination: { limit: LIMIT, offset },
|
|
208
|
+
evidencePagination: { limit: LIMIT, offset: evidenceOffset },
|
|
209
|
+
},
|
|
210
|
+
refresh + action.revision
|
|
211
|
+
);
|
|
212
|
+
const sources = useShipmentStatusQuery<StatusAuthoritySourcesQuery>(
|
|
213
|
+
StatusAuthoritySourcesDocument,
|
|
214
|
+
canUpdate && canReadSources ? { pagination: { limit: LIMIT, offset: sourceOffset } } : null
|
|
215
|
+
);
|
|
216
|
+
const shipment = detail.data?.shipment;
|
|
217
|
+
// Forms stay mounted during refresh, so a stale response never erases operator input.
|
|
218
|
+
const [source, setSource] = useState<ShipmentSourceDraft>(EMPTY_SOURCE);
|
|
219
|
+
const [manualStatus, setManualStatus] = useState<ShipmentStatus | ''>('');
|
|
220
|
+
const [manualReason, setManualReason] = useState('');
|
|
221
|
+
const [formRevision, setFormRevision] = useState<number | null>(null);
|
|
222
|
+
const [formError, setFormError] = useState<string | null>(null);
|
|
223
|
+
const [reviewed, setReviewed] = useState(false);
|
|
224
|
+
const [requiredRefresh, setRequiredRefresh] = useState(0);
|
|
225
|
+
const freshReviewAvailable =
|
|
226
|
+
refresh >= requiredRefresh && !!shipment && !detail.loading && !detail.error;
|
|
227
|
+
const stale =
|
|
228
|
+
formRevision !== null && shipment != null && formRevision !== shipment.statusRevision;
|
|
229
|
+
const uncertain = action.feedback?.kind === 'UNKNOWN';
|
|
230
|
+
const blocked =
|
|
231
|
+
action.busy ||
|
|
232
|
+
detail.loading ||
|
|
233
|
+
!shipment ||
|
|
234
|
+
formRevision === null ||
|
|
235
|
+
stale ||
|
|
236
|
+
uncertain ||
|
|
237
|
+
!freshReviewAvailable;
|
|
238
|
+
const basis =
|
|
239
|
+
shipment?.trackingEvents.filter(
|
|
240
|
+
(event) =>
|
|
241
|
+
event.sourceIntegrationId === source.integrationId &&
|
|
242
|
+
event.sourceTimestampProvenance === 'PROVIDER_TIME_VALIDATED' &&
|
|
243
|
+
event.providerEventTime &&
|
|
244
|
+
event.eventType !== 'CREATED'
|
|
245
|
+
) ?? [];
|
|
246
|
+
const changeSource = (field: keyof ShipmentSourceDraft, value: string) =>
|
|
247
|
+
setSource((current) => ({ ...current, [field]: value }));
|
|
248
|
+
return (
|
|
249
|
+
<div className="space-y-6">
|
|
250
|
+
<section className={reuseCard} aria-label="Recorded shipment status">
|
|
251
|
+
<div className="flex flex-wrap justify-between gap-3">
|
|
252
|
+
<h2 className="font-semibold">Recorded status</h2>
|
|
253
|
+
<Button
|
|
254
|
+
variant="outline"
|
|
255
|
+
disabled={detail.loading || action.busy}
|
|
256
|
+
onClick={() => {
|
|
257
|
+
setReviewed(false);
|
|
258
|
+
setRefresh((value) => value + 1);
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
Refresh recorded status
|
|
262
|
+
</Button>
|
|
263
|
+
</div>
|
|
264
|
+
<ReuseState loading={detail.loading} error={detail.error} />
|
|
265
|
+
{shipment === null && <p>Shipment unavailable in this workspace.</p>}
|
|
266
|
+
{shipment && (
|
|
267
|
+
<dl className="grid gap-3 sm:grid-cols-2 break-words">
|
|
268
|
+
<div>
|
|
269
|
+
<dt>Shipment</dt>
|
|
270
|
+
<dd>{shipment.shipmentNumber}</dd>
|
|
271
|
+
</div>
|
|
272
|
+
<div>
|
|
273
|
+
<dt>Status</dt>
|
|
274
|
+
<dd>{label(shipment.status)}</dd>
|
|
275
|
+
</div>
|
|
276
|
+
<div>
|
|
277
|
+
<dt>Status revision</dt>
|
|
278
|
+
<dd>{shipment.statusRevision}</dd>
|
|
279
|
+
</div>
|
|
280
|
+
<div>
|
|
281
|
+
<dt>Authority</dt>
|
|
282
|
+
<dd>{label(shipment.statusAuthority)}</dd>
|
|
283
|
+
</div>
|
|
284
|
+
<div>
|
|
285
|
+
<dt>Chosen integration</dt>
|
|
286
|
+
<dd className="break-all">{shipment.designatedIntegrationId ?? 'None selected'}</dd>
|
|
287
|
+
</div>
|
|
288
|
+
<div>
|
|
289
|
+
<dt>Basis source time</dt>
|
|
290
|
+
<dd>{time(shipment.statusBasisSourceTime)}</dd>
|
|
291
|
+
</div>
|
|
292
|
+
<div>
|
|
293
|
+
<dt>Maximum source age</dt>
|
|
294
|
+
<dd>
|
|
295
|
+
{shipment.maxSourceAgeSeconds === null
|
|
296
|
+
? 'No active source policy'
|
|
297
|
+
: `${shipment.maxSourceAgeSeconds} seconds`}
|
|
298
|
+
</dd>
|
|
299
|
+
</div>
|
|
300
|
+
<div>
|
|
301
|
+
<dt>Allowed future skew</dt>
|
|
302
|
+
<dd>
|
|
303
|
+
{shipment.maxFutureSkewSeconds === null
|
|
304
|
+
? 'No active source policy'
|
|
305
|
+
: `${shipment.maxFutureSkewSeconds} seconds`}
|
|
306
|
+
</dd>
|
|
307
|
+
</div>
|
|
308
|
+
</dl>
|
|
309
|
+
)}
|
|
310
|
+
</section>
|
|
311
|
+
{action.feedback && <p role="status">{action.feedback.message}</p>}
|
|
312
|
+
{formError && <p role="alert">{formError}</p>}
|
|
313
|
+
{!canUpdate && (
|
|
314
|
+
<p role="status">
|
|
315
|
+
Read-only access. Shipment update permission is required to choose a source or record a
|
|
316
|
+
correction.
|
|
317
|
+
</p>
|
|
318
|
+
)}
|
|
319
|
+
{canUpdate && (
|
|
320
|
+
<section className={reuseCard} aria-label="Status decisions">
|
|
321
|
+
<p>
|
|
322
|
+
Review the current status, then explicitly use its revision for a new decision. Clock
|
|
323
|
+
limits are your policy, not carrier certification.
|
|
324
|
+
</p>
|
|
325
|
+
{stale && (
|
|
326
|
+
<p role="alert">
|
|
327
|
+
Status changed since this form opened. Your input is preserved. Review the current
|
|
328
|
+
recorded status before applying.
|
|
329
|
+
</p>
|
|
330
|
+
)}
|
|
331
|
+
{!uncertain && (
|
|
332
|
+
<Button
|
|
333
|
+
variant="outline"
|
|
334
|
+
disabled={!freshReviewAvailable || action.busy}
|
|
335
|
+
onClick={() => {
|
|
336
|
+
if (shipment) {
|
|
337
|
+
setFormRevision(shipment.statusRevision);
|
|
338
|
+
setFormError(null);
|
|
339
|
+
action.beginSeparateAction();
|
|
340
|
+
}
|
|
341
|
+
}}
|
|
342
|
+
>
|
|
343
|
+
Use reviewed revision {shipment?.statusRevision ?? '—'}
|
|
344
|
+
</Button>
|
|
345
|
+
)}
|
|
346
|
+
{uncertain && (
|
|
347
|
+
<div className="space-y-3">
|
|
348
|
+
<label className="flex items-start gap-2">
|
|
349
|
+
<input
|
|
350
|
+
type="checkbox"
|
|
351
|
+
disabled={!freshReviewAvailable}
|
|
352
|
+
checked={reviewed}
|
|
353
|
+
onChange={(event) => setReviewed(event.target.checked)}
|
|
354
|
+
/>
|
|
355
|
+
I reviewed the refreshed recorded status and decision history.
|
|
356
|
+
</label>
|
|
357
|
+
<Button
|
|
358
|
+
variant="outline"
|
|
359
|
+
disabled={!reviewed || !freshReviewAvailable || action.busy}
|
|
360
|
+
onClick={() => {
|
|
361
|
+
if (shipment) {
|
|
362
|
+
setSource(EMPTY_SOURCE);
|
|
363
|
+
setManualStatus('');
|
|
364
|
+
setManualReason('');
|
|
365
|
+
setFormRevision(shipment.statusRevision);
|
|
366
|
+
setFormError(null);
|
|
367
|
+
setReviewed(false);
|
|
368
|
+
action.beginSeparateAction();
|
|
369
|
+
}
|
|
370
|
+
}}
|
|
371
|
+
>
|
|
372
|
+
Begin a separate new action
|
|
373
|
+
</Button>
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
376
|
+
<form
|
|
377
|
+
className="space-y-3"
|
|
378
|
+
onSubmit={async (event) => {
|
|
379
|
+
event.preventDefault();
|
|
380
|
+
setFormError(null);
|
|
381
|
+
if (blocked || formRevision === null || !shipment) return;
|
|
382
|
+
try {
|
|
383
|
+
const input = shipmentSourceInput(source, formRevision);
|
|
384
|
+
const result = await action.run<StatusAuthorityConfigureMutation>(
|
|
385
|
+
StatusAuthorityConfigureDocument,
|
|
386
|
+
{ id, input },
|
|
387
|
+
(data) =>
|
|
388
|
+
sourceStatusAcknowledged(data.configureShipmentStatusSource, {
|
|
389
|
+
id,
|
|
390
|
+
epoch: shipment.statusAuthorityEpoch,
|
|
391
|
+
input,
|
|
392
|
+
basis: basis.find((event) => event.id === input.basisTrackingEventId),
|
|
393
|
+
})
|
|
394
|
+
);
|
|
395
|
+
if (result) setFormRevision(null);
|
|
396
|
+
else {
|
|
397
|
+
setReviewed(false);
|
|
398
|
+
setRequiredRefresh(refresh + 1);
|
|
399
|
+
}
|
|
400
|
+
} catch (failure) {
|
|
401
|
+
setFormError(
|
|
402
|
+
failure instanceof Error ? failure.message : 'Review the source policy.'
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}}
|
|
406
|
+
>
|
|
407
|
+
<fieldset disabled={blocked || !canReadSources} className="space-y-3">
|
|
408
|
+
<legend className="font-semibold">Choose a status source</legend>
|
|
409
|
+
{!canReadSources && (
|
|
410
|
+
<p>Integration read permission is required for source selection.</p>
|
|
411
|
+
)}
|
|
412
|
+
<ReuseState loading={sources.loading} error={sources.error} />
|
|
413
|
+
<label className="block">
|
|
414
|
+
Integration
|
|
415
|
+
<select
|
|
416
|
+
className={reuseSelect}
|
|
417
|
+
value={source.integrationId}
|
|
418
|
+
onChange={(event) => {
|
|
419
|
+
changeSource('integrationId', event.target.value);
|
|
420
|
+
changeSource('basisTrackingEventId', '');
|
|
421
|
+
}}
|
|
422
|
+
>
|
|
423
|
+
<option value="">Choose an integration</option>
|
|
424
|
+
{sources.data?.integrations
|
|
425
|
+
.filter((integration) => integration.status === 'ACTIVE')
|
|
426
|
+
.map((integration) => (
|
|
427
|
+
<option key={integration.id} value={integration.id}>
|
|
428
|
+
{integration.name} · {integration.provider}
|
|
429
|
+
</option>
|
|
430
|
+
))}
|
|
431
|
+
</select>
|
|
432
|
+
</label>
|
|
433
|
+
<label className="block">
|
|
434
|
+
Recorded basis event
|
|
435
|
+
<select
|
|
436
|
+
className={reuseSelect}
|
|
437
|
+
value={source.basisTrackingEventId}
|
|
438
|
+
onChange={(event) => changeSource('basisTrackingEventId', event.target.value)}
|
|
439
|
+
>
|
|
440
|
+
<option value="">Choose an existing basis event</option>
|
|
441
|
+
{basis.map((event) => (
|
|
442
|
+
<option key={event.id} value={event.id}>
|
|
443
|
+
{label(event.eventType)} · {time(event.providerEventTime)} · {event.id}
|
|
444
|
+
</option>
|
|
445
|
+
))}
|
|
446
|
+
</select>
|
|
447
|
+
</label>
|
|
448
|
+
<p>
|
|
449
|
+
This page lists evidence from the selected source with validated provider time. The
|
|
450
|
+
server checks its recorded receipt, source and clock window before applying.
|
|
451
|
+
</p>
|
|
452
|
+
<Label htmlFor="status-source-age">Maximum source age (seconds)</Label>
|
|
453
|
+
<Input
|
|
454
|
+
id="status-source-age"
|
|
455
|
+
inputMode="numeric"
|
|
456
|
+
value={source.maxSourceAgeSeconds}
|
|
457
|
+
onChange={(event) => changeSource('maxSourceAgeSeconds', event.target.value)}
|
|
458
|
+
/>
|
|
459
|
+
<Label htmlFor="status-source-skew">Allowed future skew (seconds)</Label>
|
|
460
|
+
<Input
|
|
461
|
+
id="status-source-skew"
|
|
462
|
+
inputMode="numeric"
|
|
463
|
+
value={source.maxFutureSkewSeconds}
|
|
464
|
+
onChange={(event) => changeSource('maxFutureSkewSeconds', event.target.value)}
|
|
465
|
+
/>
|
|
466
|
+
<Label htmlFor="status-source-reason">Source choice reason</Label>
|
|
467
|
+
<Textarea
|
|
468
|
+
id="status-source-reason"
|
|
469
|
+
maxLength={1000}
|
|
470
|
+
value={source.reason}
|
|
471
|
+
onChange={(event) => changeSource('reason', event.target.value)}
|
|
472
|
+
/>
|
|
473
|
+
<Button type="submit">Apply source choice</Button>
|
|
474
|
+
</fieldset>
|
|
475
|
+
</form>
|
|
476
|
+
{canReadSources && (
|
|
477
|
+
<Pager
|
|
478
|
+
offset={sourceOffset}
|
|
479
|
+
length={sources.data?.integrations.length}
|
|
480
|
+
busy={sources.loading || action.busy}
|
|
481
|
+
change={(next) => {
|
|
482
|
+
setSourceOffset(next);
|
|
483
|
+
changeSource('integrationId', '');
|
|
484
|
+
changeSource('basisTrackingEventId', '');
|
|
485
|
+
}}
|
|
486
|
+
/>
|
|
487
|
+
)}
|
|
488
|
+
<form
|
|
489
|
+
className="space-y-3"
|
|
490
|
+
onSubmit={async (event) => {
|
|
491
|
+
event.preventDefault();
|
|
492
|
+
setFormError(null);
|
|
493
|
+
if (blocked || formRevision === null || !shipment) return;
|
|
494
|
+
try {
|
|
495
|
+
if (!manualStatus) throw new Error('Choose a recorded status for this correction.');
|
|
496
|
+
const result = await action.run<StatusAuthorityManualMutation>(
|
|
497
|
+
StatusAuthorityManualDocument,
|
|
498
|
+
{
|
|
499
|
+
id,
|
|
500
|
+
input: {
|
|
501
|
+
status: manualStatus,
|
|
502
|
+
expectedStatusRevision: formRevision,
|
|
503
|
+
statusReason: statusDecisionReason(manualReason),
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
(data) =>
|
|
507
|
+
manualStatusAcknowledged(data.updateShipment, {
|
|
508
|
+
id,
|
|
509
|
+
revision: formRevision,
|
|
510
|
+
epoch: shipment.statusAuthorityEpoch,
|
|
511
|
+
status: manualStatus,
|
|
512
|
+
})
|
|
513
|
+
);
|
|
514
|
+
if (result) setFormRevision(null);
|
|
515
|
+
else {
|
|
516
|
+
setReviewed(false);
|
|
517
|
+
setRequiredRefresh(refresh + 1);
|
|
518
|
+
}
|
|
519
|
+
} catch (failure) {
|
|
520
|
+
setFormError(failure instanceof Error ? failure.message : 'Review the correction.');
|
|
521
|
+
}
|
|
522
|
+
}}
|
|
523
|
+
>
|
|
524
|
+
<fieldset disabled={blocked} className="space-y-3">
|
|
525
|
+
<legend className="font-semibold">Record a manual correction</legend>
|
|
526
|
+
<p>
|
|
527
|
+
A manual correction puts automatic tracking projection on hold. Existing evidence
|
|
528
|
+
remains unchanged.
|
|
529
|
+
</p>
|
|
530
|
+
<label className="block">
|
|
531
|
+
Recorded status
|
|
532
|
+
<select
|
|
533
|
+
className={reuseSelect}
|
|
534
|
+
value={manualStatus}
|
|
535
|
+
onChange={(event) => {
|
|
536
|
+
const selectedStatus = STATUSES.find((status) => status === event.target.value);
|
|
537
|
+
setManualStatus(selectedStatus ?? '');
|
|
538
|
+
}}
|
|
539
|
+
>
|
|
540
|
+
<option value="">Choose a status</option>
|
|
541
|
+
{STATUSES.map((status) => (
|
|
542
|
+
<option key={status} value={status}>
|
|
543
|
+
{label(status)}
|
|
544
|
+
</option>
|
|
545
|
+
))}
|
|
546
|
+
</select>
|
|
547
|
+
</label>
|
|
548
|
+
<Label htmlFor="status-manual-reason">Correction reason</Label>
|
|
549
|
+
<Textarea
|
|
550
|
+
id="status-manual-reason"
|
|
551
|
+
value={manualReason}
|
|
552
|
+
maxLength={1000}
|
|
553
|
+
onChange={(event) => setManualReason(event.target.value)}
|
|
554
|
+
/>
|
|
555
|
+
<Button type="submit">Record correction and hold</Button>
|
|
556
|
+
</fieldset>
|
|
557
|
+
</form>
|
|
558
|
+
</section>
|
|
559
|
+
)}
|
|
560
|
+
<section className={reuseCard} aria-label="Shipment status decision history">
|
|
561
|
+
<h2 className="font-semibold">Decision history</h2>
|
|
562
|
+
{detail.data?.shipmentStatusDecisions.length === 0 && (
|
|
563
|
+
<p>
|
|
564
|
+
No recorded status decisions on this page. Legacy evidence may have no decision ledger.
|
|
565
|
+
</p>
|
|
566
|
+
)}
|
|
567
|
+
<div className="space-y-3">
|
|
568
|
+
{detail.data?.shipmentStatusDecisions.map((decision) => (
|
|
569
|
+
<article key={decision.id} className="border rounded-md p-3 break-words">
|
|
570
|
+
<p>
|
|
571
|
+
{label(decision.origin)} · {label(decision.outcome)}
|
|
572
|
+
{decision.heldReason ? `: ${label(decision.heldReason)}` : ''}
|
|
573
|
+
</p>
|
|
574
|
+
<p>
|
|
575
|
+
{label(decision.priorStatus)} → {label(decision.resultStatus)} · revision{' '}
|
|
576
|
+
{decision.observedRevision} → {decision.resultRevision}
|
|
577
|
+
</p>
|
|
578
|
+
<p>{time(decision.recordedAt)}</p>
|
|
579
|
+
{decision.reason && <p>{decision.reason}</p>}
|
|
580
|
+
{decision.trackingEventId && (
|
|
581
|
+
<p className="break-all">Evidence: {decision.trackingEventId}</p>
|
|
582
|
+
)}
|
|
583
|
+
{decision.proofId && <p className="break-all">Delivery proof: {decision.proofId}</p>}
|
|
584
|
+
{decision.configuredIntegrationId && (
|
|
585
|
+
<p className="break-all">
|
|
586
|
+
Chosen integration for this decision: {decision.configuredIntegrationId}
|
|
587
|
+
</p>
|
|
588
|
+
)}
|
|
589
|
+
{decision.configuredMaxSourceAgeSeconds !== null && (
|
|
590
|
+
<p>
|
|
591
|
+
Source age limit for this decision: {decision.configuredMaxSourceAgeSeconds}{' '}
|
|
592
|
+
seconds
|
|
593
|
+
</p>
|
|
594
|
+
)}
|
|
595
|
+
{decision.configuredMaxFutureSkewSeconds !== null && (
|
|
596
|
+
<p>
|
|
597
|
+
Future skew limit for this decision: {decision.configuredMaxFutureSkewSeconds}{' '}
|
|
598
|
+
seconds
|
|
599
|
+
</p>
|
|
600
|
+
)}
|
|
601
|
+
</article>
|
|
602
|
+
))}
|
|
603
|
+
</div>
|
|
604
|
+
<Pager
|
|
605
|
+
offset={offset}
|
|
606
|
+
length={detail.data?.shipmentStatusDecisions.length}
|
|
607
|
+
busy={detail.loading || action.busy}
|
|
608
|
+
change={(next) => {
|
|
609
|
+
setOffset(next);
|
|
610
|
+
}}
|
|
611
|
+
/>
|
|
612
|
+
</section>
|
|
613
|
+
<section className={reuseCard} aria-label="Recorded tracking evidence">
|
|
614
|
+
<h2 className="font-semibold">Tracking evidence</h2>
|
|
615
|
+
<div className="space-y-3">
|
|
616
|
+
{shipment?.trackingEvents.map((event) => (
|
|
617
|
+
<article key={event.id} className="border rounded-md p-3 break-words">
|
|
618
|
+
<p>
|
|
619
|
+
{label(event.eventType)} · {label(event.sourceTimestampProvenance)}
|
|
620
|
+
</p>
|
|
621
|
+
<p>Provider time: {time(event.providerEventTime)}</p>
|
|
622
|
+
<p>Received: {time(event.serverReceivedAt)}</p>
|
|
623
|
+
<p className="break-all">Evidence: {event.id}</p>
|
|
624
|
+
</article>
|
|
625
|
+
))}
|
|
626
|
+
</div>
|
|
627
|
+
<Pager
|
|
628
|
+
offset={evidenceOffset}
|
|
629
|
+
length={shipment?.trackingEvents.length}
|
|
630
|
+
busy={detail.loading || action.busy}
|
|
631
|
+
change={(next) => {
|
|
632
|
+
setEvidenceOffset(next);
|
|
633
|
+
changeSource('basisTrackingEventId', '');
|
|
634
|
+
}}
|
|
635
|
+
/>
|
|
636
|
+
</section>
|
|
637
|
+
</div>
|
|
638
|
+
);
|
|
639
|
+
}
|
|
@@ -168,6 +168,16 @@ function QualityWorkbench() {
|
|
|
168
168
|
{outcome.errorCode && (
|
|
169
169
|
<p>Reason: {outcome.errorCode.replaceAll('_', ' ').toLowerCase()}</p>
|
|
170
170
|
)}
|
|
171
|
+
{(outcome.status === 'ACCEPTED' || outcome.status === 'DUPLICATE') && (
|
|
172
|
+
<p>
|
|
173
|
+
Status decision:{' '}
|
|
174
|
+
{outcome.projection?.status === 'APPLIED'
|
|
175
|
+
? 'Applied to the recorded shipment status'
|
|
176
|
+
: outcome.projection?.status === 'HELD'
|
|
177
|
+
? `Held — ${outcome.projection.heldReason?.replaceAll('_', ' ').toLowerCase()}`
|
|
178
|
+
: 'No status decision recorded in this legacy result'}
|
|
179
|
+
</p>
|
|
180
|
+
)}
|
|
171
181
|
{outcome.status === 'CONFLICT' && (
|
|
172
182
|
<p>The earlier receipt stays unchanged; this update adds no event.</p>
|
|
173
183
|
)}
|
|
@@ -198,8 +208,9 @@ function QualityWorkbench() {
|
|
|
198
208
|
<QualityShipment key={shipmentId} id={shipmentId} onClose={() => setShipmentId(null)} />
|
|
199
209
|
)}
|
|
200
210
|
<p className="text-sm text-text-secondary">
|
|
201
|
-
These results describe recorded ingestion, not physical shipment truth.
|
|
202
|
-
|
|
211
|
+
These results describe recorded ingestion, not physical shipment truth. The chosen-source
|
|
212
|
+
clock policy and manual corrections control recorded shipment status. Provider corrections
|
|
213
|
+
and replay need separate reviewed policies.
|
|
203
214
|
</p>
|
|
204
215
|
</div>
|
|
205
216
|
);
|