@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,465 @@
1
+ /**
2
+ * Customer delivery notifications on the Settings page (BOFF-4402).
3
+ *
4
+ * Lets a workspace admin switch customer notifications on, pick email and/or
5
+ * SMS and the shipment events that notify, preview what customers receive,
6
+ * and see the most recent notifications with their delivery status. The
7
+ * service owns validation and delivery; this card only edits the settings.
8
+ *
9
+ * The parent keys this card by workspace, so a workspace switch remounts it.
10
+ * The form stays disabled until the saved settings have actually been read:
11
+ * the defaults are a placeholder, never something to edit and save over.
12
+ */
13
+ import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
14
+ import { BellRing, Loader2, RefreshCw, RotateCcw, Save } from 'lucide-react';
15
+ import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
16
+ import { executeGraphQL } from '../utils/graphqlClient';
17
+ import { formatDateTime } from '../utils/formatters';
18
+ import {
19
+ CUSTOMER_NOTIFICATION_EVENTS,
20
+ MTW_CUSTOMER_NOTIFICATIONS_QUERY,
21
+ MTW_CUSTOMER_NOTIFICATION_PREVIEWS_QUERY,
22
+ MTW_CUSTOMER_NOTIFICATION_SETTINGS_QUERY,
23
+ STATUS_LABELS,
24
+ UPDATE_MTW_CUSTOMER_NOTIFICATION_SETTINGS_MUTATION,
25
+ changedNotificationSettings,
26
+ defaultNotificationSettings,
27
+ eventLabel,
28
+ notificationDraftError,
29
+ toggleEvent,
30
+ type MtwCustomerNotification,
31
+ type MtwCustomerNotificationEvent,
32
+ type MtwCustomerNotificationPreview,
33
+ type MtwCustomerNotificationSettings,
34
+ type MtwCustomerNotificationStatus,
35
+ } from '../utils/customerNotifications';
36
+ import { FormError } from './FormError';
37
+ import { Notice, useNotice } from './Notice';
38
+ import {
39
+ Badge,
40
+ Button,
41
+ Card,
42
+ CardContent,
43
+ CardDescription,
44
+ CardHeader,
45
+ CardTitle,
46
+ Checkbox,
47
+ Label,
48
+ Skeleton,
49
+ Switch,
50
+ } from './ui';
51
+
52
+ const RECENT_LIMIT = 10;
53
+
54
+ type Draft = Pick<
55
+ MtwCustomerNotificationSettings,
56
+ 'enabled' | 'emailEnabled' | 'smsEnabled' | 'events'
57
+ >;
58
+
59
+ const toDraft = (settings: MtwCustomerNotificationSettings): Draft => ({
60
+ enabled: settings.enabled,
61
+ emailEnabled: settings.emailEnabled,
62
+ smsEnabled: settings.smsEnabled,
63
+ events: [...settings.events],
64
+ });
65
+
66
+ const STATUS_VARIANT: Record<
67
+ MtwCustomerNotificationStatus,
68
+ 'default' | 'secondary' | 'outline' | 'destructive'
69
+ > = {
70
+ SENT: 'default',
71
+ PENDING: 'secondary',
72
+ SKIPPED: 'outline',
73
+ FAILED: 'destructive',
74
+ };
75
+
76
+ export function CustomerNotificationSettingsCard({ readOnly }: { readOnly: boolean }) {
77
+ const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
78
+ useMoveTheWheels();
79
+ const graphQLOptions = useMemo(
80
+ () => ({
81
+ apiGatewayUrl,
82
+ authToken,
83
+ workspaceToken,
84
+ workspaceId,
85
+ tenantId,
86
+ actorId: currentUser?.id,
87
+ }),
88
+ [apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
89
+ );
90
+
91
+ const [settings, setSettings] = useState<MtwCustomerNotificationSettings>(() =>
92
+ defaultNotificationSettings(workspaceId ?? '')
93
+ );
94
+ const [draft, setDraft] = useState<Draft>(() => toDraft(settings));
95
+ const [isLoaded, setIsLoaded] = useState(false);
96
+ const [loadError, setLoadError] = useState<string | null>(null);
97
+ const [isSaving, setIsSaving] = useState(false);
98
+ const [formError, setFormError] = useState<string | null>(null);
99
+ const { message: notice, notify } = useNotice();
100
+ const [recent, setRecent] = useState<MtwCustomerNotification[]>([]);
101
+ const [recentTotal, setRecentTotal] = useState(0);
102
+ const [recentError, setRecentError] = useState<string | null>(null);
103
+ // Starts true so the empty state does not flash before the first fetch resolves.
104
+ const [isLoadingRecent, setIsLoadingRecent] = useState(true);
105
+ const [previews, setPreviews] = useState<MtwCustomerNotificationPreview[]>([]);
106
+ const [previewEvent, setPreviewEvent] = useState<MtwCustomerNotificationEvent>('BOOKED');
107
+ const [loadAttempt, setLoadAttempt] = useState(0);
108
+ const recentRequest = useRef(0);
109
+
110
+ useEffect(() => {
111
+ if (!graphQLOptions.workspaceId) return;
112
+ let cancelled = false;
113
+ setIsLoaded(false);
114
+ executeGraphQL<{ mtwCustomerNotificationSettings: MtwCustomerNotificationSettings }>(
115
+ graphQLOptions,
116
+ MTW_CUSTOMER_NOTIFICATION_SETTINGS_QUERY
117
+ )
118
+ .then((data) => {
119
+ if (cancelled) return;
120
+ setSettings(data.mtwCustomerNotificationSettings);
121
+ setDraft(toDraft(data.mtwCustomerNotificationSettings));
122
+ setLoadError(null);
123
+ setIsLoaded(true);
124
+ })
125
+ .catch((caught: unknown) => {
126
+ if (!cancelled)
127
+ setLoadError(caught instanceof Error ? caught.message : 'Could not load the settings');
128
+ });
129
+ executeGraphQL<{ mtwCustomerNotificationPreviews: MtwCustomerNotificationPreview[] }>(
130
+ graphQLOptions,
131
+ MTW_CUSTOMER_NOTIFICATION_PREVIEWS_QUERY
132
+ )
133
+ .then((data) => {
134
+ if (!cancelled) setPreviews(data.mtwCustomerNotificationPreviews);
135
+ })
136
+ .catch(() => {
137
+ // Previews are a convenience; the settings still work without them.
138
+ });
139
+ return () => {
140
+ cancelled = true;
141
+ };
142
+ }, [graphQLOptions, loadAttempt]);
143
+
144
+ // Only the newest request may write the log: a slower, older response (or
145
+ // one that lands after unmount) is dropped.
146
+ const loadRecent = useCallback(async () => {
147
+ if (!graphQLOptions.workspaceId) {
148
+ setIsLoadingRecent(false);
149
+ return;
150
+ }
151
+ const request = ++recentRequest.current;
152
+ setIsLoadingRecent(true);
153
+ try {
154
+ const data = await executeGraphQL<{
155
+ mtwCustomerNotifications: { items: MtwCustomerNotification[]; total: number };
156
+ }>(graphQLOptions, MTW_CUSTOMER_NOTIFICATIONS_QUERY, {
157
+ pagination: { limit: RECENT_LIMIT, offset: 0 },
158
+ });
159
+ if (request !== recentRequest.current) return;
160
+ setRecent(data.mtwCustomerNotifications.items);
161
+ setRecentTotal(data.mtwCustomerNotifications.total);
162
+ setRecentError(null);
163
+ } catch (caught) {
164
+ if (request !== recentRequest.current) return;
165
+ setRecentError(caught instanceof Error ? caught.message : 'Could not load notifications');
166
+ } finally {
167
+ if (request === recentRequest.current) setIsLoadingRecent(false);
168
+ }
169
+ }, [graphQLOptions]);
170
+
171
+ useEffect(() => {
172
+ void loadRecent();
173
+ return () => {
174
+ // Invalidate whatever is in flight when the options change or the card unmounts.
175
+ recentRequest.current += 1;
176
+ };
177
+ }, [loadRecent]);
178
+
179
+ const changes = useMemo(() => changedNotificationSettings(settings, draft), [settings, draft]);
180
+ const draftError = notificationDraftError(draft);
181
+ const isDirty = Object.keys(changes).length > 0;
182
+ const canSave = isLoaded && !readOnly && !isSaving && isDirty && !draftError;
183
+ const disabled = readOnly || isSaving || !isLoaded;
184
+
185
+ const update = (next: Partial<Draft>) => {
186
+ setFormError(null);
187
+ setDraft((previous) => ({ ...previous, ...next }));
188
+ };
189
+
190
+ const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
191
+ event.preventDefault();
192
+ if (!canSave) return;
193
+ setIsSaving(true);
194
+ setFormError(null);
195
+ try {
196
+ const data = await executeGraphQL<{
197
+ updateMtwCustomerNotificationSettings: MtwCustomerNotificationSettings;
198
+ }>(
199
+ graphQLOptions,
200
+ UPDATE_MTW_CUSTOMER_NOTIFICATION_SETTINGS_MUTATION,
201
+ { input: changes },
202
+ 'SINGLE_ATTEMPT'
203
+ );
204
+ const saved = data.updateMtwCustomerNotificationSettings;
205
+ setSettings(saved);
206
+ setDraft(toDraft(saved));
207
+ notify(
208
+ saved.enabled
209
+ ? 'Customer notifications are on. Customers hear about shipment changes from now on.'
210
+ : 'Customer notifications are off.'
211
+ );
212
+ } catch (caught) {
213
+ setFormError(caught instanceof Error ? caught.message : 'Could not save the settings');
214
+ } finally {
215
+ setIsSaving(false);
216
+ }
217
+ };
218
+
219
+ const preview = previews.find((item) => item.event === previewEvent);
220
+
221
+ return (
222
+ <Card data-testid="mtw-customer-notifications">
223
+ <CardHeader>
224
+ <div className="flex flex-wrap items-center justify-between gap-3">
225
+ <CardTitle className="flex items-center gap-2 text-base">
226
+ <BellRing size={18} /> Customer delivery notifications
227
+ </CardTitle>
228
+ {isLoaded && (
229
+ <Badge variant={settings.enabled ? 'default' : 'outline'}>
230
+ {settings.enabled ? 'On' : 'Off'}
231
+ </Badge>
232
+ )}
233
+ </div>
234
+ <CardDescription>
235
+ Email or text your customers when their shipment is booked, picked up, out for delivery,
236
+ delivered, running late or could not be delivered. Each customer can opt out on their
237
+ customer record; SMS goes only to customers who agreed to texts.
238
+ </CardDescription>
239
+ </CardHeader>
240
+ <CardContent className="space-y-6">
241
+ {loadError && (
242
+ <div className="space-y-3">
243
+ <FormError
244
+ message={`Could not load the customer notification settings (${loadError}). They cannot be changed until they load.`}
245
+ />
246
+ <Button
247
+ type="button"
248
+ variant="outline"
249
+ size="sm"
250
+ onClick={() => {
251
+ setLoadError(null);
252
+ setLoadAttempt((attempt) => attempt + 1);
253
+ }}
254
+ >
255
+ <RefreshCw size={14} className="mr-2" /> Try again
256
+ </Button>
257
+ </div>
258
+ )}
259
+ <Notice message={notice} />
260
+
261
+ {!isLoaded ? (
262
+ loadError ? null : (
263
+ <div className="space-y-3" aria-busy="true">
264
+ <Skeleton className="h-8 w-full" />
265
+ <Skeleton className="h-24 w-full" />
266
+ </div>
267
+ )
268
+ ) : (
269
+ <form
270
+ className="space-y-6"
271
+ onSubmit={(event) => void onSubmit(event)}
272
+ aria-label="Customer notification settings"
273
+ >
274
+ {readOnly && (
275
+ <p className="rounded-lg border border-border-default bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
276
+ You can view these settings. Ask a workspace admin or the Logistics Director to
277
+ change them.
278
+ </p>
279
+ )}
280
+ <div className="flex items-start justify-between gap-4 rounded-lg border border-border-default px-4 py-3">
281
+ <div className="min-w-0">
282
+ <Label htmlFor="mtw-customer-notify-enabled" className="text-sm font-medium">
283
+ Notify customers about their shipments
284
+ </Label>
285
+ <p className="text-xs text-text-secondary">
286
+ Only changes made after you switch this on notify; customers are never mailed
287
+ about shipments that already happened.
288
+ {settings.enabledAt && settings.enabled
289
+ ? ` On since ${formatDateTime(settings.enabledAt)}.`
290
+ : ''}
291
+ </p>
292
+ </div>
293
+ <Switch
294
+ id="mtw-customer-notify-enabled"
295
+ checked={draft.enabled}
296
+ disabled={disabled}
297
+ onCheckedChange={(checked: boolean) => update({ enabled: checked })}
298
+ />
299
+ </div>
300
+
301
+ <fieldset className="space-y-3" disabled={disabled}>
302
+ <legend className="text-sm font-semibold text-text-primary">Channels</legend>
303
+ <div className="flex flex-wrap gap-6">
304
+ <div className="flex items-center gap-2">
305
+ <Checkbox
306
+ id="mtw-customer-notify-email"
307
+ checked={draft.emailEnabled}
308
+ disabled={disabled}
309
+ onCheckedChange={(checked) => update({ emailEnabled: checked === true })}
310
+ />
311
+ <Label htmlFor="mtw-customer-notify-email" className="text-sm font-normal">
312
+ Email
313
+ </Label>
314
+ </div>
315
+ <div className="flex items-center gap-2">
316
+ <Checkbox
317
+ id="mtw-customer-notify-sms"
318
+ checked={draft.smsEnabled}
319
+ disabled={disabled}
320
+ onCheckedChange={(checked) => update({ smsEnabled: checked === true })}
321
+ />
322
+ <Label htmlFor="mtw-customer-notify-sms" className="text-sm font-normal">
323
+ SMS (phone numbers with a country code, e.g. +91…)
324
+ </Label>
325
+ </div>
326
+ </div>
327
+ <p className="text-xs text-text-secondary">
328
+ Texts are sent through the SMS channel configured for your organization. Without
329
+ one, SMS sends are recorded as failed in the recent notifications below.
330
+ </p>
331
+ </fieldset>
332
+
333
+ <fieldset className="space-y-3" disabled={disabled}>
334
+ <legend className="text-sm font-semibold text-text-primary">Events</legend>
335
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
336
+ {CUSTOMER_NOTIFICATION_EVENTS.map((option) => {
337
+ const id = `mtw-customer-notify-event-${option.value}`;
338
+ return (
339
+ <div key={option.value} className="flex items-start gap-2">
340
+ <Checkbox
341
+ id={id}
342
+ checked={draft.events.includes(option.value)}
343
+ disabled={disabled}
344
+ onCheckedChange={(checked) =>
345
+ update({
346
+ events: toggleEvent(draft.events, option.value, checked === true),
347
+ })
348
+ }
349
+ />
350
+ <Label htmlFor={id} className="text-sm font-normal leading-snug">
351
+ <span className="block font-medium text-text-primary">{option.label}</span>
352
+ <span className="block text-xs text-text-secondary">
353
+ {option.description}
354
+ </span>
355
+ </Label>
356
+ </div>
357
+ );
358
+ })}
359
+ </div>
360
+ </fieldset>
361
+
362
+ {draftError && <p className="text-xs text-status-error-text">{draftError}</p>}
363
+ <FormError message={formError} />
364
+
365
+ <div className="flex flex-wrap items-center justify-end gap-3">
366
+ <Button
367
+ type="button"
368
+ variant="outline"
369
+ disabled={!isDirty || isSaving}
370
+ onClick={() => {
371
+ setFormError(null);
372
+ setDraft(toDraft(settings));
373
+ }}
374
+ >
375
+ <RotateCcw size={16} className="mr-2" /> Discard changes
376
+ </Button>
377
+ <Button type="submit" disabled={!canSave}>
378
+ {isSaving ? (
379
+ <Loader2 size={16} className="mr-2 animate-spin" />
380
+ ) : (
381
+ <Save size={16} className="mr-2" />
382
+ )}
383
+ Save notification settings
384
+ </Button>
385
+ </div>
386
+ </form>
387
+ )}
388
+
389
+ {previews.length > 0 && (
390
+ <div className="space-y-3 border-t border-border-default pt-4">
391
+ <div className="flex flex-wrap items-center gap-2">
392
+ <span className="text-sm font-semibold text-text-primary">Preview</span>
393
+ {CUSTOMER_NOTIFICATION_EVENTS.map((option) => (
394
+ <Button
395
+ key={option.value}
396
+ type="button"
397
+ size="sm"
398
+ variant={previewEvent === option.value ? 'default' : 'outline'}
399
+ onClick={() => setPreviewEvent(option.value)}
400
+ >
401
+ {option.label}
402
+ </Button>
403
+ ))}
404
+ </div>
405
+ {preview && (
406
+ <div
407
+ className="space-y-2 rounded-lg bg-surface-secondary px-4 py-3 text-sm"
408
+ data-testid="mtw-customer-notify-preview"
409
+ >
410
+ <p className="font-medium text-text-primary">{preview.subject}</p>
411
+ <p className="whitespace-pre-line text-text-secondary">{preview.emailText}</p>
412
+ <p className="text-xs text-text-tertiary">SMS: {preview.smsText}</p>
413
+ </div>
414
+ )}
415
+ </div>
416
+ )}
417
+
418
+ <div className="space-y-3 border-t border-border-default pt-4">
419
+ <div className="flex flex-wrap items-center justify-between gap-2">
420
+ <span className="text-sm font-semibold text-text-primary">
421
+ Recent notifications{recentTotal > 0 ? ` (${recentTotal})` : ''}
422
+ </span>
423
+ <Button
424
+ type="button"
425
+ size="sm"
426
+ variant="outline"
427
+ disabled={isLoadingRecent}
428
+ onClick={() => void loadRecent()}
429
+ >
430
+ <RefreshCw size={14} className="mr-2" /> Refresh
431
+ </Button>
432
+ </div>
433
+ {recentError && <FormError message={recentError} />}
434
+ {!recentError && recent.length === 0 && !isLoadingRecent && (
435
+ <p className="text-sm text-text-secondary">
436
+ No customer notifications yet. They appear here as shipments change.
437
+ </p>
438
+ )}
439
+ {recent.length > 0 && (
440
+ <ul className="divide-y divide-border-default" data-testid="mtw-customer-notify-recent">
441
+ {recent.map((item) => (
442
+ <li key={item.id} className="flex flex-wrap items-start justify-between gap-2 py-2">
443
+ <div className="min-w-0">
444
+ <p className="text-sm font-medium text-text-primary">
445
+ {eventLabel(item.event)} · {item.shipmentNumber ?? item.shipmentId}
446
+ </p>
447
+ <p className="text-xs text-text-secondary">
448
+ {item.channel === 'EMAIL' ? 'Email' : 'SMS'}
449
+ {item.recipient ? ` to ${item.recipient}` : ''} ·{' '}
450
+ {formatDateTime(item.sentAt ?? item.createdAt)}
451
+ </p>
452
+ {item.lastError && item.status !== 'SENT' && (
453
+ <p className="text-xs text-text-tertiary">{item.lastError}</p>
454
+ )}
455
+ </div>
456
+ <Badge variant={STATUS_VARIANT[item.status]}>{STATUS_LABELS[item.status]}</Badge>
457
+ </li>
458
+ ))}
459
+ </ul>
460
+ )}
461
+ </div>
462
+ </CardContent>
463
+ </Card>
464
+ );
465
+ }