@burdenoff/microfe-movethewheels 2026.1102.1 → 2026.1102.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.
Files changed (27) hide show
  1. package/dist/components/freight-cost/FreightCommercialFactEditor.js +46 -46
  2. package/dist/components/freight-cost/FreightCommercialHistory.js +7 -7
  3. package/dist/components/freight-cost/FreightDraftForm.js +20 -20
  4. package/dist/components/freight-invoice/InvoiceReviewPanel.js +255 -0
  5. package/dist/components/freight-invoice/InvoiceReviewQueue.js +93 -0
  6. package/dist/generated/freight-invoice-operations.d.ts +461 -0
  7. package/dist/hooks/useInvoiceComparisonRecovery.js +26 -26
  8. package/dist/hooks/useInvoiceReviewPermissions.js +14 -0
  9. package/dist/movethewheels/components/freight-invoice/InvoiceReviewPanel.d.ts +11 -0
  10. package/dist/movethewheels/components/freight-invoice/InvoiceReviewQueue.d.ts +22 -0
  11. package/dist/movethewheels/hooks/useInvoiceReviewPermissions.d.ts +5 -0
  12. package/dist/movethewheels/pages/FinancePage.d.ts +3 -1
  13. package/dist/movethewheels/utils/invoiceReview.d.ts +26 -0
  14. package/dist/pages/FinancePage.js +109 -67
  15. package/dist/pages/FreightEstimatePage.js +89 -89
  16. package/dist/pages/FreightInvoicePage.js +138 -120
  17. package/dist/src/generated/freight-invoice-operations.js +2100 -1
  18. package/dist/utils/invoiceReview.js +84 -0
  19. package/package.json +2 -2
  20. package/src/generated/freight-invoice-operations.ts +245 -1
  21. package/src/movethewheels/components/freight-invoice/InvoiceReviewPanel.tsx +319 -0
  22. package/src/movethewheels/components/freight-invoice/InvoiceReviewQueue.tsx +96 -0
  23. package/src/movethewheels/hooks/useInvoiceReviewPermissions.ts +25 -0
  24. package/src/movethewheels/pages/FinancePage.tsx +34 -2
  25. package/src/movethewheels/pages/FreightInvoicePage.tsx +44 -3
  26. package/src/movethewheels/utils/invoiceReview.ts +146 -0
  27. package/src/operations/freight-invoice/review.graphql +26 -0
@@ -0,0 +1,319 @@
1
+ import { useState } from 'react';
2
+ import { Button } from '../ui';
3
+ import { FreightSelect } from '../freight-cost/FreightFields';
4
+ import { ReuseState, reuseCard } from '../ReuseControls';
5
+ import { useConditionApi, useConditionQuery } from '../../hooks/useContainerCondition';
6
+ import {
7
+ DecideInvoiceReviewDocument,
8
+ HandOffInvoiceSettlementDocument,
9
+ InvoiceReviewExactDocument,
10
+ RecordInvoiceSupplierResponseDocument,
11
+ ResolveInvoiceDisputeDocument,
12
+ type InvoiceReceiptFragment,
13
+ type InvoiceReviewExactQuery,
14
+ type InvoiceReviewFragment,
15
+ type MtwFreightInvoiceDecision,
16
+ type MtwFreightInvoiceResolution,
17
+ type MtwFreightInvoiceReviewAction,
18
+ } from '../../../generated/freight-invoice-operations';
19
+ import { invoiceMoney } from '../../utils/invoiceComparison';
20
+ import {
21
+ DRAFT_PROBLEM_LABELS,
22
+ REVIEW_EVENT_LABELS,
23
+ REVIEW_STATUS_LABELS,
24
+ authorNotice,
25
+ availableReviewActions,
26
+ decisionInput,
27
+ handOffInput,
28
+ isDraftProblem,
29
+ resolutionInput,
30
+ supplierResponseInput,
31
+ type ReviewPermissions,
32
+ } from '../../utils/invoiceReview';
33
+
34
+ const textArea = 'mt-1 min-h-24 w-full rounded border p-2';
35
+
36
+ /**
37
+ * Review, dispute and Billing hand-off for one exact comparison revision
38
+ * (BOFF-10588). Every step appends to the revision's history; nothing is edited.
39
+ */
40
+ export function InvoiceReviewPanel({
41
+ record,
42
+ permissions,
43
+ onChanged,
44
+ }: {
45
+ record: Pick<InvoiceReceiptFragment, 'id' | 'shipmentId' | 'facts'>;
46
+ permissions: ReviewPermissions;
47
+ onChanged?: () => void;
48
+ }) {
49
+ const [revision, setRevision] = useState(0);
50
+ const review = useConditionQuery<InvoiceReviewExactQuery>(
51
+ InvoiceReviewExactDocument,
52
+ permissions.canRead ? { shipmentId: record.shipmentId, comparisonId: record.id } : null,
53
+ revision
54
+ );
55
+ if (!permissions.canRead)
56
+ return (
57
+ <section className={reuseCard} aria-label="Invoice review">
58
+ <h2 className="text-lg font-semibold">Invoice review</h2>
59
+ <p>Freight invoice read permission is required to see the review status of this invoice.</p>
60
+ </section>
61
+ );
62
+ const value = review.data?.mtwFreightInvoiceReview;
63
+ return (
64
+ <section className={reuseCard} aria-label="Invoice review">
65
+ <h2 className="text-lg font-semibold">Invoice review</h2>
66
+ <ReuseState loading={review.loading} error={review.error} />
67
+ {value && (
68
+ <ReviewBody
69
+ key={`${value.comparisonId}:${value.sequence}`}
70
+ review={value}
71
+ lines={record.facts.rows}
72
+ permissions={permissions}
73
+ onRecorded={() => {
74
+ setRevision((n) => n + 1);
75
+ onChanged?.();
76
+ }}
77
+ />
78
+ )}
79
+ </section>
80
+ );
81
+ }
82
+
83
+ function ReviewBody({
84
+ review,
85
+ lines,
86
+ permissions,
87
+ onRecorded,
88
+ }: {
89
+ review: InvoiceReviewFragment;
90
+ lines: InvoiceReceiptFragment['facts']['rows'];
91
+ permissions: ReviewPermissions;
92
+ onRecorded: () => void;
93
+ }) {
94
+ const actions = availableReviewActions(review, permissions);
95
+ const notice = authorNotice(review);
96
+ const lineLabel = (id: string) => lines.find((line) => line.id === id)?.description ?? id;
97
+ return (
98
+ <div className="space-y-4">
99
+ <p className="font-medium" role="status">
100
+ Status: {REVIEW_STATUS_LABELS[review.status]}
101
+ </p>
102
+ <p>
103
+ Revision {review.revision} of {review.latestRevision}. Known invoice amount{' '}
104
+ {invoiceMoney(review.knownInvoiceAmountMinor, review.currency)}
105
+ {review.invoiceAmountComplete
106
+ ? ''
107
+ : ' (some reported amounts are unknown and are not counted as zero)'}
108
+ .
109
+ </p>
110
+ {review.status === 'SUPERSEDED' && (
111
+ <p>
112
+ A newer correction replaces this revision; review continues on the latest revision.
113
+ {review.resolvedByCorrection && ' Its open dispute was resolved by that correction.'}
114
+ </p>
115
+ )}
116
+ {review.settlementReference && (
117
+ <p>
118
+ Handed to platform Billing for settlement under reference{' '}
119
+ <span className="break-all font-mono">{review.settlementReference}</span>. This revision
120
+ can no longer be corrected.
121
+ </p>
122
+ )}
123
+ {notice && <p>{notice}</p>}
124
+ {review.events.length > 0 && (
125
+ <ol className="space-y-2" aria-label="Review history">
126
+ {review.events.map((event) => (
127
+ <li key={event.id} className="rounded border p-3 text-sm">
128
+ <p className="font-medium">
129
+ {event.sequence}. {REVIEW_EVENT_LABELS[event.kind]} —{' '}
130
+ {REVIEW_STATUS_LABELS[event.statusAfter]}
131
+ </p>
132
+ <p>
133
+ {event.recordedAt} by {event.createdBy}
134
+ </p>
135
+ {event.reason && <p className="whitespace-pre-wrap break-words">{event.reason}</p>}
136
+ {event.disputedLineIds.length > 0 && (
137
+ <p>Disputed lines: {event.disputedLineIds.map(lineLabel).join(', ')}</p>
138
+ )}
139
+ </li>
140
+ ))}
141
+ </ol>
142
+ )}
143
+ {actions.length > 0 ? (
144
+ <ReviewStep review={review} actions={actions} lines={lines} onRecorded={onRecorded} />
145
+ ) : (
146
+ review.status !== 'SUPERSEDED' &&
147
+ review.status !== 'SETTLEMENT_HANDED_OFF' &&
148
+ !notice && <p>Your role cannot take the next review step on this invoice.</p>
149
+ )}
150
+ </div>
151
+ );
152
+ }
153
+
154
+ const ACTION_LABELS: Record<MtwFreightInvoiceReviewAction, string> = {
155
+ DECIDE: 'Accept or dispute',
156
+ RECORD_SUPPLIER_RESPONSE: 'Record supplier response',
157
+ RESOLVE_DISPUTE: 'Resolve dispute',
158
+ HAND_OFF_SETTLEMENT: 'Hand off to Billing',
159
+ };
160
+
161
+ function ReviewStep({
162
+ review,
163
+ actions,
164
+ lines,
165
+ onRecorded,
166
+ }: {
167
+ review: InvoiceReviewFragment;
168
+ actions: MtwFreightInvoiceReviewAction[];
169
+ lines: InvoiceReceiptFragment['facts']['rows'];
170
+ onRecorded: () => void;
171
+ }) {
172
+ const request = useConditionApi();
173
+ const [action, setAction] = useState<MtwFreightInvoiceReviewAction>(actions[0]);
174
+ const [decision, setDecision] = useState<MtwFreightInvoiceDecision>('ACCEPTED');
175
+ const [resolution, setResolution] = useState<MtwFreightInvoiceResolution>('ACCEPTED');
176
+ const [reason, setReason] = useState('');
177
+ const [disputed, setDisputed] = useState<string[]>([]);
178
+ // One key per draft: a resend after a lost response replays instead of recording twice.
179
+ const [idempotencyKey, setKey] = useState(() => crypto.randomUUID());
180
+ const [busy, setBusy] = useState(false);
181
+ const [problem, setProblem] = useState<string | null>(null);
182
+ const edit =
183
+ <T,>(set: (value: T) => void) =>
184
+ (value: T) => {
185
+ set(value);
186
+ setKey(crypto.randomUUID());
187
+ setProblem(null);
188
+ };
189
+ const base = { review, idempotencyKey };
190
+ const submit = async () => {
191
+ const plan =
192
+ action === 'DECIDE'
193
+ ? {
194
+ document: DecideInvoiceReviewDocument,
195
+ input: decisionInput(base, decision, reason, disputed),
196
+ }
197
+ : action === 'RECORD_SUPPLIER_RESPONSE'
198
+ ? {
199
+ document: RecordInvoiceSupplierResponseDocument,
200
+ input: supplierResponseInput(base, reason),
201
+ }
202
+ : action === 'RESOLVE_DISPUTE'
203
+ ? {
204
+ document: ResolveInvoiceDisputeDocument,
205
+ input: resolutionInput(base, resolution, reason),
206
+ }
207
+ : { document: HandOffInvoiceSettlementDocument, input: handOffInput(base, reason) };
208
+ if (isDraftProblem(plan.input)) {
209
+ setProblem(DRAFT_PROBLEM_LABELS[plan.input]);
210
+ return;
211
+ }
212
+ setBusy(true);
213
+ setProblem(null);
214
+ try {
215
+ await request(plan.document, { input: plan.input });
216
+ onRecorded();
217
+ } catch (failure) {
218
+ setProblem(failure instanceof Error ? failure.message : 'The review step was not recorded.');
219
+ } finally {
220
+ setBusy(false);
221
+ }
222
+ };
223
+ return (
224
+ <form
225
+ className="space-y-3 border-t pt-4"
226
+ aria-label="Next review step"
227
+ onSubmit={(event) => {
228
+ event.preventDefault();
229
+ void submit();
230
+ }}
231
+ >
232
+ {actions.length > 1 && (
233
+ <FreightSelect
234
+ label="Review step"
235
+ value={action}
236
+ onChange={(value) => edit(setAction)(value as MtwFreightInvoiceReviewAction)}
237
+ >
238
+ {actions.map((item) => (
239
+ <option key={item} value={item}>
240
+ {ACTION_LABELS[item]}
241
+ </option>
242
+ ))}
243
+ </FreightSelect>
244
+ )}
245
+ {action === 'DECIDE' && (
246
+ <>
247
+ <FreightSelect
248
+ label="Decision"
249
+ value={decision}
250
+ onChange={(value) => edit(setDecision)(value as MtwFreightInvoiceDecision)}
251
+ >
252
+ <option value="ACCEPTED">Accept</option>
253
+ <option value="ACCEPTED_WITH_VARIANCE">Accept with variance</option>
254
+ <option value="DISPUTED">Dispute</option>
255
+ </FreightSelect>
256
+ {decision === 'DISPUTED' && (
257
+ <fieldset className="space-y-2">
258
+ <legend className="text-sm font-medium">Disputed invoice lines</legend>
259
+ {lines.map((line) => (
260
+ <label key={line.id} className="flex min-h-11 items-center gap-2 text-sm">
261
+ <input
262
+ type="checkbox"
263
+ checked={disputed.includes(line.id)}
264
+ onChange={(event) =>
265
+ edit(setDisputed)(
266
+ event.target.checked
267
+ ? [...disputed, line.id]
268
+ : disputed.filter((id) => id !== line.id)
269
+ )
270
+ }
271
+ />
272
+ {line.description} ({invoiceMoney(line.amountMinor, line.currency)})
273
+ </label>
274
+ ))}
275
+ </fieldset>
276
+ )}
277
+ </>
278
+ )}
279
+ {action === 'RESOLVE_DISPUTE' && (
280
+ <FreightSelect
281
+ label="Resolution"
282
+ value={resolution}
283
+ onChange={(value) => edit(setResolution)(value as MtwFreightInvoiceResolution)}
284
+ >
285
+ <option value="ACCEPTED">Accept as invoiced</option>
286
+ <option value="ACCEPTED_WITH_VARIANCE">Accept with variance</option>
287
+ </FreightSelect>
288
+ )}
289
+ <label className="block text-sm">
290
+ {action === 'RECORD_SUPPLIER_RESPONSE'
291
+ ? "Supplier's response"
292
+ : action === 'HAND_OFF_SETTLEMENT'
293
+ ? 'Note for Billing (optional)'
294
+ : 'Reason'}
295
+ <textarea
296
+ className={textArea}
297
+ value={reason}
298
+ maxLength={1000}
299
+ onChange={(event) => edit(setReason)(event.target.value)}
300
+ />
301
+ </label>
302
+ {action === 'RESOLVE_DISPUTE' && (
303
+ <p className="text-sm">
304
+ A dispute can also close by recording a corrected invoice revision.
305
+ </p>
306
+ )}
307
+ {action === 'HAND_OFF_SETTLEMENT' && (
308
+ <p className="text-sm">
309
+ Billing settles the invoice; MoveTheWheels keeps no payment ledger. After hand-off this
310
+ revision can no longer be corrected.
311
+ </p>
312
+ )}
313
+ {problem && <p role="alert">{problem}</p>}
314
+ <Button type="submit" disabled={busy}>
315
+ {busy ? 'Recording…' : ACTION_LABELS[action]}
316
+ </Button>
317
+ </form>
318
+ );
319
+ }
@@ -0,0 +1,96 @@
1
+ import { useState } from 'react';
2
+ import { Button } from '../ui';
3
+ import { FreightSelect } from '../freight-cost/FreightFields';
4
+ import { ReusePager, ReuseState, reuseCard } from '../ReuseControls';
5
+ import { useConditionQuery } from '../../hooks/useContainerCondition';
6
+ import {
7
+ InvoiceReviewQueueDocument,
8
+ InvoiceReviewSummaryDocument,
9
+ type InvoiceReviewFragment,
10
+ type InvoiceReviewQueueQuery,
11
+ type InvoiceReviewSummaryQuery,
12
+ type MtwFreightInvoiceReviewStatus,
13
+ } from '../../../generated/freight-invoice-operations';
14
+ import { invoiceMoney } from '../../utils/invoiceComparison';
15
+ import { REVIEW_QUEUE_STATUSES, REVIEW_STATUS_LABELS } from '../../utils/invoiceReview';
16
+
17
+ const PAGE = 10;
18
+
19
+ /** The latest revision of every recorded carrier invoice, filtered by review status (BOFF-10588). */
20
+ export function InvoiceReviewQueue({
21
+ revision,
22
+ onOpen,
23
+ locked = false,
24
+ }: {
25
+ revision: number;
26
+ onOpen: (review: InvoiceReviewFragment) => void;
27
+ /** True while an invoice or correction draft is open: opening a review would discard it. */
28
+ locked?: boolean;
29
+ }) {
30
+ const [status, setStatus] = useState<MtwFreightInvoiceReviewStatus | ''>('AWAITING_REVIEW');
31
+ const [offset, setOffset] = useState(0);
32
+ const queue = useConditionQuery<InvoiceReviewQueueQuery>(
33
+ InvoiceReviewQueueDocument,
34
+ { status: status || null, pagination: { limit: PAGE, offset } },
35
+ revision
36
+ );
37
+ const page = queue.data?.mtwFreightInvoiceReviewPage;
38
+ return (
39
+ <section className={reuseCard} aria-label="Invoice review queue">
40
+ <h2 className="text-lg font-semibold">Invoice review queue</h2>
41
+ <p>The latest revision of each recorded carrier invoice. Open one to review it.</p>
42
+ {locked && (
43
+ <p role="status">Finish or cancel the invoice draft below before opening a review.</p>
44
+ )}
45
+ <FreightSelect
46
+ label="Review status"
47
+ value={status}
48
+ onChange={(value) => {
49
+ setStatus(value as MtwFreightInvoiceReviewStatus | '');
50
+ setOffset(0);
51
+ }}
52
+ >
53
+ <option value="">All statuses</option>
54
+ {REVIEW_QUEUE_STATUSES.map((item) => (
55
+ <option key={item} value={item}>
56
+ {REVIEW_STATUS_LABELS[item]}
57
+ </option>
58
+ ))}
59
+ </FreightSelect>
60
+ <ReuseState loading={queue.loading} error={queue.error} />
61
+ {page && page.items.length === 0 && <p>No invoices with this review status.</p>}
62
+ <ul className="space-y-2">
63
+ {page?.items.map((item) => (
64
+ <li key={item.comparisonId}>
65
+ <Button
66
+ type="button"
67
+ variant="outline"
68
+ className="h-auto w-full max-w-full justify-start whitespace-normal break-words text-left"
69
+ disabled={locked}
70
+ onClick={() => onOpen(item)}
71
+ >
72
+ {item.supplierLabel ?? item.supplierReference} / invoice {item.invoiceReference},
73
+ revision {item.revision} — {REVIEW_STATUS_LABELS[item.status]} —{' '}
74
+ {invoiceMoney(item.knownInvoiceAmountMinor, item.currency)}
75
+ {item.invoiceAmountComplete ? '' : ' known'}
76
+ </Button>
77
+ </li>
78
+ ))}
79
+ </ul>
80
+ <ReusePager page={page} busy={queue.loading} offset={offset} onChange={setOffset} />
81
+ </section>
82
+ );
83
+ }
84
+
85
+ /** Review counts over the latest revision of every recorded carrier invoice. */
86
+ export function useInvoiceReviewSummary(enabled: boolean) {
87
+ const summary = useConditionQuery<InvoiceReviewSummaryQuery>(
88
+ InvoiceReviewSummaryDocument,
89
+ enabled ? {} : null
90
+ );
91
+ return {
92
+ summary: summary.data?.mtwFreightInvoiceReviewSummary ?? null,
93
+ loading: summary.loading,
94
+ error: summary.error,
95
+ };
96
+ }
@@ -0,0 +1,25 @@
1
+ import { usePermissions } from '@burdenoff/fe-libs/shared/providers/shell';
2
+ import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
3
+ import type { ReviewPermissions } from '../utils/invoiceReview';
4
+
5
+ /** Freight invoice review grants for this page's current actor and workspace (BOFF-10588). */
6
+ export function useInvoiceReviewPermissions(): ReviewPermissions & { loading: boolean } {
7
+ const permissions = usePermissions();
8
+ const { workspaceId, currentUser } = useMoveTheWheels();
9
+ const currentScope = Boolean(
10
+ workspaceId &&
11
+ currentUser?.id &&
12
+ permissions.scopeId === workspaceId &&
13
+ (permissions.isSuperAdmin ||
14
+ (permissions.permissions?.scopeId === workspaceId &&
15
+ permissions.permissions.actorId === currentUser.id))
16
+ );
17
+ const ready = currentScope && !permissions.isLoading && !permissions.error;
18
+ const canRead = ready && permissions.hasPermission('mtw-freight-invoice:read');
19
+ return {
20
+ canRead,
21
+ canUpdate: canRead && permissions.hasPermission('mtw-freight-invoice:update'),
22
+ canApprove: canRead && permissions.hasPermission('mtw-freight-invoice:approve'),
23
+ loading: permissions.isLoading,
24
+ };
25
+ }
@@ -7,7 +7,9 @@
7
7
  * settlement are handled by the platform Billing module, which this page
8
8
  * deep-links to rather than re-implementing an Invoice ledger here. Carrier
9
9
  * invoices are checked against the agreed freight estimate on the freight
10
- * invoice comparison page, which this page links to (BOFF-4395).
10
+ * invoice comparison page, which this page links to (BOFF-4395). The review
11
+ * counts (awaiting review, disputed, accepted, handed to Billing) come from
12
+ * Query.mtwFreightInvoiceReviewSummary over real recorded invoices (BOFF-10588).
11
13
  */
12
14
 
13
15
  import { useMemo } from 'react';
@@ -30,6 +32,34 @@ import { useAnalytics, useFinanceMetrics } from '../hooks/useAnalytics';
30
32
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
31
33
  import { formatCurrency } from '../utils/formatters';
32
34
  import { joinPath } from '../utils/navigation';
35
+ import { useInvoiceReviewSummary } from '../components/freight-invoice/InvoiceReviewQueue';
36
+
37
+ function InvoiceReviewCounts() {
38
+ const { summary, loading, error } = useInvoiceReviewSummary(true);
39
+ if (loading) return <p className="text-sm text-text-secondary">Loading invoice review counts…</p>;
40
+ if (error || !summary)
41
+ return (
42
+ <p className="text-sm text-text-secondary">
43
+ Invoice review counts need freight invoice read permission.
44
+ </p>
45
+ );
46
+ const counts = [
47
+ { label: 'Awaiting review', value: summary.awaitingReview },
48
+ { label: 'Disputed', value: summary.disputed + summary.supplierResponded },
49
+ { label: 'Accepted', value: summary.accepted + summary.acceptedWithVariance },
50
+ { label: 'Handed to Billing', value: summary.settlementHandedOff },
51
+ ];
52
+ return (
53
+ <dl className="grid grid-cols-2 gap-2" aria-label="Carrier invoice review">
54
+ {counts.map((count) => (
55
+ <div key={count.label} className="rounded-lg border border-border-default p-3">
56
+ <dt className="text-xs text-text-secondary">{count.label}</dt>
57
+ <dd className="text-lg font-semibold text-text-primary">{count.value}</dd>
58
+ </div>
59
+ ))}
60
+ </dl>
61
+ );
62
+ }
33
63
 
34
64
  export default function FinancePage() {
35
65
  const { analytics } = useAnalytics();
@@ -156,8 +186,10 @@ export default function FinancePage() {
156
186
  <h3 className="text-sm font-semibold text-text-primary">Freight cost control</h3>
157
187
  <p className="text-sm text-text-secondary">
158
188
  Record the agreed freight estimate for a shipment, then check the carrier&apos;s
159
- invoice against it line by line before it is settled in Billing.
189
+ invoice against it line by line. Someone other than its author accepts or disputes it,
190
+ and accepted invoices are handed to Billing for settlement.
160
191
  </p>
192
+ <InvoiceReviewCounts />
161
193
  <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
162
194
  <Button variant="outline" onClick={openFreightEstimates}>
163
195
  <Receipt size={16} /> Freight estimates
@@ -8,6 +8,9 @@ import { PinnedSelect, ReusePager, ReuseState, reuseCard } from '../components/R
8
8
  import { FreightText } from '../components/freight-cost/FreightFields';
9
9
  import { InvoiceForm } from '../components/freight-invoice/InvoiceForm';
10
10
  import { InvoiceHistoryRecord } from '../components/freight-invoice/InvoiceResults';
11
+ import { InvoiceReviewPanel } from '../components/freight-invoice/InvoiceReviewPanel';
12
+ import { InvoiceReviewQueue } from '../components/freight-invoice/InvoiceReviewQueue';
13
+ import { useInvoiceReviewPermissions } from '../hooks/useInvoiceReviewPermissions';
11
14
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
12
15
  import { useShipmentPermissions } from '../hooks/useShipmentPermissions';
13
16
  import { useConditionApi, useConditionQuery } from '../hooks/useContainerCondition';
@@ -29,6 +32,7 @@ import {
29
32
  type InvoiceComparisonExactQuery,
30
33
  type InvoiceReceiptFragment,
31
34
  type InvoiceStructuralPreviewQuery,
35
+ type InvoiceReviewFragment,
32
36
  } from '../../generated/freight-invoice-operations';
33
37
  import {
34
38
  sameInvoiceScope,
@@ -83,6 +87,8 @@ function InvoiceWorkbench({
83
87
  actions: ReturnType<typeof useInvoiceComparisonRecovery>;
84
88
  }) {
85
89
  const request = useConditionApi(),
90
+ reviewAccess = useInvoiceReviewPermissions(),
91
+ [reviewRevision, setReviewRevision] = useState(0),
86
92
  [params, setParams] = useSearchParams();
87
93
  const [shipment, setShipment] = useState(
88
94
  () => params.get('invoiceShipment')?.slice(0, 255) ?? ''
@@ -155,6 +161,27 @@ function InvoiceWorkbench({
155
161
  { replace: true }
156
162
  );
157
163
  };
164
+ const openReview = (row: InvoiceReviewFragment) => {
165
+ setShipment(row.shipmentId);
166
+ setEstimateId('');
167
+ setEstimateOffset(0);
168
+ setKnown(null);
169
+ setSelectedId(row.comparisonId);
170
+ setIdentity(row.invoiceIdentityId);
171
+ setIdentityEntry(row.invoiceIdentityId);
172
+ setHistoryOffset(0);
173
+ setStarted(false);
174
+ setCorrection(null);
175
+ setParams(
176
+ (previous) => {
177
+ const next = new URLSearchParams(previous);
178
+ next.set('invoiceShipment', row.shipmentId);
179
+ next.set('invoiceIdentity', row.invoiceIdentityId);
180
+ return next;
181
+ },
182
+ { replace: true }
183
+ );
184
+ };
158
185
  const options =
159
186
  shipments.data?.shipments.map((s) => ({
160
187
  id: s.id,
@@ -175,9 +202,10 @@ function InvoiceWorkbench({
175
202
  return (
176
203
  <div className="min-w-0 space-y-6 break-words">
177
204
  <p className="rounded border p-4 text-sm">
178
- Record only what the supplier source reports. Unknown is not zero. This comparison
179
- establishes no quote acceptance, liability, invoice approval or payment. Corrections append
180
- a new immutable revision.
205
+ Record only what the supplier source reports. Unknown is not zero. Recording a comparison
206
+ does not approve it: someone other than its author accepts or disputes each revision, and
207
+ only an accepted invoice is handed to Billing for settlement. Corrections append a new
208
+ immutable revision.
181
209
  </p>
182
210
  {actions.uncertain && (
183
211
  <InvoiceRequestRecovery
@@ -197,6 +225,13 @@ function InvoiceWorkbench({
197
225
  )}
198
226
  {enabled && (
199
227
  <>
228
+ {reviewAccess.canRead && (
229
+ <InvoiceReviewQueue
230
+ revision={reviewRevision + actions.revision}
231
+ onOpen={openReview}
232
+ locked={paused || started}
233
+ />
234
+ )}
200
235
  <section className={reuseCard}>
201
236
  <h2 className="text-lg font-semibold">Choose the exact recorded estimate</h2>
202
237
  <PinnedSelect
@@ -401,6 +436,12 @@ function InvoiceWorkbench({
401
436
  {record && record.shipmentId === shipment && (
402
437
  <>
403
438
  <InvoiceHistoryRecord record={record} />
439
+ <InvoiceReviewPanel
440
+ key={`${record.id}:${actions.revision}`}
441
+ record={record}
442
+ permissions={reviewAccess}
443
+ onChanged={() => setReviewRevision((n) => n + 1)}
444
+ />
404
445
  <Button
405
446
  type="button"
406
447
  disabled={