@burdenoff/microfe-movethewheels 2026.1016.13 → 2026.1017.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/hooks/useFleet.js +7 -0
- package/dist/hooks/useOrderDispatch.js +93 -0
- package/dist/hooks/useOrders.js +148 -134
- package/dist/movethewheels/hooks/useOrderDispatch.d.ts +26 -0
- package/dist/movethewheels/hooks/useOrders.d.ts +2 -1
- package/dist/movethewheels/utils/graphqlClient.d.ts +2 -1
- package/dist/movethewheels/utils/orderVehicleHold.d.ts +16 -0
- package/dist/pages/OrderDetailsPage.js +269 -241
- package/dist/utils/graphqlClient.js +72 -65
- package/dist/utils/orderVehicleHold.js +38 -0
- package/package.json +3 -2
- package/src/movethewheels/hooks/useFleet.ts +9 -0
- package/src/movethewheels/hooks/useOrderDispatch.ts +196 -0
- package/src/movethewheels/hooks/useOrders.ts +59 -27
- package/src/movethewheels/pages/OrderDetailsPage.tsx +229 -90
- package/src/movethewheels/utils/graphqlClient.ts +36 -7
- package/src/movethewheels/utils/orderVehicleHold.ts +84 -0
|
@@ -50,7 +50,11 @@ import { useOrder, useOrders } from '../hooks/useOrders';
|
|
|
50
50
|
import { useVehicles, useVehicle, useDrivers, useDriver } from '../hooks/useFleet';
|
|
51
51
|
import { useDebouncedValue } from '../hooks/useDebouncedValue';
|
|
52
52
|
import { FormError } from '../components/FormError';
|
|
53
|
-
import { Notice
|
|
53
|
+
import { Notice } from '../components/Notice';
|
|
54
|
+
import { useOrderDispatch } from '../hooks/useOrderDispatch';
|
|
55
|
+
import { isVehicleAssignable } from '../utils/orderVehicleHold';
|
|
56
|
+
import { reuseRecoveryScope, reuseRecoveryContextKey } from '../utils/reuseRecovery';
|
|
57
|
+
import { useTr } from '../shared/hooks/useTr';
|
|
54
58
|
import { DocumentsCard } from '../components/DocumentsCard';
|
|
55
59
|
import { ProofOfDeliveryCard } from '../components/ProofOfDeliveryCard';
|
|
56
60
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
@@ -94,6 +98,28 @@ function nextStatus(current: OrderStatus): OrderStatus | null {
|
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
export default function OrderDetailsPage() {
|
|
101
|
+
const context = useMoveTheWheels();
|
|
102
|
+
const { orderId } = useParams<{ orderId: string }>();
|
|
103
|
+
const scope = reuseRecoveryScope(
|
|
104
|
+
context.currentUser?.id,
|
|
105
|
+
context.workspaceId,
|
|
106
|
+
context.tenantId,
|
|
107
|
+
context.apiGatewayUrl,
|
|
108
|
+
context.workspaceToken
|
|
109
|
+
);
|
|
110
|
+
const contextKey = JSON.stringify([
|
|
111
|
+
reuseRecoveryContextKey(scope),
|
|
112
|
+
context.currentUser?.id,
|
|
113
|
+
context.workspaceId,
|
|
114
|
+
context.tenantId,
|
|
115
|
+
context.apiGatewayUrl,
|
|
116
|
+
orderId,
|
|
117
|
+
]);
|
|
118
|
+
return <OrderDetails key={contextKey} contextKey={contextKey} contextReady={scope !== null} />;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function OrderDetails({ contextKey, contextReady }: { contextKey: string; contextReady: boolean }) {
|
|
122
|
+
const tr = useTr();
|
|
97
123
|
const { orderId } = useParams<{ orderId: string }>();
|
|
98
124
|
const { basePath, navigate } = useMoveTheWheels();
|
|
99
125
|
const { order, isLoading, error, refetch } = useOrder(orderId);
|
|
@@ -101,9 +127,6 @@ export default function OrderDetailsPage() {
|
|
|
101
127
|
const [fleetSearch, setFleetSearch] = useState('');
|
|
102
128
|
const [selectedVehicle, setSelectedVehicle] = useState('');
|
|
103
129
|
const [selectedDriver, setSelectedDriver] = useState('');
|
|
104
|
-
const [assigning, setAssigning] = useState(false);
|
|
105
|
-
const [advancing, setAdvancing] = useState(false);
|
|
106
|
-
const [actionError, setActionError] = useState<string | null>(null);
|
|
107
130
|
const debouncedFleetSearch = useDebouncedValue(fleetSearch);
|
|
108
131
|
const {
|
|
109
132
|
vehicles,
|
|
@@ -111,6 +134,8 @@ export default function OrderDetailsPage() {
|
|
|
111
134
|
isLoadingMore: vehiclesLoadingMore,
|
|
112
135
|
hasMore: vehiclesHaveMore,
|
|
113
136
|
totalVehicles,
|
|
137
|
+
error: vehiclesError,
|
|
138
|
+
refetch: refreshVehicles,
|
|
114
139
|
loadMore: loadMoreVehicles,
|
|
115
140
|
} = useVehicles({
|
|
116
141
|
limit: 20,
|
|
@@ -122,6 +147,8 @@ export default function OrderDetailsPage() {
|
|
|
122
147
|
isLoadingMore: driversLoadingMore,
|
|
123
148
|
hasMore: driversHaveMore,
|
|
124
149
|
totalDrivers,
|
|
150
|
+
error: driversError,
|
|
151
|
+
refetch: refreshDrivers,
|
|
125
152
|
loadMore: loadMoreDrivers,
|
|
126
153
|
} = useDrivers({
|
|
127
154
|
limit: 20,
|
|
@@ -130,76 +157,106 @@ export default function OrderDetailsPage() {
|
|
|
130
157
|
// Resolved independently of the (searched, 20-row) lists above so the
|
|
131
158
|
// trigger still shows the right label once a pick scrolls out of the
|
|
132
159
|
// current search results.
|
|
133
|
-
const {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
);
|
|
139
|
-
const {
|
|
160
|
+
const {
|
|
161
|
+
vehicle: selectedVehicleRecord,
|
|
162
|
+
isLoading: selectedVehicleLoading,
|
|
163
|
+
error: vehicleError,
|
|
164
|
+
refetch: refreshVehicle,
|
|
165
|
+
} = useVehicle(selectedVehicle || order?.vehicleId || undefined);
|
|
166
|
+
const {
|
|
167
|
+
driver: selectedDriverRecord,
|
|
168
|
+
isLoading: selectedDriverLoading,
|
|
169
|
+
error: driverError,
|
|
170
|
+
refetch: refreshDriver,
|
|
171
|
+
} = useDriver(selectedDriver || order?.driverId || undefined);
|
|
140
172
|
const availableVehicles = vehicles.filter(
|
|
141
|
-
(vehicle) => vehicle.status
|
|
173
|
+
(vehicle) => isVehicleAssignable(vehicle.status) || vehicle.id === order?.vehicleId
|
|
142
174
|
);
|
|
143
175
|
const availableDrivers = drivers.filter(
|
|
144
176
|
(driver) => driver.status === 'available' || driver.id === order?.driverId
|
|
145
177
|
);
|
|
146
178
|
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
if (TERMINAL_STATUSES.has(order.status)) return;
|
|
190
|
-
|
|
191
|
-
setAdvancing(true);
|
|
192
|
-
setActionError(null);
|
|
193
|
-
try {
|
|
194
|
-
await updateOrderStatus(orderId, status);
|
|
195
|
-
notify(successMessage);
|
|
196
|
-
refetch();
|
|
197
|
-
} catch (err) {
|
|
198
|
-
setActionError(err instanceof Error ? err.message : 'Failed to update status.');
|
|
199
|
-
} finally {
|
|
200
|
-
setAdvancing(false);
|
|
201
|
-
}
|
|
179
|
+
const {
|
|
180
|
+
vehicle: assignedVehicle,
|
|
181
|
+
isLoading: assignedVehicleLoading,
|
|
182
|
+
error: assignedVehicleError,
|
|
183
|
+
refetch: refreshAssignedVehicle,
|
|
184
|
+
} = useVehicle(order?.vehicleId);
|
|
185
|
+
const assignedVehicleVerified =
|
|
186
|
+
!order?.vehicleId ||
|
|
187
|
+
(!assignedVehicleLoading && !assignedVehicleError && assignedVehicle?.id === order.vehicleId);
|
|
188
|
+
const assignedVehicleHeld = Boolean(
|
|
189
|
+
order?.vehicleId &&
|
|
190
|
+
assignedVehicleVerified &&
|
|
191
|
+
assignedVehicle &&
|
|
192
|
+
!isVehicleAssignable(assignedVehicle.status)
|
|
193
|
+
);
|
|
194
|
+
const effectiveVehicleId = selectedVehicle || order?.vehicleId;
|
|
195
|
+
const effectiveDriverId = selectedDriver || order?.driverId;
|
|
196
|
+
const vehicleVerified =
|
|
197
|
+
!effectiveVehicleId ||
|
|
198
|
+
(!selectedVehicleLoading && !vehicleError && selectedVehicleRecord?.id === effectiveVehicleId);
|
|
199
|
+
const driverVerified =
|
|
200
|
+
!effectiveDriverId ||
|
|
201
|
+
(!selectedDriverLoading && !driverError && selectedDriverRecord?.id === effectiveDriverId);
|
|
202
|
+
const vehicleHeld = Boolean(
|
|
203
|
+
effectiveVehicleId &&
|
|
204
|
+
vehicleVerified &&
|
|
205
|
+
selectedVehicleRecord &&
|
|
206
|
+
!isVehicleAssignable(selectedVehicleRecord.status)
|
|
207
|
+
);
|
|
208
|
+
const assignmentEntersMovement = Boolean(
|
|
209
|
+
order && !(order.vehicleId && order.driverId) && order.status === 'confirmed'
|
|
210
|
+
);
|
|
211
|
+
const heldAssignmentBlocked =
|
|
212
|
+
vehicleHeld && (effectiveVehicleId !== order?.vehicleId || assignmentEntersMovement);
|
|
213
|
+
const refreshAssignment = () => {
|
|
214
|
+
refetch();
|
|
215
|
+
refreshVehicle();
|
|
216
|
+
refreshAssignedVehicle();
|
|
217
|
+
refreshDriver();
|
|
218
|
+
refreshVehicles();
|
|
219
|
+
refreshDrivers();
|
|
202
220
|
};
|
|
221
|
+
const dispatch = useOrderDispatch({
|
|
222
|
+
order,
|
|
223
|
+
contextKey,
|
|
224
|
+
ready: contextReady && !isLoading && !error && Boolean(order),
|
|
225
|
+
assignmentReady:
|
|
226
|
+
Boolean(effectiveVehicleId && effectiveDriverId) &&
|
|
227
|
+
vehicleVerified &&
|
|
228
|
+
driverVerified &&
|
|
229
|
+
!heldAssignmentBlocked &&
|
|
230
|
+
!vehiclesLoading &&
|
|
231
|
+
!driversLoading &&
|
|
232
|
+
!vehiclesError &&
|
|
233
|
+
!driversError,
|
|
234
|
+
movementReady: assignedVehicleVerified && !assignedVehicleHeld,
|
|
235
|
+
updateOrder,
|
|
236
|
+
updateOrderStatus,
|
|
237
|
+
refresh: refreshAssignment,
|
|
238
|
+
});
|
|
239
|
+
const assignmentChanged =
|
|
240
|
+
effectiveVehicleId !== order?.vehicleId || effectiveDriverId !== order?.driverId;
|
|
241
|
+
const actionUnavailable =
|
|
242
|
+
dispatch.busy || dispatch.blocked || !contextReady || isLoading || Boolean(error);
|
|
243
|
+
const handleAssign = () => dispatch.assign(effectiveVehicleId ?? '', effectiveDriverId ?? '');
|
|
244
|
+
const handleStatusChange = (status: OrderStatus) => dispatch.changeStatus(status);
|
|
245
|
+
const heldMessage =
|
|
246
|
+
selectedVehicleRecord?.status === 'maintenance'
|
|
247
|
+
? tr(
|
|
248
|
+
'movethewheels.order.vehicleMaintenance',
|
|
249
|
+
'Vehicle marked for maintenance. Choose another vehicle.'
|
|
250
|
+
)
|
|
251
|
+
: selectedVehicleRecord?.status === 'decommissioned'
|
|
252
|
+
? tr(
|
|
253
|
+
'movethewheels.order.vehicleDecommissioned',
|
|
254
|
+
'Vehicle decommissioned. Choose another vehicle.'
|
|
255
|
+
)
|
|
256
|
+
: tr(
|
|
257
|
+
'movethewheels.order.vehicleInactive',
|
|
258
|
+
'Vehicle marked inactive. Choose another vehicle.'
|
|
259
|
+
);
|
|
203
260
|
|
|
204
261
|
const goTo = (path: string) => {
|
|
205
262
|
if (navigate) {
|
|
@@ -213,15 +270,18 @@ export default function OrderDetailsPage() {
|
|
|
213
270
|
}
|
|
214
271
|
};
|
|
215
272
|
|
|
216
|
-
if (isLoading) {
|
|
273
|
+
if (isLoading && !order) {
|
|
217
274
|
return <LoadingState message="Loading order details..." />;
|
|
218
275
|
}
|
|
219
276
|
|
|
220
|
-
if (
|
|
277
|
+
if (!order) {
|
|
221
278
|
return (
|
|
222
279
|
<ErrorState
|
|
223
|
-
title=
|
|
224
|
-
message=
|
|
280
|
+
title={tr('movethewheels.order.unavailable', 'Order unavailable')}
|
|
281
|
+
message={tr(
|
|
282
|
+
'movethewheels.order.loadUnavailable',
|
|
283
|
+
'The requested order could not be loaded. Refresh before making changes.'
|
|
284
|
+
)}
|
|
225
285
|
onRetry={refetch}
|
|
226
286
|
/>
|
|
227
287
|
);
|
|
@@ -558,8 +618,65 @@ export default function OrderDetailsPage() {
|
|
|
558
618
|
</CardHeader>
|
|
559
619
|
<CardContent>
|
|
560
620
|
<div className="space-y-4">
|
|
561
|
-
{
|
|
562
|
-
|
|
621
|
+
{isLoading && (
|
|
622
|
+
<p role="status" className="text-sm">
|
|
623
|
+
{tr(
|
|
624
|
+
'movethewheels.order.refreshing',
|
|
625
|
+
'Refreshing order details. Changes are unavailable.'
|
|
626
|
+
)}
|
|
627
|
+
</p>
|
|
628
|
+
)}
|
|
629
|
+
{error && (
|
|
630
|
+
<FormError
|
|
631
|
+
message={tr(
|
|
632
|
+
'movethewheels.order.refreshFailed',
|
|
633
|
+
'Order details could not be refreshed. Existing values are read-only.'
|
|
634
|
+
)}
|
|
635
|
+
/>
|
|
636
|
+
)}
|
|
637
|
+
{dispatch.message && <Notice variant="info" message={dispatch.message} />}
|
|
638
|
+
{dispatch.knownAssignment && (
|
|
639
|
+
<p className="text-sm break-words" role="status">
|
|
640
|
+
{tr(
|
|
641
|
+
'movethewheels.order.knownAssignment',
|
|
642
|
+
'Saved assignment: vehicle {{vehicle}}, driver {{driver}}.',
|
|
643
|
+
{
|
|
644
|
+
vehicle:
|
|
645
|
+
selectedVehicleRecord?.id === dispatch.knownAssignment.vehicleId
|
|
646
|
+
? selectedVehicleRecord.vehicleNumber
|
|
647
|
+
: dispatch.knownAssignment.vehicleId,
|
|
648
|
+
driver:
|
|
649
|
+
selectedDriverRecord?.id === dispatch.knownAssignment.driverId
|
|
650
|
+
? selectedDriverRecord.fullName
|
|
651
|
+
: dispatch.knownAssignment.driverId,
|
|
652
|
+
}
|
|
653
|
+
)}
|
|
654
|
+
</p>
|
|
655
|
+
)}
|
|
656
|
+
{dispatch.blocked && (
|
|
657
|
+
<FormError
|
|
658
|
+
message={tr(
|
|
659
|
+
'movethewheels.order.blocked',
|
|
660
|
+
'An order write is unconfirmed. Refresh is read-only and will not unlock another write.'
|
|
661
|
+
)}
|
|
662
|
+
/>
|
|
663
|
+
)}
|
|
664
|
+
{vehicleHeld && <FormError message={heldMessage} />}
|
|
665
|
+
{(!vehicleVerified ||
|
|
666
|
+
!driverVerified ||
|
|
667
|
+
!assignedVehicleVerified ||
|
|
668
|
+
vehiclesError ||
|
|
669
|
+
driversError) && (
|
|
670
|
+
<FormError
|
|
671
|
+
message={tr(
|
|
672
|
+
'movethewheels.order.unverifiedFleet',
|
|
673
|
+
'Vehicle status could not be verified. Refresh before assigning or advancing.'
|
|
674
|
+
)}
|
|
675
|
+
/>
|
|
676
|
+
)}
|
|
677
|
+
<Button variant="outline" onClick={refreshAssignment} disabled={dispatch.busy}>
|
|
678
|
+
{tr('movethewheels.order.refreshAssignment', 'Refresh assignment')}
|
|
679
|
+
</Button>
|
|
563
680
|
|
|
564
681
|
<div>
|
|
565
682
|
<p className="text-sm text-text-secondary">Tracking Number</p>
|
|
@@ -576,6 +693,7 @@ export default function OrderDetailsPage() {
|
|
|
576
693
|
<Input
|
|
577
694
|
id="assign-fleet-search"
|
|
578
695
|
value={fleetSearch}
|
|
696
|
+
disabled={actionUnavailable}
|
|
579
697
|
onChange={(event) => setFleetSearch(event.target.value)}
|
|
580
698
|
placeholder="Vehicle number, driver name or employee ID"
|
|
581
699
|
className="pl-9"
|
|
@@ -586,7 +704,8 @@ export default function OrderDetailsPage() {
|
|
|
586
704
|
<Label htmlFor="assign-vehicle">Vehicle</Label>
|
|
587
705
|
<EntityCombobox
|
|
588
706
|
id="assign-vehicle"
|
|
589
|
-
|
|
707
|
+
disabled={actionUnavailable}
|
|
708
|
+
value={effectiveVehicleId ?? ''}
|
|
590
709
|
onChange={setSelectedVehicle}
|
|
591
710
|
options={availableVehicles}
|
|
592
711
|
loading={vehiclesLoading}
|
|
@@ -597,8 +716,8 @@ export default function OrderDetailsPage() {
|
|
|
597
716
|
{vehicle.vehicleNumber} ({vehicle.type})
|
|
598
717
|
</>
|
|
599
718
|
)}
|
|
600
|
-
selectedOption={selectedVehicleRecord}
|
|
601
|
-
selectedLoading={
|
|
719
|
+
selectedOption={vehicleVerified ? selectedVehicleRecord : null}
|
|
720
|
+
selectedLoading={!vehicleVerified}
|
|
602
721
|
hasMore={vehiclesHaveMore}
|
|
603
722
|
totalCount={totalVehicles}
|
|
604
723
|
onLoadMore={loadMoreVehicles}
|
|
@@ -617,7 +736,8 @@ export default function OrderDetailsPage() {
|
|
|
617
736
|
<Label htmlFor="assign-driver">Driver</Label>
|
|
618
737
|
<EntityCombobox
|
|
619
738
|
id="assign-driver"
|
|
620
|
-
|
|
739
|
+
disabled={actionUnavailable}
|
|
740
|
+
value={effectiveDriverId ?? ''}
|
|
621
741
|
onChange={setSelectedDriver}
|
|
622
742
|
options={availableDrivers}
|
|
623
743
|
loading={driversLoading}
|
|
@@ -628,8 +748,8 @@ export default function OrderDetailsPage() {
|
|
|
628
748
|
{driver.fullName} ({driver.employeeId})
|
|
629
749
|
</>
|
|
630
750
|
)}
|
|
631
|
-
selectedOption={selectedDriverRecord}
|
|
632
|
-
selectedLoading={
|
|
751
|
+
selectedOption={driverVerified ? selectedDriverRecord : null}
|
|
752
|
+
selectedLoading={!driverVerified}
|
|
633
753
|
hasMore={driversHaveMore}
|
|
634
754
|
totalCount={totalDrivers}
|
|
635
755
|
onLoadMore={loadMoreDrivers}
|
|
@@ -645,10 +765,19 @@ export default function OrderDetailsPage() {
|
|
|
645
765
|
<Button
|
|
646
766
|
className="w-full"
|
|
647
767
|
onClick={handleAssign}
|
|
648
|
-
disabled={
|
|
768
|
+
disabled={
|
|
769
|
+
actionUnavailable ||
|
|
770
|
+
!assignmentChanged ||
|
|
771
|
+
heldAssignmentBlocked ||
|
|
772
|
+
!vehicleVerified ||
|
|
773
|
+
!driverVerified ||
|
|
774
|
+
vehiclesLoading ||
|
|
775
|
+
driversLoading ||
|
|
776
|
+
Boolean(vehiclesError || driversError)
|
|
777
|
+
}
|
|
649
778
|
>
|
|
650
779
|
<UserCheck size={16} />
|
|
651
|
-
{
|
|
780
|
+
{dispatch.busy ? 'Assigning…' : 'Assign driver & vehicle'}
|
|
652
781
|
</Button>
|
|
653
782
|
</div>
|
|
654
783
|
|
|
@@ -659,6 +788,14 @@ export default function OrderDetailsPage() {
|
|
|
659
788
|
Current status:{' '}
|
|
660
789
|
<span className="font-medium">{STATUS_LABELS[order.status]}</span>
|
|
661
790
|
</p>
|
|
791
|
+
{assignedVehicleHeld && (
|
|
792
|
+
<p className="text-sm text-status-error-text" role="status">
|
|
793
|
+
{tr(
|
|
794
|
+
'movethewheels.order.currentVehicleHeld',
|
|
795
|
+
'The currently assigned vehicle is on hold. Save a replacement before advancing movement.'
|
|
796
|
+
)}
|
|
797
|
+
</p>
|
|
798
|
+
)}
|
|
662
799
|
{(() => {
|
|
663
800
|
const upcoming = nextStatus(order.status);
|
|
664
801
|
if (!upcoming) return null;
|
|
@@ -669,16 +806,18 @@ export default function OrderDetailsPage() {
|
|
|
669
806
|
<Button
|
|
670
807
|
variant="outline"
|
|
671
808
|
className="w-full"
|
|
672
|
-
onClick={() =>
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
809
|
+
onClick={() => handleStatusChange(upcoming)}
|
|
810
|
+
disabled={
|
|
811
|
+
actionUnavailable ||
|
|
812
|
+
missingDispatchPair ||
|
|
813
|
+
(['assigned', 'picked_up', 'in_transit', 'out_for_delivery'].includes(
|
|
814
|
+
upcoming
|
|
815
|
+
) &&
|
|
816
|
+
(!assignedVehicleVerified || assignedVehicleHeld))
|
|
677
817
|
}
|
|
678
|
-
disabled={advancing || missingDispatchPair}
|
|
679
818
|
>
|
|
680
819
|
<ArrowRight size={16} />
|
|
681
|
-
{
|
|
820
|
+
{dispatch.busy ? 'Updating…' : `Advance to ${STATUS_LABELS[upcoming]}`}
|
|
682
821
|
</Button>
|
|
683
822
|
{missingDispatchPair && (
|
|
684
823
|
<p className="text-xs text-text-secondary">
|
|
@@ -693,8 +832,8 @@ export default function OrderDetailsPage() {
|
|
|
693
832
|
<Button
|
|
694
833
|
variant="outline"
|
|
695
834
|
className="w-full text-status-error-text"
|
|
696
|
-
onClick={() => handleStatusChange('cancelled'
|
|
697
|
-
disabled={
|
|
835
|
+
onClick={() => handleStatusChange('cancelled')}
|
|
836
|
+
disabled={actionUnavailable}
|
|
698
837
|
>
|
|
699
838
|
<XCircle size={16} />
|
|
700
839
|
Cancel order
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { vehicleHoldRefusal, type VehicleHoldMutation } from './orderVehicleHold';
|
|
1
2
|
import { prepareWorkspaceRequest } from '@burdenoff/fe-libs/shared/graphql';
|
|
2
3
|
|
|
3
4
|
export type GraphQLAttemptPolicy = 'RETRY_TRANSIENT' | 'SINGLE_ATTEMPT';
|
|
@@ -111,7 +112,8 @@ export async function executeGraphQL<TData>(
|
|
|
111
112
|
options: GraphQLRequestOptions,
|
|
112
113
|
query: string,
|
|
113
114
|
variables?: Record<string, unknown>,
|
|
114
|
-
attemptPolicy: GraphQLAttemptPolicy = 'RETRY_TRANSIENT'
|
|
115
|
+
attemptPolicy: GraphQLAttemptPolicy = 'RETRY_TRANSIENT',
|
|
116
|
+
holdRefusalField?: VehicleHoldMutation
|
|
115
117
|
): Promise<TData> {
|
|
116
118
|
// Capture the original intent before queueing or asynchronously renewing authority.
|
|
117
119
|
const request = { ...options };
|
|
@@ -152,7 +154,8 @@ export async function executeGraphQL<TData>(
|
|
|
152
154
|
headers,
|
|
153
155
|
body,
|
|
154
156
|
attemptPolicy === 'SINGLE_ATTEMPT' ? 0 : MAX_RETRIES,
|
|
155
|
-
authority?.assertCurrent ?? (() => {})
|
|
157
|
+
authority?.assertCurrent ?? (() => {}),
|
|
158
|
+
attemptPolicy === 'SINGLE_ATTEMPT' ? holdRefusalField : undefined
|
|
156
159
|
);
|
|
157
160
|
} finally {
|
|
158
161
|
releaseSlot();
|
|
@@ -160,24 +163,45 @@ export async function executeGraphQL<TData>(
|
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
/** Best-effort: the joined `errors[].message` of a non-2xx GraphQL response, or null. */
|
|
163
|
-
async function readGraphqlErrorMessage(
|
|
166
|
+
async function readGraphqlErrorMessage(
|
|
167
|
+
response: Response,
|
|
168
|
+
holdRefusalField?: VehicleHoldMutation
|
|
169
|
+
): Promise<Error | null> {
|
|
164
170
|
try {
|
|
165
171
|
const payload = (await response.json()) as { errors?: Array<{ message?: string }> };
|
|
172
|
+
const refusal = vehicleHoldRefusal(payload, response.status, holdRefusalField);
|
|
173
|
+
if (refusal) return refusal;
|
|
166
174
|
const messages = (payload.errors ?? [])
|
|
167
175
|
.map((item) => item.message)
|
|
168
176
|
.filter((message): message is string => typeof message === 'string' && message.length > 0);
|
|
169
|
-
return messages.length > 0 ? messages.join('; ') : null;
|
|
177
|
+
return messages.length > 0 ? new Error(messages.join('; ')) : null;
|
|
170
178
|
} catch {
|
|
171
179
|
return null;
|
|
172
180
|
}
|
|
173
181
|
}
|
|
174
182
|
|
|
183
|
+
/** A write ACK must have one owning root and an absent or empty errors array. */
|
|
184
|
+
function validWriteEnvelope(payload: unknown, field: VehicleHoldMutation): boolean {
|
|
185
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return false;
|
|
186
|
+
if ('errors' in payload && (!Array.isArray(payload.errors) || payload.errors.length !== 0))
|
|
187
|
+
return false;
|
|
188
|
+
if (
|
|
189
|
+
!('data' in payload) ||
|
|
190
|
+
typeof payload.data !== 'object' ||
|
|
191
|
+
payload.data === null ||
|
|
192
|
+
Array.isArray(payload.data)
|
|
193
|
+
)
|
|
194
|
+
return false;
|
|
195
|
+
return Object.keys(payload.data).length === 1 && Object.hasOwn(payload.data, field);
|
|
196
|
+
}
|
|
197
|
+
|
|
175
198
|
async function runWithRetries<TData>(
|
|
176
199
|
url: string,
|
|
177
200
|
headers: Record<string, string>,
|
|
178
201
|
body: string,
|
|
179
202
|
retries: number,
|
|
180
|
-
assertCurrent: () => void
|
|
203
|
+
assertCurrent: () => void,
|
|
204
|
+
holdRefusalField?: VehicleHoldMutation
|
|
181
205
|
): Promise<TData> {
|
|
182
206
|
let lastError: Error = new Error('GraphQL request failed');
|
|
183
207
|
|
|
@@ -204,8 +228,8 @@ async function runWithRetries<TData>(
|
|
|
204
228
|
// rule rejection) — show that message rather than a bare status code.
|
|
205
229
|
const detail = isTransientStatus(response.status)
|
|
206
230
|
? null
|
|
207
|
-
: await readGraphqlErrorMessage(response);
|
|
208
|
-
lastError = new Error(
|
|
231
|
+
: await readGraphqlErrorMessage(response, holdRefusalField);
|
|
232
|
+
lastError = detail ?? new Error(`GraphQL request failed with status ${response.status}`);
|
|
209
233
|
if (canRetry && isTransientStatus(response.status)) continue;
|
|
210
234
|
throw lastError;
|
|
211
235
|
}
|
|
@@ -215,6 +239,11 @@ async function runWithRetries<TData>(
|
|
|
215
239
|
errors?: Array<{ message?: string }>;
|
|
216
240
|
};
|
|
217
241
|
|
|
242
|
+
const refusal = vehicleHoldRefusal(payload, response.status, holdRefusalField);
|
|
243
|
+
if (refusal) throw refusal;
|
|
244
|
+
if (holdRefusalField && !validWriteEnvelope(payload, holdRefusalField))
|
|
245
|
+
throw new Error('The order write response could not be confirmed.');
|
|
246
|
+
|
|
218
247
|
if (payload.errors?.length) {
|
|
219
248
|
const message = payload.errors
|
|
220
249
|
.map((item) => item.message ?? 'Unknown GraphQL error')
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export type VehicleHoldMutation = 'updateDeliveryJob' | 'updateDeliveryJobStatus';
|
|
2
|
+
|
|
3
|
+
export class VehicleHoldRefusal extends Error {
|
|
4
|
+
constructor(
|
|
5
|
+
readonly operation: VehicleHoldMutation,
|
|
6
|
+
readonly kind: 'vehicle' | 'driver' = 'vehicle'
|
|
7
|
+
) {
|
|
8
|
+
super('The recorded eligibility prevents this assignment or movement.');
|
|
9
|
+
this.name = 'VehicleHoldRefusal';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function object(value: unknown): value is Record<string, unknown> {
|
|
14
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Only the owning pre-write refusal is known to have made no job/history write. */
|
|
18
|
+
export function vehicleHoldRefusal(
|
|
19
|
+
payload: unknown,
|
|
20
|
+
status: number,
|
|
21
|
+
operation?: VehicleHoldMutation
|
|
22
|
+
): VehicleHoldRefusal | null {
|
|
23
|
+
if (!operation || (status !== 200 && status !== 400) || !object(payload)) return null;
|
|
24
|
+
if (!Array.isArray(payload.errors) || payload.errors.length !== 1) return null;
|
|
25
|
+
const error: unknown = payload.errors[0];
|
|
26
|
+
if (!object(error) || typeof error.message !== 'string' || !error.message) return null;
|
|
27
|
+
if (!Array.isArray(error.path) || error.path.length !== 1 || error.path[0] !== operation)
|
|
28
|
+
return null;
|
|
29
|
+
if (!object(error.extensions) || error.extensions.code !== 'BAD_USER_INPUT') return null;
|
|
30
|
+
const key = error.extensions.i18nKey;
|
|
31
|
+
const kind =
|
|
32
|
+
key === 'route.errors.vehicleNotAssignable'
|
|
33
|
+
? 'vehicle'
|
|
34
|
+
: key === 'route.errors.driverInactive' ||
|
|
35
|
+
key === 'route.errors.driverLicenseUnknown' ||
|
|
36
|
+
key === 'route.errors.driverLicenseExpired'
|
|
37
|
+
? 'driver'
|
|
38
|
+
: null;
|
|
39
|
+
if (!kind) return null;
|
|
40
|
+
if (
|
|
41
|
+
payload.data !== undefined &&
|
|
42
|
+
payload.data !== null &&
|
|
43
|
+
(!object(payload.data) ||
|
|
44
|
+
Object.keys(payload.data).length !== 1 ||
|
|
45
|
+
payload.data[operation] !== null)
|
|
46
|
+
)
|
|
47
|
+
return null;
|
|
48
|
+
return new VehicleHoldRefusal(operation, kind);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const API_STATUSES = new Set([
|
|
52
|
+
'PENDING',
|
|
53
|
+
'CONFIRMED',
|
|
54
|
+
'ASSIGNED',
|
|
55
|
+
'PICKED_UP',
|
|
56
|
+
'IN_TRANSIT',
|
|
57
|
+
'OUT_FOR_DELIVERY',
|
|
58
|
+
'DELIVERED',
|
|
59
|
+
'RETURNED',
|
|
60
|
+
'CANCELLED',
|
|
61
|
+
'FAILED',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** Validate the returned write identity before mapping or dispatching a dependent mutation. */
|
|
65
|
+
export function assertOrderAcknowledgement(
|
|
66
|
+
value: unknown,
|
|
67
|
+
expected: { id: string; vehicleId?: string; driverId?: string; status?: string }
|
|
68
|
+
): void {
|
|
69
|
+
if (
|
|
70
|
+
!object(value) ||
|
|
71
|
+
value.id !== expected.id ||
|
|
72
|
+
typeof value.status !== 'string' ||
|
|
73
|
+
!API_STATUSES.has(value.status) ||
|
|
74
|
+
(expected.vehicleId !== undefined && value.vehicleId !== expected.vehicleId) ||
|
|
75
|
+
(expected.driverId !== undefined && value.driverId !== expected.driverId) ||
|
|
76
|
+
(expected.status !== undefined && value.status !== expected.status)
|
|
77
|
+
)
|
|
78
|
+
throw new Error('The order write could not be confirmed.');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isVehicleAssignable(status: string): boolean {
|
|
82
|
+
// These are the existing view-model mappings of ACTIVE and IN_TRANSIT.
|
|
83
|
+
return status === 'available' || status === 'assigned';
|
|
84
|
+
}
|