@burdenoff/microfe-movethewheels 2026.1016.2 → 2026.1016.3
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/freight-cost/FreightCommercialEditor.js +217 -0
- package/dist/components/freight-cost/FreightCommercialFactEditor.js +217 -0
- package/dist/components/freight-cost/FreightCommercialHistory.js +115 -0
- package/dist/components/freight-cost/FreightDraftForm.js +106 -91
- package/dist/components/freight-cost/FreightHistory.js +42 -40
- package/dist/generated/freight-cost-operations.d.ts +959 -0
- package/dist/hooks/useFreightRecoveryAction.js +20 -20
- package/dist/movethewheels/components/freight-cost/FreightCommercialEditor.d.ts +8 -0
- package/dist/movethewheels/components/freight-cost/FreightCommercialFactEditor.d.ts +8 -0
- package/dist/movethewheels/components/freight-cost/FreightCommercialHistory.d.ts +4 -0
- package/dist/movethewheels/components/freight-cost/FreightDraftForm.d.ts +4 -2
- package/dist/movethewheels/components/freight-cost/FreightHistory.d.ts +2 -2
- package/dist/movethewheels/utils/freightCommercial.d.ts +44 -0
- package/dist/pages/FreightEstimatePage.js +96 -92
- package/dist/src/generated/freight-cost-operations.js +495 -6
- package/dist/utils/freightAcknowledgement.js +6 -5
- package/dist/utils/freightCommercial.js +194 -0
- package/dist/utils/freightRecovery.js +22 -22
- package/package.json +4 -3
- package/src/generated/freight-cost-operations.ts +831 -1
- package/src/movethewheels/components/freight-cost/FreightCommercialEditor.tsx +236 -0
- package/src/movethewheels/components/freight-cost/FreightCommercialFactEditor.tsx +258 -0
- package/src/movethewheels/components/freight-cost/FreightCommercialHistory.tsx +90 -0
- package/src/movethewheels/components/freight-cost/FreightDraftForm.tsx +33 -4
- package/src/movethewheels/components/freight-cost/FreightHistory.tsx +4 -2
- package/src/movethewheels/hooks/useFreightRecoveryAction.ts +2 -1
- package/src/movethewheels/pages/FreightEstimatePage.tsx +14 -7
- package/src/movethewheels/utils/freightAcknowledgement.ts +3 -1
- package/src/movethewheels/utils/freightCommercial.ts +388 -0
- package/src/movethewheels/utils/freightRecovery.ts +6 -2
- package/src/operations/freight-cost/commercial.graphql +483 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { OptionalFields } from '@burdenoff/fe-libs/ui';
|
|
3
|
+
import {
|
|
4
|
+
FreightCommercialLegsDocument,
|
|
5
|
+
type FreightCommercialLegsQuery,
|
|
6
|
+
} from '../../../generated/freight-cost-operations';
|
|
7
|
+
import { useConditionQuery } from '../../hooks/useContainerCondition';
|
|
8
|
+
import {
|
|
9
|
+
commercialSlots,
|
|
10
|
+
commercialCanonical,
|
|
11
|
+
newCommercialEditor,
|
|
12
|
+
type CommercialEditorState,
|
|
13
|
+
} from '../../utils/freightCommercial';
|
|
14
|
+
import { FreightCommercialFactEditor } from './FreightCommercialFactEditor';
|
|
15
|
+
import { Button } from '../ui';
|
|
16
|
+
import { ReuseState } from '../ReuseControls';
|
|
17
|
+
import type { SelectedEvidence } from '../ContainerEvidencePicker';
|
|
18
|
+
|
|
19
|
+
export function FreightCommercialEditor({
|
|
20
|
+
value,
|
|
21
|
+
onChange,
|
|
22
|
+
shipmentId,
|
|
23
|
+
evidence,
|
|
24
|
+
}: {
|
|
25
|
+
value: CommercialEditorState | null;
|
|
26
|
+
onChange: (value: CommercialEditorState | null) => void;
|
|
27
|
+
shipmentId: string;
|
|
28
|
+
evidence: SelectedEvidence[];
|
|
29
|
+
}) {
|
|
30
|
+
return (
|
|
31
|
+
<OptionalFields
|
|
32
|
+
title="Commercial context (optional)"
|
|
33
|
+
description="Source declarations only. Off by default; no quote acceptance or completeness is inferred."
|
|
34
|
+
defaultOpen={false}
|
|
35
|
+
>
|
|
36
|
+
<label className="flex min-h-11 items-center gap-2">
|
|
37
|
+
<input
|
|
38
|
+
type="checkbox"
|
|
39
|
+
checked={value !== null}
|
|
40
|
+
onChange={(event) => onChange(event.target.checked ? newCommercialEditor() : null)}
|
|
41
|
+
/>
|
|
42
|
+
Include commercial context
|
|
43
|
+
</label>
|
|
44
|
+
{value && (
|
|
45
|
+
<CommercialFields
|
|
46
|
+
value={value}
|
|
47
|
+
onChange={onChange}
|
|
48
|
+
shipmentId={shipmentId}
|
|
49
|
+
evidence={evidence}
|
|
50
|
+
/>
|
|
51
|
+
)}
|
|
52
|
+
</OptionalFields>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
function CommercialFields({
|
|
56
|
+
value,
|
|
57
|
+
onChange,
|
|
58
|
+
shipmentId,
|
|
59
|
+
evidence,
|
|
60
|
+
}: {
|
|
61
|
+
value: CommercialEditorState;
|
|
62
|
+
onChange: (value: CommercialEditorState) => void;
|
|
63
|
+
shipmentId: string;
|
|
64
|
+
evidence: SelectedEvidence[];
|
|
65
|
+
}) {
|
|
66
|
+
const [revision, setRevision] = useState(0);
|
|
67
|
+
const [offset, setOffset] = useState(0);
|
|
68
|
+
const legs = useConditionQuery<FreightCommercialLegsQuery>(
|
|
69
|
+
FreightCommercialLegsDocument,
|
|
70
|
+
{ shipmentId, pagination: { limit: 25, offset } },
|
|
71
|
+
revision
|
|
72
|
+
);
|
|
73
|
+
const current = legs.data?.shipment?.id === shipmentId ? legs.data.shipment.legs : undefined;
|
|
74
|
+
// Only this page is loaded. Off-page pins retain their original revisions; the server checks all selected pins.
|
|
75
|
+
const matching =
|
|
76
|
+
!!current &&
|
|
77
|
+
current.every(
|
|
78
|
+
(leg) =>
|
|
79
|
+
leg.shipmentId === shipmentId &&
|
|
80
|
+
!value.selectedLegs.some(
|
|
81
|
+
(pin) => pin.id === leg.id && pin.expectedUpdatedAt !== leg.updatedAt
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
const changePage = (next: number) => {
|
|
85
|
+
onChange({ ...value, reviewed: null });
|
|
86
|
+
setOffset(next);
|
|
87
|
+
};
|
|
88
|
+
return (
|
|
89
|
+
<div className="min-w-0 space-y-5">
|
|
90
|
+
<p className="text-sm">
|
|
91
|
+
All eight facts start Unknown: “Not recorded for this cost version”. Edit that explanation
|
|
92
|
+
when useful. Sourced and not-applicable declarations require explicit provenance; shipment
|
|
93
|
+
details are never copied into these facts.
|
|
94
|
+
</p>
|
|
95
|
+
<section className="space-y-3 rounded border p-3">
|
|
96
|
+
<h3 className="font-semibold">Review selected shipment legs</h3>
|
|
97
|
+
<p className="text-sm">
|
|
98
|
+
Select only the legs covered by this source. No selection does not mean all legs. Review
|
|
99
|
+
the loaded selection explicitly before saving.
|
|
100
|
+
</p>
|
|
101
|
+
<ReuseState loading={legs.loading} error={legs.error} />
|
|
102
|
+
{current?.map((leg) => {
|
|
103
|
+
const selected = value.selectedLegs.some((pin) => pin.id === leg.id);
|
|
104
|
+
return (
|
|
105
|
+
<label key={leg.id} className="flex min-h-11 items-center gap-2">
|
|
106
|
+
<input
|
|
107
|
+
type="checkbox"
|
|
108
|
+
checked={selected}
|
|
109
|
+
disabled={!selected && value.selectedLegs.length >= 16}
|
|
110
|
+
onChange={() =>
|
|
111
|
+
onChange({
|
|
112
|
+
...value,
|
|
113
|
+
reviewed: null,
|
|
114
|
+
selectedLegs: selected
|
|
115
|
+
? value.selectedLegs.filter((pin) => pin.id !== leg.id)
|
|
116
|
+
: [...value.selectedLegs, { id: leg.id, expectedUpdatedAt: leg.updatedAt }],
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
/>
|
|
120
|
+
<span>
|
|
121
|
+
Leg {leg.legNumber} — {leg.mode}; carrier {leg.carrierName ?? 'not recorded'};
|
|
122
|
+
updated {leg.updatedAt}
|
|
123
|
+
</span>
|
|
124
|
+
</label>
|
|
125
|
+
);
|
|
126
|
+
})}
|
|
127
|
+
{current?.length === 0 && <p>No legs on this loaded page.</p>}
|
|
128
|
+
<p className="text-sm">
|
|
129
|
+
Leg page {Math.floor(offset / 25) + 1}; at most 25 records per page. Off-page selections
|
|
130
|
+
keep their original revisions.
|
|
131
|
+
</p>
|
|
132
|
+
<div className="flex flex-wrap gap-3">
|
|
133
|
+
<Button
|
|
134
|
+
type="button"
|
|
135
|
+
variant="outline"
|
|
136
|
+
disabled={offset === 0 || legs.loading}
|
|
137
|
+
onClick={() => changePage(Math.max(0, offset - 25))}
|
|
138
|
+
>
|
|
139
|
+
Previous leg page
|
|
140
|
+
</Button>
|
|
141
|
+
<Button
|
|
142
|
+
type="button"
|
|
143
|
+
variant="outline"
|
|
144
|
+
disabled={legs.loading || !!legs.error || current?.length !== 25}
|
|
145
|
+
onClick={() => changePage(offset + 25)}
|
|
146
|
+
>
|
|
147
|
+
Next leg page
|
|
148
|
+
</Button>
|
|
149
|
+
</div>
|
|
150
|
+
{value.selectedLegs.map((pin) => (
|
|
151
|
+
<div key={pin.id} className="flex flex-wrap items-center gap-2 break-all text-sm">
|
|
152
|
+
<p>
|
|
153
|
+
Selected leg {pin.id}; pinned revision {pin.expectedUpdatedAt}.
|
|
154
|
+
</p>
|
|
155
|
+
<Button
|
|
156
|
+
type="button"
|
|
157
|
+
variant="outline"
|
|
158
|
+
onClick={() =>
|
|
159
|
+
onChange({
|
|
160
|
+
...value,
|
|
161
|
+
reviewed: null,
|
|
162
|
+
selectedLegs: value.selectedLegs.filter((row) => row.id !== pin.id),
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
>
|
|
166
|
+
Remove selected leg {pin.id}
|
|
167
|
+
</Button>
|
|
168
|
+
</div>
|
|
169
|
+
))}
|
|
170
|
+
<p>{value.selectedLegs.length} of 16 legs selected.</p>
|
|
171
|
+
{!matching && value.selectedLegs.length > 0 && (
|
|
172
|
+
<p role="alert">
|
|
173
|
+
A selected leg on this page changed or the page does not match this shipment. Explicitly
|
|
174
|
+
remove it and review the current selection; no revision is rebound automatically.
|
|
175
|
+
</p>
|
|
176
|
+
)}
|
|
177
|
+
<div className="flex flex-wrap gap-3">
|
|
178
|
+
<Button
|
|
179
|
+
type="button"
|
|
180
|
+
variant="outline"
|
|
181
|
+
onClick={() => {
|
|
182
|
+
onChange({ ...value, reviewed: null });
|
|
183
|
+
setRevision((n) => n + 1);
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
Refresh legs for review
|
|
187
|
+
</Button>
|
|
188
|
+
<Button
|
|
189
|
+
type="button"
|
|
190
|
+
variant="outline"
|
|
191
|
+
disabled={legs.loading || !!legs.error || !matching}
|
|
192
|
+
onClick={() =>
|
|
193
|
+
onChange({ ...value, reviewed: commercialCanonical(value.selectedLegs) })
|
|
194
|
+
}
|
|
195
|
+
>
|
|
196
|
+
I reviewed this leg selection
|
|
197
|
+
</Button>
|
|
198
|
+
</div>
|
|
199
|
+
{value.reviewed === commercialCanonical(value.selectedLegs) && matching && (
|
|
200
|
+
<p role="status">
|
|
201
|
+
This explicit selection of IDs and revisions is reviewed. The server checks every
|
|
202
|
+
selected leg on save, including off-page pins.
|
|
203
|
+
</p>
|
|
204
|
+
)}
|
|
205
|
+
</section>
|
|
206
|
+
{(
|
|
207
|
+
[
|
|
208
|
+
['origin', 'destination'],
|
|
209
|
+
['carrier', 'service', 'cargo'],
|
|
210
|
+
['weight', 'volume', 'equipment'],
|
|
211
|
+
] as const
|
|
212
|
+
).map((group, index) => (
|
|
213
|
+
<section key={group[0]} className="space-y-3">
|
|
214
|
+
<h3 className="font-semibold">
|
|
215
|
+
{['Locations', 'Carrier, service and cargo', 'Measurements and equipment'][index]}
|
|
216
|
+
</h3>
|
|
217
|
+
<div className="grid min-w-0 gap-3 md:grid-cols-2">
|
|
218
|
+
{group.map((slot) => (
|
|
219
|
+
<FreightCommercialFactEditor
|
|
220
|
+
key={slot}
|
|
221
|
+
slot={slot}
|
|
222
|
+
value={value.facts[slot]}
|
|
223
|
+
evidence={evidence}
|
|
224
|
+
onChange={(fact) => onChange({ ...value, facts: { ...value.facts, [slot]: fact } })}
|
|
225
|
+
/>
|
|
226
|
+
))}
|
|
227
|
+
</div>
|
|
228
|
+
</section>
|
|
229
|
+
))}
|
|
230
|
+
<p className="text-sm">
|
|
231
|
+
{commercialSlots.length} declared facts. Owner shipment and leg snapshots are recorded
|
|
232
|
+
separately by the server.
|
|
233
|
+
</p>
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Button } from '../ui';
|
|
3
|
+
import { FreightSelect, FreightText } from './FreightFields';
|
|
4
|
+
import { freightInstant, freightLocalTime } from '../../utils/freightDraft';
|
|
5
|
+
import {
|
|
6
|
+
newCommercialFact,
|
|
7
|
+
type CommercialSlot,
|
|
8
|
+
type CommercialFactDraft,
|
|
9
|
+
} from '../../utils/freightCommercial';
|
|
10
|
+
import type { SelectedEvidence } from '../ContainerEvidencePicker';
|
|
11
|
+
|
|
12
|
+
export function FreightCommercialFactEditor({
|
|
13
|
+
slot,
|
|
14
|
+
value,
|
|
15
|
+
onChange,
|
|
16
|
+
evidence,
|
|
17
|
+
}: {
|
|
18
|
+
slot: CommercialSlot;
|
|
19
|
+
value: CommercialFactDraft;
|
|
20
|
+
onChange: (value: CommercialFactDraft) => void;
|
|
21
|
+
evidence: SelectedEvidence[];
|
|
22
|
+
}) {
|
|
23
|
+
const [editingReason, setEditingReason] = useState(false);
|
|
24
|
+
const label = slot[0].toUpperCase() + slot.slice(1);
|
|
25
|
+
const patch = (values: Partial<CommercialFactDraft>) => onChange({ ...value, ...values });
|
|
26
|
+
return (
|
|
27
|
+
<fieldset className="min-w-0 space-y-3 rounded border p-3">
|
|
28
|
+
<legend className="px-1 font-semibold">{label}</legend>
|
|
29
|
+
<FreightSelect
|
|
30
|
+
label={`${label} status`}
|
|
31
|
+
value={value.status}
|
|
32
|
+
onChange={(status) => {
|
|
33
|
+
if (status === 'UNKNOWN' || status === 'SOURCED' || status === 'NOT_APPLICABLE') {
|
|
34
|
+
setEditingReason(false);
|
|
35
|
+
onChange(newCommercialFact(status));
|
|
36
|
+
}
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
<option value="UNKNOWN">Unknown — not recorded</option>
|
|
40
|
+
<option value="SOURCED">Sourced</option>
|
|
41
|
+
<option value="NOT_APPLICABLE">Not applicable</option>
|
|
42
|
+
</FreightSelect>
|
|
43
|
+
{value.status === 'UNKNOWN' && !editingReason && (
|
|
44
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
45
|
+
<p>{value.reason}</p>
|
|
46
|
+
<Button type="button" variant="outline" onClick={() => setEditingReason(true)}>
|
|
47
|
+
Edit {label.toLowerCase()} reason
|
|
48
|
+
</Button>
|
|
49
|
+
</div>
|
|
50
|
+
)}
|
|
51
|
+
{(value.status === 'NOT_APPLICABLE' || (value.status === 'UNKNOWN' && editingReason)) && (
|
|
52
|
+
<FreightText
|
|
53
|
+
label={`${label} reason`}
|
|
54
|
+
value={value.reason}
|
|
55
|
+
required
|
|
56
|
+
onChange={(reason) => patch({ reason })}
|
|
57
|
+
/>
|
|
58
|
+
)}
|
|
59
|
+
{value.status === 'SOURCED' && (
|
|
60
|
+
<CommercialFactValue slot={slot} label={label} value={value} patch={patch} />
|
|
61
|
+
)}
|
|
62
|
+
{value.status !== 'UNKNOWN' && (
|
|
63
|
+
<CommercialFactSource label={label} value={value} patch={patch} evidence={evidence} />
|
|
64
|
+
)}
|
|
65
|
+
</fieldset>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface FactFieldsProps {
|
|
70
|
+
label: string;
|
|
71
|
+
value: CommercialFactDraft;
|
|
72
|
+
patch: (values: Partial<CommercialFactDraft>) => void;
|
|
73
|
+
}
|
|
74
|
+
function CommercialFactValue({
|
|
75
|
+
slot,
|
|
76
|
+
label,
|
|
77
|
+
value,
|
|
78
|
+
patch,
|
|
79
|
+
}: FactFieldsProps & { slot: CommercialSlot }) {
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
{(slot === 'origin' || slot === 'destination') && (
|
|
83
|
+
<>
|
|
84
|
+
<FreightSelect
|
|
85
|
+
label={`${label} label kind`}
|
|
86
|
+
value={value.kind}
|
|
87
|
+
onChange={(kind) => patch({ kind })}
|
|
88
|
+
>
|
|
89
|
+
<option value="">Choose a label kind</option>
|
|
90
|
+
{[
|
|
91
|
+
'ADDRESS_LABEL',
|
|
92
|
+
'PORT_LABEL',
|
|
93
|
+
'TERMINAL_LABEL',
|
|
94
|
+
'POSTCODE_LABEL',
|
|
95
|
+
'CUSTOM_LABEL',
|
|
96
|
+
].map((kind) => (
|
|
97
|
+
<option key={kind} value={kind}>
|
|
98
|
+
{kind.replaceAll('_', ' ').toLowerCase()}
|
|
99
|
+
</option>
|
|
100
|
+
))}
|
|
101
|
+
</FreightSelect>
|
|
102
|
+
<FreightText
|
|
103
|
+
label={`${label} jurisdiction label (optional)`}
|
|
104
|
+
value={value.jurisdiction}
|
|
105
|
+
onChange={(jurisdiction) => patch({ jurisdiction })}
|
|
106
|
+
/>
|
|
107
|
+
</>
|
|
108
|
+
)}
|
|
109
|
+
{slot === 'carrier' && (
|
|
110
|
+
<FreightText
|
|
111
|
+
label="Carrier reference"
|
|
112
|
+
value={value.reference}
|
|
113
|
+
required
|
|
114
|
+
onChange={(reference) => patch({ reference })}
|
|
115
|
+
/>
|
|
116
|
+
)}
|
|
117
|
+
{slot !== 'weight' && slot !== 'volume' && (
|
|
118
|
+
<FreightText
|
|
119
|
+
label={
|
|
120
|
+
slot === 'cargo'
|
|
121
|
+
? 'Cargo classification label'
|
|
122
|
+
: slot === 'equipment'
|
|
123
|
+
? 'Equipment type label'
|
|
124
|
+
: `${label} label`
|
|
125
|
+
}
|
|
126
|
+
value={value.label}
|
|
127
|
+
required
|
|
128
|
+
onChange={(label) => patch({ label })}
|
|
129
|
+
/>
|
|
130
|
+
)}
|
|
131
|
+
{slot === 'cargo' && (
|
|
132
|
+
<FreightText
|
|
133
|
+
label="Cargo description"
|
|
134
|
+
value={value.description}
|
|
135
|
+
required
|
|
136
|
+
onChange={(description) => patch({ description })}
|
|
137
|
+
/>
|
|
138
|
+
)}
|
|
139
|
+
{(slot === 'weight' || slot === 'volume') && (
|
|
140
|
+
<>
|
|
141
|
+
<FreightText
|
|
142
|
+
label={`${label} amount`}
|
|
143
|
+
value={value.amount}
|
|
144
|
+
required
|
|
145
|
+
onChange={(amount) =>
|
|
146
|
+
patch({ amount, zeroReason: amount === '0' ? value.zeroReason : '' })
|
|
147
|
+
}
|
|
148
|
+
/>
|
|
149
|
+
<FreightSelect
|
|
150
|
+
label={`${label} recorded unit`}
|
|
151
|
+
value={value.unit}
|
|
152
|
+
onChange={(unit) => patch({ unit })}
|
|
153
|
+
>
|
|
154
|
+
<option value="">Choose a unit</option>
|
|
155
|
+
{(slot === 'weight' ? ['KG', 'TONNE', 'LB'] : ['M3', 'FT3']).map((unit) => (
|
|
156
|
+
<option key={unit} value={unit}>
|
|
157
|
+
{unit}
|
|
158
|
+
</option>
|
|
159
|
+
))}
|
|
160
|
+
</FreightSelect>
|
|
161
|
+
{value.amount === '0' && (
|
|
162
|
+
<FreightText
|
|
163
|
+
label={`${label} recorded zero reason`}
|
|
164
|
+
value={value.zeroReason}
|
|
165
|
+
required
|
|
166
|
+
onChange={(zeroReason) => patch({ zeroReason })}
|
|
167
|
+
/>
|
|
168
|
+
)}
|
|
169
|
+
<p className="text-sm">Record the source unit. No conversion is inferred.</p>
|
|
170
|
+
</>
|
|
171
|
+
)}
|
|
172
|
+
{slot === 'equipment' && (
|
|
173
|
+
<FreightText
|
|
174
|
+
label="Equipment count"
|
|
175
|
+
value={value.count}
|
|
176
|
+
required
|
|
177
|
+
onChange={(count) => patch({ count })}
|
|
178
|
+
/>
|
|
179
|
+
)}
|
|
180
|
+
</>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
function CommercialFactSource({
|
|
184
|
+
label,
|
|
185
|
+
value,
|
|
186
|
+
patch,
|
|
187
|
+
evidence,
|
|
188
|
+
}: FactFieldsProps & { evidence: SelectedEvidence[] }) {
|
|
189
|
+
return (
|
|
190
|
+
<>
|
|
191
|
+
<h4 className="font-medium">{label} source declaration</h4>
|
|
192
|
+
<FreightText
|
|
193
|
+
label={`${label} source reference`}
|
|
194
|
+
value={value.sourceReference}
|
|
195
|
+
required
|
|
196
|
+
onChange={(sourceReference) => patch({ sourceReference })}
|
|
197
|
+
/>
|
|
198
|
+
<FreightText
|
|
199
|
+
label={`${label} source revision`}
|
|
200
|
+
value={value.sourceRevision}
|
|
201
|
+
required
|
|
202
|
+
onChange={(sourceRevision) => patch({ sourceRevision })}
|
|
203
|
+
/>
|
|
204
|
+
<FreightText
|
|
205
|
+
label={`${label} observed at (local time, optional)`}
|
|
206
|
+
type="datetime-local"
|
|
207
|
+
value={value.observedAt ? freightLocalTime(value.observedAt) : ''}
|
|
208
|
+
onChange={(time) => patch({ observedAt: time ? freightInstant(time) : '' })}
|
|
209
|
+
/>
|
|
210
|
+
<p className="text-sm">
|
|
211
|
+
Blank observation time is recorded as Unknown. No date is inferred from the upload or file
|
|
212
|
+
check.
|
|
213
|
+
</p>
|
|
214
|
+
<p className="text-sm">
|
|
215
|
+
Choose 1–2 versions from the evidence selected below for this record. This declares the
|
|
216
|
+
source; it does not verify its authority.
|
|
217
|
+
</p>
|
|
218
|
+
{evidence.map((item) => {
|
|
219
|
+
const selected = value.evidenceLinks.some(
|
|
220
|
+
(link) => link.fileId === item.fileId && link.versionId === item.versionId
|
|
221
|
+
);
|
|
222
|
+
return (
|
|
223
|
+
<label
|
|
224
|
+
key={item.versionId}
|
|
225
|
+
className="flex min-h-11 items-center gap-2 break-words text-sm"
|
|
226
|
+
>
|
|
227
|
+
<input
|
|
228
|
+
type="checkbox"
|
|
229
|
+
checked={selected}
|
|
230
|
+
disabled={!selected && value.evidenceLinks.length >= 2}
|
|
231
|
+
onChange={() =>
|
|
232
|
+
patch({
|
|
233
|
+
evidenceLinks: selected
|
|
234
|
+
? value.evidenceLinks.filter(
|
|
235
|
+
(link) =>
|
|
236
|
+
!(link.fileId === item.fileId && link.versionId === item.versionId)
|
|
237
|
+
)
|
|
238
|
+
: [...value.evidenceLinks, { fileId: item.fileId, versionId: item.versionId }],
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
/>
|
|
242
|
+
{item.label}
|
|
243
|
+
</label>
|
|
244
|
+
);
|
|
245
|
+
})}
|
|
246
|
+
{!evidence.length && <p>Select an exact Files version in Evidence for this record.</p>}
|
|
247
|
+
{value.evidenceLinks.some(
|
|
248
|
+
(link) =>
|
|
249
|
+
!evidence.some((item) => item.fileId === link.fileId && item.versionId === link.versionId)
|
|
250
|
+
) && (
|
|
251
|
+
<p role="alert">
|
|
252
|
+
A linked evidence version was removed. Select the fact status again and review its source
|
|
253
|
+
links before saving.
|
|
254
|
+
</p>
|
|
255
|
+
)}
|
|
256
|
+
</>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { FreightCommercialVersionQuery } from '../../../generated/freight-cost-operations';
|
|
2
|
+
import { commercialSlots } from '../../utils/freightCommercial';
|
|
3
|
+
import { freightLabel } from '../../utils/freightDraft';
|
|
4
|
+
|
|
5
|
+
export function FreightCommercialHistory({
|
|
6
|
+
capture,
|
|
7
|
+
}: {
|
|
8
|
+
capture: FreightCommercialVersionQuery['mtwFreightEstimateVersion']['commercialCapture'];
|
|
9
|
+
}) {
|
|
10
|
+
if (capture === undefined)
|
|
11
|
+
return <p role="alert">Commercial context is unavailable in this reply.</p>;
|
|
12
|
+
if (capture === null) return <p>Commercial context was not captured with this version.</p>;
|
|
13
|
+
return (
|
|
14
|
+
<section className="min-w-0 space-y-4 break-words rounded border p-4">
|
|
15
|
+
<h3 className="font-semibold">
|
|
16
|
+
Recorded commercial context — schema {capture.schemaVersion}
|
|
17
|
+
</h3>
|
|
18
|
+
<p className="text-sm">
|
|
19
|
+
These are source declarations, not quote acceptance, verified authority or a complete
|
|
20
|
+
commercial checklist.
|
|
21
|
+
</p>
|
|
22
|
+
<div className="grid min-w-0 gap-3 md:grid-cols-2">
|
|
23
|
+
{commercialSlots.map((slot) => {
|
|
24
|
+
const fact = capture[slot];
|
|
25
|
+
return (
|
|
26
|
+
<article key={slot} className="space-y-2 rounded border p-3">
|
|
27
|
+
<h4 className="font-medium">
|
|
28
|
+
{freightLabel(slot)} — {freightLabel(fact.status)}
|
|
29
|
+
</h4>
|
|
30
|
+
{fact.reason && <p>{fact.reason}</p>}
|
|
31
|
+
{typeof fact.value === 'string' ? (
|
|
32
|
+
<p>{fact.value}</p>
|
|
33
|
+
) : (
|
|
34
|
+
fact.value && (
|
|
35
|
+
<dl>
|
|
36
|
+
{Object.entries(fact.value)
|
|
37
|
+
.filter(([key]) => key !== '__typename')
|
|
38
|
+
.map(([key, value]) => (
|
|
39
|
+
<div key={key}>
|
|
40
|
+
<dt className="inline font-medium">{key}: </dt>
|
|
41
|
+
<dd className="inline">
|
|
42
|
+
{value === null || value === undefined ? 'Not recorded' : String(value)}
|
|
43
|
+
</dd>
|
|
44
|
+
</div>
|
|
45
|
+
))}
|
|
46
|
+
</dl>
|
|
47
|
+
)
|
|
48
|
+
)}
|
|
49
|
+
{fact.provenance && (
|
|
50
|
+
<div className="text-sm">
|
|
51
|
+
<p>
|
|
52
|
+
Declared source {fact.provenance.sourceReference} /{' '}
|
|
53
|
+
{fact.provenance.sourceRevision}; observed{' '}
|
|
54
|
+
{fact.provenance.observedAt ?? 'Unknown'}.
|
|
55
|
+
</p>
|
|
56
|
+
{fact.provenance.evidenceLinks.map((link) => (
|
|
57
|
+
<p key={JSON.stringify([link.fileId, link.versionId])}>
|
|
58
|
+
Exact Files link: {link.fileId}; version {link.versionId}.
|
|
59
|
+
</p>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
</article>
|
|
64
|
+
);
|
|
65
|
+
})}
|
|
66
|
+
</div>
|
|
67
|
+
<section className="space-y-2">
|
|
68
|
+
<h4 className="font-medium">
|
|
69
|
+
Server-recorded owner context (separate from declared facts)
|
|
70
|
+
</h4>
|
|
71
|
+
<p>
|
|
72
|
+
Shipment {capture.ownerShipment.shipmentId}; revision at {capture.ownerShipment.updatedAt}
|
|
73
|
+
; mode {capture.ownerShipment.primaryMode}.
|
|
74
|
+
</p>
|
|
75
|
+
{capture.ownerLegs.length === 0 && (
|
|
76
|
+
<p>No legs selected for this capture. This does not mean the shipment has no legs.</p>
|
|
77
|
+
)}
|
|
78
|
+
{capture.ownerLegs.map((leg) => (
|
|
79
|
+
<p key={leg.id}>
|
|
80
|
+
Leg {leg.legNumber} ({leg.id}) / {leg.mode}; revision {leg.updatedAt}; carrier{' '}
|
|
81
|
+
{leg.carrierName ?? 'Not recorded'} ({leg.carrierId ?? 'No carrier reference'}); weight{' '}
|
|
82
|
+
{leg.weightKg ?? 'Not recorded'} kg; volume {leg.volumeCbm ?? 'Not recorded'} m³;
|
|
83
|
+
equipment {leg.containerType ?? 'Not recorded'} × {leg.containerCount ?? 'Not recorded'}
|
|
84
|
+
.
|
|
85
|
+
</p>
|
|
86
|
+
))}
|
|
87
|
+
</section>
|
|
88
|
+
</section>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { useRef, useState } from 'react';
|
|
2
|
+
import type { CommercialCapture, CommercialEditorState } from '../../utils/freightCommercial';
|
|
3
|
+
import { commercialCaptureInput, commercialCanonical } from '../../utils/freightCommercial';
|
|
4
|
+
import { FreightCommercialEditor } from './FreightCommercialEditor';
|
|
2
5
|
import type {
|
|
3
6
|
MtwFreightFactsInput,
|
|
4
7
|
PreviewFreightCostsQuery,
|
|
@@ -18,15 +21,22 @@ import {
|
|
|
18
21
|
} from '../../utils/freightDraft';
|
|
19
22
|
export function FreightDraftForm({
|
|
20
23
|
disabled,
|
|
24
|
+
shipmentId,
|
|
21
25
|
onPreview,
|
|
22
26
|
onSave,
|
|
23
27
|
}: {
|
|
24
28
|
disabled: boolean;
|
|
29
|
+
shipmentId: string;
|
|
25
30
|
onPreview: (
|
|
26
31
|
facts: MtwFreightFactsInput
|
|
27
32
|
) => Promise<PreviewFreightCostsQuery['previewMtwFreightTotal']>;
|
|
28
|
-
onSave: (
|
|
33
|
+
onSave: (
|
|
34
|
+
facts: MtwFreightFactsInput,
|
|
35
|
+
evidence: SelectedEvidence[],
|
|
36
|
+
commercialCapture?: CommercialCapture
|
|
37
|
+
) => Promise<void>;
|
|
29
38
|
}) {
|
|
39
|
+
const [commercial, setCommercial] = useState<CommercialEditorState | null>(null);
|
|
30
40
|
const [facts, setFacts] = useState(newFreightFacts);
|
|
31
41
|
const [evidence, setEvidence] = useState<SelectedEvidence[]>([]);
|
|
32
42
|
const [preview, setPreview] = useState<{
|
|
@@ -65,9 +75,22 @@ export function FreightDraftForm({
|
|
|
65
75
|
return;
|
|
66
76
|
}
|
|
67
77
|
setError(null);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
try {
|
|
79
|
+
if (commercial && commercial.reviewed !== commercialCanonical(commercial.selectedLegs))
|
|
80
|
+
throw new Error('Explicitly review the commercial leg selection before saving.');
|
|
81
|
+
const capture = commercial
|
|
82
|
+
? commercialCaptureInput(commercial.facts, commercial.selectedLegs, evidence)
|
|
83
|
+
: undefined;
|
|
84
|
+
void onSave(structuredClone(facts), structuredClone(evidence), capture).catch((failure) =>
|
|
85
|
+
setError(
|
|
86
|
+
failure instanceof Error ? failure.message : 'Unable to save this cost record.'
|
|
87
|
+
)
|
|
88
|
+
);
|
|
89
|
+
} catch (failure) {
|
|
90
|
+
setError(
|
|
91
|
+
failure instanceof Error ? failure.message : 'Review commercial facts before saving.'
|
|
92
|
+
);
|
|
93
|
+
}
|
|
71
94
|
}}
|
|
72
95
|
>
|
|
73
96
|
<fieldset disabled={disabled || previewing} className="space-y-5">
|
|
@@ -194,6 +217,12 @@ export function FreightDraftForm({
|
|
|
194
217
|
)}
|
|
195
218
|
<FreightFxEditor value={facts.fx} onChange={(fx) => patch({ fx })} />
|
|
196
219
|
</details>
|
|
220
|
+
<FreightCommercialEditor
|
|
221
|
+
value={commercial}
|
|
222
|
+
onChange={setCommercial}
|
|
223
|
+
shipmentId={shipmentId}
|
|
224
|
+
evidence={evidence}
|
|
225
|
+
/>
|
|
197
226
|
<ContainerEvidencePicker
|
|
198
227
|
value={evidence}
|
|
199
228
|
onChange={setEvidence}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { FreightCommercialHistory } from './FreightCommercialHistory';
|
|
2
|
+
import type { FreightCommercialVersionQuery } from '../../../generated/freight-cost-operations';
|
|
2
3
|
import { FreightTotals } from './FreightTotals';
|
|
3
4
|
import { freightLabel, freightMoney } from '../../utils/freightDraft';
|
|
4
5
|
export function FreightHistory({
|
|
5
6
|
record,
|
|
6
7
|
}: {
|
|
7
|
-
record:
|
|
8
|
+
record: FreightCommercialVersionQuery['mtwFreightEstimateVersion'];
|
|
8
9
|
}) {
|
|
9
10
|
return (
|
|
10
11
|
<article className="min-w-0 space-y-4 break-words rounded border p-4">
|
|
@@ -28,6 +29,7 @@ export function FreightHistory({
|
|
|
28
29
|
Price at {record.facts.pricingAt}; recorded {record.recordedAt} by {record.createdBy} (
|
|
29
30
|
{freightLabel(record.actorProvenance)}).
|
|
30
31
|
</p>
|
|
32
|
+
<FreightCommercialHistory capture={record.commercialCapture} />
|
|
31
33
|
<FreightTotals value={record.totals} currency={record.facts.reportingCurrency} />
|
|
32
34
|
<details>
|
|
33
35
|
<summary className="min-h-11 cursor-pointer font-medium">
|