@burdenoff/microfe-movethewheels 2026.1101.4 → 2026.1102.1
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/CustomerDeliveryUpdatesFields.js +57 -0
- package/dist/components/CustomerNotificationSettingsCard.js +368 -0
- package/dist/generated/tracking-quality-operations.d.ts +275 -0
- package/dist/hooks/useCustomers.js +9 -3
- package/dist/movethewheels/components/CustomerDeliveryUpdatesFields.d.ts +12 -0
- package/dist/movethewheels/components/CustomerNotificationSettingsCard.d.ts +3 -0
- package/dist/movethewheels/types/index.d.ts +4 -0
- package/dist/movethewheels/utils/customerNotifications.d.ts +66 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/bell-ring.js +22 -0
- package/dist/pages/CustomerDetailsPage.js +279 -231
- package/dist/pages/SettingsPage.js +242 -238
- package/dist/utils/customerNotifications.js +83 -0
- package/package.json +3 -2
- package/src/generated/tracking-quality-operations.ts +304 -0
- package/src/movethewheels/components/CustomerDeliveryUpdatesFields.tsx +67 -0
- package/src/movethewheels/components/CustomerNotificationSettingsCard.tsx +465 -0
- package/src/movethewheels/hooks/useCustomers.ts +17 -0
- package/src/movethewheels/pages/CustomerDetailsPage.tsx +39 -0
- package/src/movethewheels/pages/SettingsPage.tsx +221 -208
- package/src/movethewheels/types/index.ts +4 -0
- package/src/movethewheels/utils/customerNotifications.ts +209 -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
|
+
}
|
|
@@ -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
|
|
|
@@ -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
|
)}
|