@burdenoff/microfe-movethewheels 2026.1101.3 → 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.
Files changed (29) hide show
  1. package/dist/components/CustomerDeliveryUpdatesFields.js +57 -0
  2. package/dist/components/CustomerNotificationSettingsCard.js +368 -0
  3. package/dist/components/UpcomingDemandPanel.js +75 -77
  4. package/dist/generated/tracking-quality-operations.d.ts +275 -0
  5. package/dist/generated/wspace-operations.d.ts +3 -0
  6. package/dist/hooks/useCustomers.js +9 -3
  7. package/dist/hooks/useDemandForecast.js +1 -1
  8. package/dist/movethewheels/components/CustomerDeliveryUpdatesFields.d.ts +12 -0
  9. package/dist/movethewheels/components/CustomerNotificationSettingsCard.d.ts +3 -0
  10. package/dist/movethewheels/hooks/useDemandForecast.d.ts +3 -0
  11. package/dist/movethewheels/types/index.d.ts +4 -0
  12. package/dist/movethewheels/utils/customerNotifications.d.ts +66 -0
  13. package/dist/node_modules/lucide-react/dist/esm/icons/bell-ring.js +22 -0
  14. package/dist/pages/CustomerDetailsPage.js +279 -231
  15. package/dist/pages/SettingsPage.js +242 -238
  16. package/dist/utils/customerNotifications.js +83 -0
  17. package/package.json +3 -2
  18. package/src/generated/tracking-quality-operations.ts +304 -0
  19. package/src/generated/wspace-operations.ts +6 -6
  20. package/src/movethewheels/components/CustomerDeliveryUpdatesFields.tsx +67 -0
  21. package/src/movethewheels/components/CustomerNotificationSettingsCard.tsx +465 -0
  22. package/src/movethewheels/components/UpcomingDemandPanel.tsx +71 -29
  23. package/src/movethewheels/hooks/useCustomers.ts +17 -0
  24. package/src/movethewheels/hooks/useDemandForecast.ts +7 -0
  25. package/src/movethewheels/pages/CustomerDetailsPage.tsx +39 -0
  26. package/src/movethewheels/pages/SettingsPage.tsx +221 -208
  27. package/src/movethewheels/types/index.ts +4 -0
  28. package/src/movethewheels/utils/customerNotifications.ts +209 -0
  29. package/src/operations/wspace/movethewheels/queries/forecast.graphql +3 -0
@@ -4,11 +4,17 @@
4
4
  * Surfaces next-week demand forecasts by lane / customer alongside the
5
5
  * most recent closed-period variance (BOFF-4393). Pure read-side view
6
6
  * over `wspace-movethewheels-svc`'s DemandForecast surface.
7
+ *
8
+ * "Next week" is the first forecast week that has not begun yet: the current
9
+ * week's rows are kept server-side for the closed-period variance, but they
10
+ * are not upcoming demand. Cold-start rows (confidence 0, fewer than 4 weeks
11
+ * of history for that lane or customer) are not shown as forecasts.
7
12
  */
8
13
 
9
14
  import { Calendar, TrendingUp, TrendingDown, AlertCircle } from 'lucide-react';
10
15
 
11
16
  import { Card, CardContent, CardHeader, CardTitle, Skeleton } from './ui';
17
+ import { formatCurrency } from '../utils/formatters';
12
18
 
13
19
  import type { ForecastPoint, ForecastVariance } from '../hooks/useDemandForecast';
14
20
 
@@ -28,20 +34,41 @@ const formatWeek = (iso: string): string => {
28
34
  return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
29
35
  };
30
36
 
31
- const topByVolume = (rows: ForecastPoint[], weekStart: string): ForecastPoint[] => {
32
- const filtered = rows.filter((row) => row.periodStart === weekStart);
33
- return [...filtered].sort((a, b) => b.predictedVolume - a.predictedVolume).slice(0, 3);
34
- };
37
+ /** Rows for one week that rest on enough history to be a forecast. */
38
+ const forecastsFor = (rows: ForecastPoint[], weekStart: string): ForecastPoint[] =>
39
+ rows.filter((row) => row.periodStart === weekStart && row.confidence > 0);
35
40
 
36
- const topByRevenue = (rows: ForecastPoint[], weekStart: string): ForecastPoint[] => {
37
- const filtered = rows.filter((row) => row.periodStart === weekStart);
38
- return [...filtered].sort((a, b) => b.predictedRevenue - a.predictedRevenue).slice(0, 3);
39
- };
41
+ const topByVolume = (rows: ForecastPoint[], weekStart: string): ForecastPoint[] =>
42
+ [...forecastsFor(rows, weekStart)]
43
+ .sort(
44
+ (a, b) => b.predictedVolume - a.predictedVolume || b.predictedRevenue - a.predictedRevenue
45
+ )
46
+ .slice(0, 3);
47
+
48
+ const topByRevenue = (rows: ForecastPoint[], weekStart: string): ForecastPoint[] =>
49
+ [...forecastsFor(rows, weekStart)]
50
+ .sort((a, b) => b.predictedRevenue - a.predictedRevenue)
51
+ .slice(0, 3);
40
52
 
41
53
  const totalVolume = (rows: ForecastPoint[], weekStart: string): number =>
42
- rows
43
- .filter((row) => row.periodStart === weekStart)
44
- .reduce((sum, row) => sum + row.predictedVolume, 0);
54
+ forecastsFor(rows, weekStart).reduce((sum, row) => sum + row.predictedVolume, 0);
55
+
56
+ /**
57
+ * The first forecast week that has not started yet, else the latest week on
58
+ * offer (rows arrive sorted by period).
59
+ */
60
+ const upcomingWeekStart = (rows: ForecastPoint[], now: Date = new Date()): string | null => {
61
+ const starts = [...new Set(rows.map((row) => row.periodStart))].sort(
62
+ (a, b) => new Date(a).getTime() - new Date(b).getTime()
63
+ );
64
+ return starts.find((start) => new Date(start).getTime() > now.getTime()) ?? starts.at(-1) ?? null;
65
+ };
66
+
67
+ /** `3 jobs`, `1 job`, or `<1 job` when demand is real but under one a week. */
68
+ const formatJobs = (volume: number, revenue: number): string => {
69
+ if (volume === 0 && revenue > 0) return '<1 job';
70
+ return volume === 1 ? '1 job' : `${volume} jobs`;
71
+ };
45
72
 
46
73
  const formatConfidence = (confidence: number): string => {
47
74
  if (confidence <= 0) return 'Not enough history';
@@ -90,9 +117,13 @@ export function UpcomingDemandPanel({
90
117
  );
91
118
  }
92
119
 
93
- const nextWeekStart = laneForecast[0]?.periodStart ?? customerForecast[0]?.periodStart ?? null;
120
+ const nextWeekStart = upcomingWeekStart([...laneForecast, ...customerForecast]);
121
+ const hasForecast =
122
+ nextWeekStart !== null &&
123
+ (forecastsFor(laneForecast, nextWeekStart).length > 0 ||
124
+ forecastsFor(customerForecast, nextWeekStart).length > 0);
94
125
 
95
- if (!nextWeekStart || laneForecast.length === 0) {
126
+ if (!nextWeekStart || !hasForecast) {
96
127
  return (
97
128
  <Card>
98
129
  <CardHeader>
@@ -105,8 +136,8 @@ export function UpcomingDemandPanel({
105
136
  <div className="flex items-start gap-2 rounded-lg border border-border/60 bg-muted/40 p-4 text-sm text-text-secondary">
106
137
  <AlertCircle size={16} aria-hidden="true" className="mt-0.5 shrink-0" />
107
138
  <p>
108
- Not enough history to forecast next week yet. Once you have at least 4 weeks of
109
- completed orders, this panel will surface demand by lane and customer.
139
+ Not enough history to forecast next week yet. Once a lane or customer has at least 4
140
+ weeks of delivery jobs, this panel will surface its demand.
110
141
  </p>
111
142
  </div>
112
143
  </CardContent>
@@ -117,7 +148,18 @@ export function UpcomingDemandPanel({
117
148
  const topLanes = topByVolume(laneForecast, nextWeekStart);
118
149
  const topLanesByRevenue = topByRevenue(laneForecast, nextWeekStart);
119
150
  const topCustomers = topByVolume(customerForecast, nextWeekStart);
120
- const total = totalVolume(laneForecast, nextWeekStart);
151
+ // The headline total comes from lane forecasts when any lane clears the
152
+ // history threshold. A customer can have enough history while each of its
153
+ // lanes does not, so fall back to the customer total rather than show a
154
+ // lane total of 0 beside valid customer forecasts.
155
+ const hasLaneForecast = forecastsFor(laneForecast, nextWeekStart).length > 0;
156
+ const total = hasLaneForecast
157
+ ? totalVolume(laneForecast, nextWeekStart)
158
+ : totalVolume(customerForecast, nextWeekStart);
159
+ const totalLabel = hasLaneForecast
160
+ ? 'Predicted jobs (all lanes)'
161
+ : 'Predicted jobs (all customers)';
162
+ const headlineRow = hasLaneForecast ? topLanes[0] : topCustomers[0];
121
163
 
122
164
  // Most-recent variance row per scope key — show the lane that overshot /
123
165
  // undershot the most in absolute volume terms.
@@ -136,13 +178,11 @@ export function UpcomingDemandPanel({
136
178
  <CardContent className="space-y-4">
137
179
  <div className="flex items-baseline justify-between rounded-lg border border-border/60 bg-muted/40 px-4 py-3">
138
180
  <div>
139
- <p className="text-xs uppercase tracking-wide text-text-secondary">
140
- Predicted jobs (all lanes)
141
- </p>
181
+ <p className="text-xs uppercase tracking-wide text-text-secondary">{totalLabel}</p>
142
182
  <p className="text-2xl font-bold">{total}</p>
143
183
  </div>
144
184
  <p className="text-xs text-text-secondary">
145
- {topLanes[0] ? formatConfidence(topLanes[0].confidence) : ''}
185
+ {headlineRow ? formatConfidence(headlineRow.confidence) : ''}
146
186
  </p>
147
187
  </div>
148
188
 
@@ -180,14 +220,12 @@ function ForecastList({ title, rows }: ForecastListProps) {
180
220
  key={`${row.scopeType}-${row.scopeKey}`}
181
221
  className="flex items-center justify-between text-sm"
182
222
  >
183
- <span className="truncate font-medium">{row.scopeKey}</span>
223
+ <span className="truncate font-medium" title={row.scopeLabel}>
224
+ {row.scopeLabel}
225
+ </span>
184
226
  <span className="ml-2 shrink-0 text-text-secondary">
185
- {row.predictedVolume} jobs ·{' '}
186
- {new Intl.NumberFormat(undefined, {
187
- style: 'currency',
188
- currency: 'USD',
189
- maximumFractionDigits: 0,
190
- }).format(row.predictedRevenue)}
227
+ {formatJobs(row.predictedVolume, row.predictedRevenue)} ·{' '}
228
+ {formatCurrency(row.predictedRevenue)}
191
229
  </span>
192
230
  </li>
193
231
  ))}
@@ -219,11 +257,15 @@ function VarianceList({ rows }: VarianceListProps) {
219
257
  >
220
258
  <span className="flex items-center gap-2 truncate">
221
259
  <Icon size={14} className={tone} aria-hidden="true" />
222
- <span className="truncate font-medium">{row.scopeKey}</span>
260
+ <span className="truncate font-medium" title={row.scopeLabel}>
261
+ {row.scopeLabel}
262
+ </span>
223
263
  </span>
224
264
  <span className={`ml-2 shrink-0 ${tone}`}>
225
265
  {isOvershoot ? '+' : ''}
226
- {row.volumeVariance} jobs
266
+ {Math.abs(row.volumeVariance) === 1
267
+ ? `${row.volumeVariance} job`
268
+ : `${row.volumeVariance} jobs`}
227
269
  </span>
228
270
  </li>
229
271
  );
@@ -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
 
@@ -12,6 +12,8 @@ import { executeGraphQL } from '../utils/graphqlClient';
12
12
  export interface ForecastPoint {
13
13
  scopeType: 'LANE' | 'CUSTOMER';
14
14
  scopeKey: string;
15
+ /** Customer name or `Origin → Destination`; falls back to the key. */
16
+ scopeLabel: string;
15
17
  periodStart: string;
16
18
  periodEnd: string;
17
19
  predictedVolume: number;
@@ -24,6 +26,7 @@ export interface ForecastPoint {
24
26
  export interface ForecastVariance {
25
27
  scopeType: 'LANE' | 'CUSTOMER';
26
28
  scopeKey: string;
29
+ scopeLabel: string;
27
30
  periodStart: string;
28
31
  periodEnd: string;
29
32
  predictedVolume: number;
@@ -49,6 +52,7 @@ const FORECAST_QUERY = `
49
52
  demandForecastByLane(weeks: $weeks) {
50
53
  scopeType
51
54
  scopeKey
55
+ scopeLabel
52
56
  periodStart
53
57
  periodEnd
54
58
  predictedVolume
@@ -60,6 +64,7 @@ const FORECAST_QUERY = `
60
64
  demandForecastByCustomer(weeks: $weeks) {
61
65
  scopeType
62
66
  scopeKey
67
+ scopeLabel
63
68
  periodStart
64
69
  periodEnd
65
70
  predictedVolume
@@ -71,6 +76,7 @@ const FORECAST_QUERY = `
71
76
  laneVariance: demandForecastVariance(scopeType: LANE, limit: $varianceLimit) {
72
77
  scopeType
73
78
  scopeKey
79
+ scopeLabel
74
80
  periodStart
75
81
  periodEnd
76
82
  predictedVolume
@@ -83,6 +89,7 @@ const FORECAST_QUERY = `
83
89
  customerVariance: demandForecastVariance(scopeType: CUSTOMER, limit: $varianceLimit) {
84
90
  scopeType
85
91
  scopeKey
92
+ scopeLabel
86
93
  periodStart
87
94
  periodEnd
88
95
  predictedVolume
@@ -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
  )}