@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.
- package/dist/components/freight-cost/FreightCommercialFactEditor.js +46 -46
- package/dist/components/freight-cost/FreightCommercialHistory.js +7 -7
- package/dist/components/freight-cost/FreightDraftForm.js +20 -20
- package/dist/components/freight-invoice/InvoiceReviewPanel.js +255 -0
- package/dist/components/freight-invoice/InvoiceReviewQueue.js +93 -0
- package/dist/generated/freight-invoice-operations.d.ts +461 -0
- package/dist/hooks/useInvoiceComparisonRecovery.js +26 -26
- package/dist/hooks/useInvoiceReviewPermissions.js +14 -0
- package/dist/movethewheels/components/freight-invoice/InvoiceReviewPanel.d.ts +11 -0
- package/dist/movethewheels/components/freight-invoice/InvoiceReviewQueue.d.ts +22 -0
- package/dist/movethewheels/hooks/useInvoiceReviewPermissions.d.ts +5 -0
- package/dist/movethewheels/pages/FinancePage.d.ts +3 -1
- package/dist/movethewheels/utils/invoiceReview.d.ts +26 -0
- package/dist/pages/FinancePage.js +109 -67
- package/dist/pages/FreightEstimatePage.js +89 -89
- package/dist/pages/FreightInvoicePage.js +138 -120
- package/dist/src/generated/freight-invoice-operations.js +2100 -1
- package/dist/utils/invoiceReview.js +84 -0
- package/package.json +2 -2
- package/src/generated/freight-invoice-operations.ts +245 -1
- package/src/movethewheels/components/freight-invoice/InvoiceReviewPanel.tsx +319 -0
- package/src/movethewheels/components/freight-invoice/InvoiceReviewQueue.tsx +96 -0
- package/src/movethewheels/hooks/useInvoiceReviewPermissions.ts +25 -0
- package/src/movethewheels/pages/FinancePage.tsx +34 -2
- package/src/movethewheels/pages/FreightInvoicePage.tsx +44 -3
- package/src/movethewheels/utils/invoiceReview.ts +146 -0
- 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's
|
|
159
|
-
invoice against it line by line
|
|
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.
|
|
179
|
-
|
|
180
|
-
|
|
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={
|