@burdenoff/microfe-movethewheels 2026.1101.4 → 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 (47) hide show
  1. package/dist/components/CustomerDeliveryUpdatesFields.js +57 -0
  2. package/dist/components/CustomerNotificationSettingsCard.js +368 -0
  3. package/dist/components/freight-cost/FreightCommercialFactEditor.js +46 -46
  4. package/dist/components/freight-cost/FreightCommercialHistory.js +7 -7
  5. package/dist/components/freight-cost/FreightDraftForm.js +20 -20
  6. package/dist/components/freight-invoice/InvoiceReviewPanel.js +255 -0
  7. package/dist/components/freight-invoice/InvoiceReviewQueue.js +93 -0
  8. package/dist/generated/freight-invoice-operations.d.ts +461 -0
  9. package/dist/generated/tracking-quality-operations.d.ts +275 -0
  10. package/dist/hooks/useCustomers.js +9 -3
  11. package/dist/hooks/useInvoiceComparisonRecovery.js +26 -26
  12. package/dist/hooks/useInvoiceReviewPermissions.js +14 -0
  13. package/dist/movethewheels/components/CustomerDeliveryUpdatesFields.d.ts +12 -0
  14. package/dist/movethewheels/components/CustomerNotificationSettingsCard.d.ts +3 -0
  15. package/dist/movethewheels/components/freight-invoice/InvoiceReviewPanel.d.ts +11 -0
  16. package/dist/movethewheels/components/freight-invoice/InvoiceReviewQueue.d.ts +22 -0
  17. package/dist/movethewheels/hooks/useInvoiceReviewPermissions.d.ts +5 -0
  18. package/dist/movethewheels/pages/FinancePage.d.ts +3 -1
  19. package/dist/movethewheels/types/index.d.ts +4 -0
  20. package/dist/movethewheels/utils/customerNotifications.d.ts +66 -0
  21. package/dist/movethewheels/utils/invoiceReview.d.ts +26 -0
  22. package/dist/node_modules/lucide-react/dist/esm/icons/bell-ring.js +22 -0
  23. package/dist/pages/CustomerDetailsPage.js +279 -231
  24. package/dist/pages/FinancePage.js +109 -67
  25. package/dist/pages/FreightEstimatePage.js +89 -89
  26. package/dist/pages/FreightInvoicePage.js +138 -120
  27. package/dist/pages/SettingsPage.js +242 -238
  28. package/dist/src/generated/freight-invoice-operations.js +2100 -1
  29. package/dist/utils/customerNotifications.js +83 -0
  30. package/dist/utils/invoiceReview.js +84 -0
  31. package/package.json +4 -3
  32. package/src/generated/freight-invoice-operations.ts +245 -1
  33. package/src/generated/tracking-quality-operations.ts +304 -0
  34. package/src/movethewheels/components/CustomerDeliveryUpdatesFields.tsx +67 -0
  35. package/src/movethewheels/components/CustomerNotificationSettingsCard.tsx +465 -0
  36. package/src/movethewheels/components/freight-invoice/InvoiceReviewPanel.tsx +319 -0
  37. package/src/movethewheels/components/freight-invoice/InvoiceReviewQueue.tsx +96 -0
  38. package/src/movethewheels/hooks/useCustomers.ts +17 -0
  39. package/src/movethewheels/hooks/useInvoiceReviewPermissions.ts +25 -0
  40. package/src/movethewheels/pages/CustomerDetailsPage.tsx +39 -0
  41. package/src/movethewheels/pages/FinancePage.tsx +34 -2
  42. package/src/movethewheels/pages/FreightInvoicePage.tsx +44 -3
  43. package/src/movethewheels/pages/SettingsPage.tsx +221 -208
  44. package/src/movethewheels/types/index.ts +4 -0
  45. package/src/movethewheels/utils/customerNotifications.ts +209 -0
  46. package/src/movethewheels/utils/invoiceReview.ts +146 -0
  47. 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
+ }
@@ -35,6 +35,8 @@ interface ApiCustomer {
35
35
  businessLicense?: string | null;
36
36
  taxId?: string | null;
37
37
  rating?: number | null;
38
+ notifyByEmail?: boolean | null;
39
+ notifyBySms?: boolean | null;
38
40
  createdAt: string;
39
41
  updatedAt: string;
40
42
  deletedAt?: string | null;
@@ -53,6 +55,8 @@ const LIST_CUSTOMERS_QUERY = `
53
55
  businessLicense
54
56
  taxId
55
57
  rating
58
+ notifyByEmail
59
+ notifyBySms
56
60
  createdAt
57
61
  updatedAt
58
62
  deletedAt
@@ -74,6 +78,8 @@ const GET_CUSTOMER_QUERY = `
74
78
  businessLicense
75
79
  taxId
76
80
  rating
81
+ notifyByEmail
82
+ notifyBySms
77
83
  createdAt
78
84
  updatedAt
79
85
  deletedAt
@@ -94,6 +100,8 @@ const CREATE_CUSTOMER_MUTATION = `
94
100
  businessLicense
95
101
  taxId
96
102
  rating
103
+ notifyByEmail
104
+ notifyBySms
97
105
  createdAt
98
106
  updatedAt
99
107
  deletedAt
@@ -114,6 +122,8 @@ const UPDATE_CUSTOMER_MUTATION = `
114
122
  businessLicense
115
123
  taxId
116
124
  rating
125
+ notifyByEmail
126
+ notifyBySms
117
127
  createdAt
118
128
  updatedAt
119
129
  deletedAt
@@ -144,6 +154,9 @@ function mapCustomer(customer: ApiCustomer): Customer {
144
154
  businessLicense: customer.businessLicense ?? undefined,
145
155
  taxId: customer.taxId ?? undefined,
146
156
  rating: customer.rating ?? undefined,
157
+ // Absent only from an older service; match its defaults (email on, SMS off).
158
+ notifyByEmail: customer.notifyByEmail ?? true,
159
+ notifyBySms: customer.notifyBySms ?? false,
147
160
  createdAt: new Date(customer.createdAt),
148
161
  updatedAt: new Date(customer.updatedAt),
149
162
  };
@@ -263,6 +276,8 @@ export function useCustomers(options: UseCustomersOptions = {}): UseCustomersRes
263
276
  businessLicense: customerData.businessLicense,
264
277
  taxId: customerData.taxId,
265
278
  rating: customerData.rating,
279
+ notifyByEmail: customerData.notifyByEmail,
280
+ notifyBySms: customerData.notifyBySms,
266
281
  },
267
282
  });
268
283
 
@@ -289,6 +304,8 @@ export function useCustomers(options: UseCustomersOptions = {}): UseCustomersRes
289
304
  businessLicense: updates.businessLicense,
290
305
  taxId: updates.taxId,
291
306
  rating: updates.rating,
307
+ notifyByEmail: updates.notifyByEmail,
308
+ notifyBySms: updates.notifyBySms,
292
309
  },
293
310
  });
294
311
 
@@ -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
+ }
@@ -18,6 +18,7 @@ import {
18
18
  Package,
19
19
  X,
20
20
  Trash2,
21
+ BellRing,
21
22
  } from 'lucide-react';
22
23
  import { PageLayout, LoadingState, ErrorState } from '../components/PageLayout';
23
24
  import { TypeBadge, StatusBadge } from '../components/StatusBadge';
@@ -51,6 +52,10 @@ import { useOrders } from '../hooks/useOrders';
51
52
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
52
53
  import { formatAddress, formatCurrency, formatDate } from '../utils/formatters';
53
54
  import { joinPath, buildOrderCustomerPath, getSafeCustomerReturnPath } from '../utils/navigation';
55
+ import {
56
+ CustomerDeliveryUpdatesFields,
57
+ deliveryUpdatesSummary,
58
+ } from '../components/CustomerDeliveryUpdatesFields';
54
59
  import { FormError } from '../components/FormError';
55
60
  import { Notice, useNotice } from '../components/Notice';
56
61
 
@@ -68,6 +73,8 @@ interface CustomerFormState {
68
73
  businessLicense: string;
69
74
  taxId: string;
70
75
  rating: string;
76
+ notifyByEmail: boolean;
77
+ notifyBySms: boolean;
71
78
  }
72
79
 
73
80
  const emptyCustomerForm: CustomerFormState = {
@@ -84,6 +91,8 @@ const emptyCustomerForm: CustomerFormState = {
84
91
  businessLicense: '',
85
92
  taxId: '',
86
93
  rating: '',
94
+ notifyByEmail: true,
95
+ notifyBySms: false,
87
96
  };
88
97
 
89
98
  function NewCustomerForm() {
@@ -125,6 +134,8 @@ function NewCustomerForm() {
125
134
  businessLicense: form.businessLicense.trim() || undefined,
126
135
  taxId: form.taxId.trim() || undefined,
127
136
  rating: form.rating ? Number(form.rating) : undefined,
137
+ notifyByEmail: form.notifyByEmail,
138
+ notifyBySms: form.notifyBySms,
128
139
  });
129
140
  const destination = returnTo
130
141
  ? buildOrderCustomerPath(returnTo, created.id)
@@ -320,6 +331,13 @@ function NewCustomerForm() {
320
331
  />
321
332
  </div>
322
333
  </div>
334
+ <div className="mt-6">
335
+ <CustomerDeliveryUpdatesFields
336
+ idPrefix="new-customer"
337
+ value={{ notifyByEmail: form.notifyByEmail, notifyBySms: form.notifyBySms }}
338
+ onChange={(next) => setForm((prev) => ({ ...prev, ...next }))}
339
+ />
340
+ </div>
323
341
  </CardContent>
324
342
  </Card>
325
343
  </form>
@@ -343,6 +361,8 @@ function customerToForm(customer: {
343
361
  businessLicense?: string;
344
362
  taxId?: string;
345
363
  rating?: number;
364
+ notifyByEmail: boolean;
365
+ notifyBySms: boolean;
346
366
  }): CustomerFormState {
347
367
  return {
348
368
  name: customer.name,
@@ -358,6 +378,8 @@ function customerToForm(customer: {
358
378
  businessLicense: customer.businessLicense ?? '',
359
379
  taxId: customer.taxId ?? '',
360
380
  rating: customer.rating ? String(customer.rating) : '',
381
+ notifyByEmail: customer.notifyByEmail,
382
+ notifyBySms: customer.notifyBySms,
361
383
  };
362
384
  }
363
385
 
@@ -424,6 +446,8 @@ export default function CustomerDetailsPage() {
424
446
  businessLicense: form.businessLicense.trim() || undefined,
425
447
  taxId: form.taxId.trim() || undefined,
426
448
  rating: form.rating ? Number(form.rating) : undefined,
449
+ notifyByEmail: form.notifyByEmail,
450
+ notifyBySms: form.notifyBySms,
427
451
  });
428
452
  notify('Customer updated.');
429
453
  setEditing(false);
@@ -662,6 +686,12 @@ export default function CustomerDetailsPage() {
662
686
  />
663
687
  </div>
664
688
  </div>
689
+ <CustomerDeliveryUpdatesFields
690
+ idPrefix="edit-customer"
691
+ value={{ notifyByEmail: form.notifyByEmail, notifyBySms: form.notifyBySms }}
692
+ onChange={(next) => setForm((prev) => ({ ...prev, ...next }))}
693
+ disabled={saving}
694
+ />
665
695
  </div>
666
696
  ) : (
667
697
  <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -691,6 +721,15 @@ export default function CustomerDetailsPage() {
691
721
  </div>
692
722
  </div>
693
723
  )}
724
+ <div className="flex items-center gap-3">
725
+ <BellRing size={18} className="text-text-secondary" />
726
+ <div>
727
+ <p className="text-sm text-text-secondary">Delivery updates</p>
728
+ <p className="font-medium" data-testid="customer-delivery-updates">
729
+ {deliveryUpdatesSummary(customer)}
730
+ </p>
731
+ </div>
732
+ </div>
694
733
  </div>
695
734
  </div>
696
735
  )}
@@ -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