@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
@@ -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={
@@ -6,8 +6,12 @@
6
6
  * the priority and delivery window new orders start from. Saving applies them
7
7
  * to every MoveTheWheels screen straight away.
8
8
  *
9
- * Company profile, notifications, two-factor authentication and integrations
10
- * are platform settings; this page links to them instead of duplicating them.
9
+ * It also holds the customer delivery notification settings (BOFF-4402): whether
10
+ * and how the workspace tells its customers about their shipments.
11
+ *
12
+ * Company profile, your own notification preferences, two-factor
13
+ * authentication and integrations are platform settings; this page links to
14
+ * them instead of duplicating them.
11
15
  */
12
16
  import { useEffect, useMemo, useState, type ChangeEvent, type FormEvent } from 'react';
13
17
  import {
@@ -23,6 +27,7 @@ import {
23
27
  } from 'lucide-react';
24
28
  import { EmphasisPanel, PagePurpose } from '@burdenoff/fe-libs/ui';
25
29
  import { PermissionProvider, usePermissions } from '@burdenoff/fe-libs/shared/providers/shell';
30
+ import { CustomerNotificationSettingsCard } from '../components/CustomerNotificationSettingsCard';
26
31
  import { PageLayout } from '../components/PageLayout';
27
32
  import { FormError } from '../components/FormError';
28
33
  import { Notice, useNotice } from '../components/Notice';
@@ -204,7 +209,7 @@ function SettingsWorkbench() {
204
209
  return (
205
210
  <PageLayout
206
211
  title="Settings"
207
- subtitle="Workspace defaults for currency, units, time zone and new orders"
212
+ subtitle="Workspace defaults, new-order defaults and customer delivery notifications"
208
213
  icon={<Settings size={24} />}
209
214
  maxWidth="xl"
210
215
  >
@@ -222,223 +227,231 @@ function SettingsWorkbench() {
222
227
  <Notice message={notice} />
223
228
 
224
229
  <div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
225
- <EmphasisPanel className="space-y-6 xl:col-span-2">
226
- <div className="flex flex-wrap items-center justify-between gap-3">
227
- <h2 className="text-base font-semibold text-text-primary">Operational defaults</h2>
228
- {isLoaded && (
229
- <Badge variant={settings.isCustomized ? 'default' : 'outline'}>
230
- {settings.isCustomized ? 'Customized for this workspace' : 'Platform defaults'}
231
- </Badge>
232
- )}
233
- </div>
234
-
235
- {!isLoaded ? (
236
- <div className="space-y-4" aria-busy="true">
237
- <Skeleton className="h-10 w-full" />
238
- <Skeleton className="h-10 w-full" />
239
- <Skeleton className="h-10 w-full" />
240
- </div>
241
- ) : (
242
- <form
243
- className="space-y-6"
244
- onSubmit={(event) => void onSubmit(event)}
245
- aria-label="MoveTheWheels settings"
246
- >
247
- {readOnly && (
248
- <p className="rounded-lg border border-border-default bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
249
- You can view these settings. Ask a workspace admin or the Logistics Director to
250
- change them.
251
- </p>
230
+ <div className="min-w-0 space-y-6 xl:col-span-2">
231
+ <EmphasisPanel className="space-y-6">
232
+ <div className="flex flex-wrap items-center justify-between gap-3">
233
+ <h2 className="text-base font-semibold text-text-primary">Operational defaults</h2>
234
+ {isLoaded && (
235
+ <Badge variant={settings.isCustomized ? 'default' : 'outline'}>
236
+ {settings.isCustomized ? 'Customized for this workspace' : 'Platform defaults'}
237
+ </Badge>
252
238
  )}
239
+ </div>
253
240
 
254
- <fieldset className="space-y-4" disabled={readOnly || isSaving}>
255
- <legend className="text-sm font-semibold text-text-primary">Display</legend>
256
- <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
257
- <div className="min-w-0 space-y-2">
258
- <Label htmlFor="mtw-settings-currency">Default currency</Label>
259
- <Select
260
- value={draft.defaultCurrency}
261
- onValueChange={(value: string) => update('defaultCurrency', value)}
262
- disabled={readOnly || isSaving}
263
- >
264
- <SelectTrigger id="mtw-settings-currency" aria-label="Default currency">
265
- <SelectValue />
266
- </SelectTrigger>
267
- <SelectContent className="max-h-72">
268
- {currencies.map((code) => (
269
- <SelectItem key={code} value={code}>
270
- {currencyLabel(code)}
271
- </SelectItem>
272
- ))}
273
- </SelectContent>
274
- </Select>
275
- <p className="text-xs text-text-secondary">
276
- Used for order totals, revenue and costs that have no currency of their own.
277
- </p>
278
- </div>
241
+ {!isLoaded ? (
242
+ <div className="space-y-4" aria-busy="true">
243
+ <Skeleton className="h-10 w-full" />
244
+ <Skeleton className="h-10 w-full" />
245
+ <Skeleton className="h-10 w-full" />
246
+ </div>
247
+ ) : (
248
+ <form
249
+ className="space-y-6"
250
+ onSubmit={(event) => void onSubmit(event)}
251
+ aria-label="MoveTheWheels settings"
252
+ >
253
+ {readOnly && (
254
+ <p className="rounded-lg border border-border-default bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
255
+ You can view these settings. Ask a workspace admin or the Logistics Director to
256
+ change them.
257
+ </p>
258
+ )}
279
259
 
280
- <div className="min-w-0 space-y-2">
281
- <Label htmlFor="mtw-settings-timezone">Time zone</Label>
282
- <Select
283
- value={draft.timezone}
284
- onValueChange={(value: string) => update('timezone', value)}
285
- disabled={readOnly || isSaving}
286
- >
287
- <SelectTrigger id="mtw-settings-timezone" aria-label="Time zone">
288
- <SelectValue />
289
- </SelectTrigger>
290
- <SelectContent className="max-h-72">
291
- <SelectItem value={LOCAL_TIME}>
292
- {`Each viewer's local time (yours: ${localZone})`}
293
- </SelectItem>
294
- {zones.map((name) => (
295
- <SelectItem key={name} value={name}>
296
- {name.replace(/_/g, ' ')}
297
- </SelectItem>
298
- ))}
299
- </SelectContent>
300
- </Select>
301
- <p className="text-xs text-text-secondary">
302
- Dates and times on every MoveTheWheels screen are shown in this zone.
303
- </p>
304
- </div>
260
+ <fieldset className="space-y-4" disabled={readOnly || isSaving}>
261
+ <legend className="text-sm font-semibold text-text-primary">Display</legend>
262
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
263
+ <div className="min-w-0 space-y-2">
264
+ <Label htmlFor="mtw-settings-currency">Default currency</Label>
265
+ <Select
266
+ value={draft.defaultCurrency}
267
+ onValueChange={(value: string) => update('defaultCurrency', value)}
268
+ disabled={readOnly || isSaving}
269
+ >
270
+ <SelectTrigger id="mtw-settings-currency" aria-label="Default currency">
271
+ <SelectValue />
272
+ </SelectTrigger>
273
+ <SelectContent className="max-h-72">
274
+ {currencies.map((code) => (
275
+ <SelectItem key={code} value={code}>
276
+ {currencyLabel(code)}
277
+ </SelectItem>
278
+ ))}
279
+ </SelectContent>
280
+ </Select>
281
+ <p className="text-xs text-text-secondary">
282
+ Used for order totals, revenue and costs that have no currency of their own.
283
+ </p>
284
+ </div>
305
285
 
306
- <div className="min-w-0 space-y-2">
307
- <Label htmlFor="mtw-settings-distance">Distance unit</Label>
308
- <Select
309
- value={draft.distanceUnit}
310
- onValueChange={(value: string) => {
311
- const unit = DISTANCE_UNIT_OPTIONS.find((option) => option.value === value);
312
- if (unit) update('distanceUnit', unit.value);
313
- }}
314
- disabled={readOnly || isSaving}
315
- >
316
- <SelectTrigger id="mtw-settings-distance" aria-label="Distance unit">
317
- <SelectValue />
318
- </SelectTrigger>
319
- <SelectContent>
320
- {DISTANCE_UNIT_OPTIONS.map((option) => (
321
- <SelectItem key={option.value} value={option.value}>
322
- {option.label}
286
+ <div className="min-w-0 space-y-2">
287
+ <Label htmlFor="mtw-settings-timezone">Time zone</Label>
288
+ <Select
289
+ value={draft.timezone}
290
+ onValueChange={(value: string) => update('timezone', value)}
291
+ disabled={readOnly || isSaving}
292
+ >
293
+ <SelectTrigger id="mtw-settings-timezone" aria-label="Time zone">
294
+ <SelectValue />
295
+ </SelectTrigger>
296
+ <SelectContent className="max-h-72">
297
+ <SelectItem value={LOCAL_TIME}>
298
+ {`Each viewer's local time (yours: ${localZone})`}
323
299
  </SelectItem>
324
- ))}
325
- </SelectContent>
326
- </Select>
327
- </div>
300
+ {zones.map((name) => (
301
+ <SelectItem key={name} value={name}>
302
+ {name.replace(/_/g, ' ')}
303
+ </SelectItem>
304
+ ))}
305
+ </SelectContent>
306
+ </Select>
307
+ <p className="text-xs text-text-secondary">
308
+ Dates and times on every MoveTheWheels screen are shown in this zone.
309
+ </p>
310
+ </div>
328
311
 
329
- <div className="min-w-0 space-y-2">
330
- <Label htmlFor="mtw-settings-weight">Weight unit</Label>
331
- <Select
332
- value={draft.weightUnit}
333
- onValueChange={(value: string) => {
334
- const unit = WEIGHT_UNIT_OPTIONS.find((option) => option.value === value);
335
- if (unit) update('weightUnit', unit.value);
336
- }}
337
- disabled={readOnly || isSaving}
338
- >
339
- <SelectTrigger id="mtw-settings-weight" aria-label="Weight unit">
340
- <SelectValue />
341
- </SelectTrigger>
342
- <SelectContent>
343
- {WEIGHT_UNIT_OPTIONS.map((option) => (
344
- <SelectItem key={option.value} value={option.value}>
345
- {option.label}
346
- </SelectItem>
347
- ))}
348
- </SelectContent>
349
- </Select>
350
- </div>
351
- </div>
352
- </fieldset>
312
+ <div className="min-w-0 space-y-2">
313
+ <Label htmlFor="mtw-settings-distance">Distance unit</Label>
314
+ <Select
315
+ value={draft.distanceUnit}
316
+ onValueChange={(value: string) => {
317
+ const unit = DISTANCE_UNIT_OPTIONS.find(
318
+ (option) => option.value === value
319
+ );
320
+ if (unit) update('distanceUnit', unit.value);
321
+ }}
322
+ disabled={readOnly || isSaving}
323
+ >
324
+ <SelectTrigger id="mtw-settings-distance" aria-label="Distance unit">
325
+ <SelectValue />
326
+ </SelectTrigger>
327
+ <SelectContent>
328
+ {DISTANCE_UNIT_OPTIONS.map((option) => (
329
+ <SelectItem key={option.value} value={option.value}>
330
+ {option.label}
331
+ </SelectItem>
332
+ ))}
333
+ </SelectContent>
334
+ </Select>
335
+ </div>
353
336
 
354
- <fieldset className="space-y-4" disabled={readOnly || isSaving}>
355
- <legend className="text-sm font-semibold text-text-primary">New orders</legend>
356
- <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
357
- <div className="min-w-0 space-y-2">
358
- <Label htmlFor="mtw-settings-priority">Default priority</Label>
359
- <Select
360
- value={draft.defaultOrderPriority}
361
- onValueChange={(value: string) => {
362
- const priority = ORDER_PRIORITY_OPTIONS.find(
363
- (option) => option.value === value
364
- );
365
- if (priority) update('defaultOrderPriority', priority.value);
366
- }}
367
- disabled={readOnly || isSaving}
368
- >
369
- <SelectTrigger id="mtw-settings-priority" aria-label="Default priority">
370
- <SelectValue />
371
- </SelectTrigger>
372
- <SelectContent>
373
- {ORDER_PRIORITY_OPTIONS.map((option) => (
374
- <SelectItem key={option.value} value={option.value}>
375
- {option.label}
376
- </SelectItem>
377
- ))}
378
- </SelectContent>
379
- </Select>
380
- <p className="text-xs text-text-secondary">
381
- New orders start with this priority.
382
- </p>
337
+ <div className="min-w-0 space-y-2">
338
+ <Label htmlFor="mtw-settings-weight">Weight unit</Label>
339
+ <Select
340
+ value={draft.weightUnit}
341
+ onValueChange={(value: string) => {
342
+ const unit = WEIGHT_UNIT_OPTIONS.find((option) => option.value === value);
343
+ if (unit) update('weightUnit', unit.value);
344
+ }}
345
+ disabled={readOnly || isSaving}
346
+ >
347
+ <SelectTrigger id="mtw-settings-weight" aria-label="Weight unit">
348
+ <SelectValue />
349
+ </SelectTrigger>
350
+ <SelectContent>
351
+ {WEIGHT_UNIT_OPTIONS.map((option) => (
352
+ <SelectItem key={option.value} value={option.value}>
353
+ {option.label}
354
+ </SelectItem>
355
+ ))}
356
+ </SelectContent>
357
+ </Select>
358
+ </div>
383
359
  </div>
360
+ </fieldset>
361
+
362
+ <fieldset className="space-y-4" disabled={readOnly || isSaving}>
363
+ <legend className="text-sm font-semibold text-text-primary">New orders</legend>
364
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
365
+ <div className="min-w-0 space-y-2">
366
+ <Label htmlFor="mtw-settings-priority">Default priority</Label>
367
+ <Select
368
+ value={draft.defaultOrderPriority}
369
+ onValueChange={(value: string) => {
370
+ const priority = ORDER_PRIORITY_OPTIONS.find(
371
+ (option) => option.value === value
372
+ );
373
+ if (priority) update('defaultOrderPriority', priority.value);
374
+ }}
375
+ disabled={readOnly || isSaving}
376
+ >
377
+ <SelectTrigger id="mtw-settings-priority" aria-label="Default priority">
378
+ <SelectValue />
379
+ </SelectTrigger>
380
+ <SelectContent>
381
+ {ORDER_PRIORITY_OPTIONS.map((option) => (
382
+ <SelectItem key={option.value} value={option.value}>
383
+ {option.label}
384
+ </SelectItem>
385
+ ))}
386
+ </SelectContent>
387
+ </Select>
388
+ <p className="text-xs text-text-secondary">
389
+ New orders start with this priority.
390
+ </p>
391
+ </div>
384
392
 
385
- <div className="min-w-0 space-y-2">
386
- <Label htmlFor="mtw-settings-window">Default delivery window (hours)</Label>
387
- <Input
388
- id="mtw-settings-window"
389
- type="number"
390
- inputMode="numeric"
391
- min={DELIVERY_WINDOW_MIN_HOURS}
392
- max={DELIVERY_WINDOW_MAX_HOURS}
393
- step={1}
394
- value={draft.defaultDeliveryWindowHours}
395
- aria-invalid={windowError ? true : undefined}
396
- aria-describedby="mtw-settings-window-help"
397
- onChange={(event: ChangeEvent<HTMLInputElement>) =>
398
- update('defaultDeliveryWindowHours', event.target.value)
399
- }
400
- />
401
- <p
402
- id="mtw-settings-window-help"
403
- className={
404
- windowError
405
- ? 'text-xs text-status-error-text'
406
- : 'text-xs text-text-secondary'
407
- }
408
- >
409
- {windowError ??
410
- 'The order form suggests a scheduled delivery this many hours ahead.'}
411
- </p>
393
+ <div className="min-w-0 space-y-2">
394
+ <Label htmlFor="mtw-settings-window">Default delivery window (hours)</Label>
395
+ <Input
396
+ id="mtw-settings-window"
397
+ type="number"
398
+ inputMode="numeric"
399
+ min={DELIVERY_WINDOW_MIN_HOURS}
400
+ max={DELIVERY_WINDOW_MAX_HOURS}
401
+ step={1}
402
+ value={draft.defaultDeliveryWindowHours}
403
+ aria-invalid={windowError ? true : undefined}
404
+ aria-describedby="mtw-settings-window-help"
405
+ onChange={(event: ChangeEvent<HTMLInputElement>) =>
406
+ update('defaultDeliveryWindowHours', event.target.value)
407
+ }
408
+ />
409
+ <p
410
+ id="mtw-settings-window-help"
411
+ className={
412
+ windowError
413
+ ? 'text-xs text-status-error-text'
414
+ : 'text-xs text-text-secondary'
415
+ }
416
+ >
417
+ {windowError ??
418
+ 'The order form suggests a scheduled delivery this many hours ahead.'}
419
+ </p>
420
+ </div>
412
421
  </div>
413
- </div>
414
- </fieldset>
422
+ </fieldset>
415
423
 
416
- <FormError message={formError} />
424
+ <FormError message={formError} />
417
425
 
418
- <div className="flex flex-wrap items-center justify-end gap-3">
419
- <Button
420
- type="button"
421
- variant="outline"
422
- disabled={!isDirty || isSaving}
423
- onClick={() => {
424
- setFormError(null);
425
- setDraft(toDraft(settings));
426
- }}
427
- >
428
- <RotateCcw size={16} className="mr-2" /> Discard changes
429
- </Button>
430
- <Button type="submit" disabled={!canSave}>
431
- {isSaving ? (
432
- <Loader2 size={16} className="mr-2 animate-spin" />
433
- ) : (
434
- <Save size={16} className="mr-2" />
435
- )}
436
- Save settings
437
- </Button>
438
- </div>
439
- </form>
440
- )}
441
- </EmphasisPanel>
426
+ <div className="flex flex-wrap items-center justify-end gap-3">
427
+ <Button
428
+ type="button"
429
+ variant="outline"
430
+ disabled={!isDirty || isSaving}
431
+ onClick={() => {
432
+ setFormError(null);
433
+ setDraft(toDraft(settings));
434
+ }}
435
+ >
436
+ <RotateCcw size={16} className="mr-2" /> Discard changes
437
+ </Button>
438
+ <Button type="submit" disabled={!canSave}>
439
+ {isSaving ? (
440
+ <Loader2 size={16} className="mr-2 animate-spin" />
441
+ ) : (
442
+ <Save size={16} className="mr-2" />
443
+ )}
444
+ Save settings
445
+ </Button>
446
+ </div>
447
+ </form>
448
+ )}
449
+ </EmphasisPanel>
450
+
451
+ {/* Keyed by workspace: a workspace switch remounts the card, so no
452
+ settings, draft or log from the previous workspace survive it. */}
453
+ <CustomerNotificationSettingsCard key={workspaceId ?? 'none'} readOnly={readOnly} />
454
+ </div>
442
455
 
443
456
  <div className="space-y-6">
444
457
  <Card>
@@ -180,6 +180,10 @@ export interface Customer {
180
180
  businessLicense?: string;
181
181
  taxId?: string;
182
182
  rating?: number;
183
+ /** Delivery notifications by email (BOFF-4402). */
184
+ notifyByEmail: boolean;
185
+ /** Delivery notifications by SMS; needs a phone number with its country code. */
186
+ notifyBySms: boolean;
183
187
  createdAt: Date;
184
188
  updatedAt: Date;
185
189
  }