@astryxdesign/cli 0.4.7 → 0.5.0-canary.009bcb3

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 (105) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +53 -51
  3. package/api/docs/docs.doc.mjs +2 -2
  4. package/api/index.d.mts +1 -1
  5. package/api/index.mjs +1 -1
  6. package/api/search/search.mjs +50 -4
  7. package/api/search/search.test.mjs +71 -0
  8. package/api/template/data-token-fallbacks.test.mjs +85 -0
  9. package/api/template/template.doc.mjs +2 -2
  10. package/api/theme/build/build.mjs +43 -44
  11. package/api/theme/targets/targets.d.mts +18 -0
  12. package/api/theme/targets/targets.mjs +87 -0
  13. package/api/theme/targets/targets.test.mjs +66 -0
  14. package/api/theme/theme.d.mts +1 -0
  15. package/api/theme/theme.mjs +3 -1
  16. package/api/theme/theme.type.d.mts +23 -0
  17. package/api/theme/theme.type.mjs +21 -1
  18. package/api/theme/themeTargets.doc.d.mts +11 -0
  19. package/api/theme/themeTargets.doc.mjs +58 -0
  20. package/api/theme/themeTemplate.doc.mjs +3 -3
  21. package/assets/codemods/__tests__/registry.test.mjs +1 -0
  22. package/assets/codemods/registry.mjs +1 -0
  23. package/assets/codemods/transforms/v0.5.0/__tests__/next-codemods.test.mjs +127 -0
  24. package/assets/codemods/transforms/v0.5.0/banner-collapsible-content.mjs +171 -0
  25. package/assets/codemods/transforms/v0.5.0/index.mjs +20 -0
  26. package/assets/docs/README.md +50 -0
  27. package/assets/docs/cli-integrations.doc.mjs +4 -4
  28. package/assets/docs/theme.doc.dense.mjs +1 -1
  29. package/assets/docs/theme.doc.mjs +1 -1
  30. package/assets/docs/typography.doc.mjs +2 -2
  31. package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.doc.mjs +1 -1
  32. package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.tsx +1 -1
  33. package/assets/templates/blocks/components/DateInput/DateInputClearable.tsx +5 -1
  34. package/assets/templates/blocks/components/DateInput/DateInputDateRange.tsx +6 -1
  35. package/assets/templates/blocks/components/DateInput/DateInputFormats.tsx +5 -1
  36. package/assets/templates/blocks/components/DateInput/DateInputShowcase.tsx +4 -1
  37. package/assets/templates/blocks/components/DateInput/DateInputWithDescription.tsx +5 -1
  38. package/assets/templates/blocks/components/DateInput/DateInputWithValidation.tsx +5 -1
  39. package/assets/templates/blocks/components/Dialog/DialogAdaptivePresentation.doc.mjs +25 -0
  40. package/assets/templates/blocks/components/Dialog/DialogAdaptivePresentation.tsx +167 -0
  41. package/assets/templates/blocks/components/Dialog/DialogScrollingContent.tsx +1 -1
  42. package/assets/templates/blocks/components/HoverCard/HoverCardHookUsage.tsx +4 -1
  43. package/assets/templates/blocks/components/Step/StepContent.doc.mjs +14 -0
  44. package/assets/templates/blocks/components/Step/StepContent.tsx +32 -0
  45. package/assets/templates/blocks/components/Step/StepIndicator.doc.mjs +14 -0
  46. package/assets/templates/blocks/components/Step/StepIndicator.tsx +60 -0
  47. package/assets/templates/blocks/components/Step/StepShowcase.doc.mjs +15 -0
  48. package/assets/templates/blocks/components/Step/StepShowcase.tsx +26 -0
  49. package/assets/templates/blocks/components/Step/StepStates.doc.mjs +14 -0
  50. package/assets/templates/blocks/components/Step/StepStates.tsx +46 -0
  51. package/assets/templates/blocks/components/Stepper/StepperCustomContent.doc.mjs +22 -0
  52. package/assets/templates/blocks/components/Stepper/StepperCustomContent.tsx +126 -0
  53. package/assets/templates/blocks/components/Stepper/StepperIndicatorModes.doc.mjs +1 -1
  54. package/assets/templates/blocks/components/Stepper/StepperIndicatorModes.tsx +17 -5
  55. package/assets/templates/blocks/components/Stepper/StepperOnTrackHorizontal.doc.mjs +14 -0
  56. package/assets/templates/blocks/components/Stepper/StepperOnTrackHorizontal.tsx +25 -0
  57. package/assets/templates/blocks/components/Stepper/StepperOnTrackVertical.doc.mjs +2 -2
  58. package/assets/templates/blocks/components/Stepper/StepperOnTrackVertical.tsx +1 -1
  59. package/assets/templates/blocks/components/Stepper/StepperShowcase.doc.mjs +1 -1
  60. package/assets/templates/blocks/components/Stepper/StepperShowcase.tsx +6 -7
  61. package/assets/templates/blocks/components/Stepper/StepperStatus.tsx +1 -1
  62. package/assets/templates/pages/dashboard-cohort-funnel/page.tsx +1 -1
  63. package/assets/templates/pages/dashboard-data/page.tsx +1 -1
  64. package/assets/templates/pages/dashboard-portfolio/page.tsx +5 -5
  65. package/assets/templates/pages/dashboard-service-monitoring/page.tsx +1 -1
  66. package/assets/templates/pages/mixed-gallery/page.tsx +12 -3
  67. package/assets/templates/pages/settings-dialog/page.tsx +2543 -757
  68. package/assets/templates/pages/settings-dialog/template.doc.mjs +1 -2
  69. package/assets/templates/pages/table-filter/page.tsx +4093 -0
  70. package/assets/templates/pages/table-filter/template.doc.mjs +12 -0
  71. package/assets/templates/pages/table-grouped/page.tsx +151 -144
  72. package/assets/templates/pages/theme-showcase/page.tsx +36 -17
  73. package/assets/templates/themes/chocolate/chocolateTheme.ts +3 -1
  74. package/assets/templates/themes/matcha/matchaTheme.ts +3 -1
  75. package/assets/templates/themes/neutral/neutralTheme.ts +16 -9
  76. package/assets/templates/themes/stone/stoneTheme.ts +3 -1
  77. package/clients/cli/commands/build-theme.color-scheme.test.mjs +7 -6
  78. package/clients/cli/commands/build-theme.data-tokens.test.mjs +144 -0
  79. package/clients/cli/commands/build-theme.mjs +85 -0
  80. package/clients/cli/commands/build-theme.variants.test.mjs +3 -0
  81. package/clients/cli/commands/dialog-adaptive-template.test.mjs +24 -0
  82. package/clients/cli/commands/theme-targets.behavior.test.mjs +64 -0
  83. package/clients/cli/commands/theme-targets.doc.mjs +38 -0
  84. package/clients/cli/commands/theme-template.doc.mjs +2 -2
  85. package/clients/cli/commands/theme.doc.mjs +4 -2
  86. package/clients/cli/index.mjs +1 -0
  87. package/clients/cli/lib/component-format.mjs +2 -2
  88. package/clients/cli/lib/manifest.mjs +2 -0
  89. package/foundation/discovery/component-discovery.mjs +10 -9
  90. package/foundation/discovery/hook-discovery.mjs +2 -1
  91. package/foundation/discovery/hook-discovery.test.mjs +156 -37
  92. package/foundation/discovery/theming-targets.d.mts +86 -0
  93. package/foundation/discovery/theming-targets.mjs +202 -0
  94. package/foundation/discovery/theming-targets.test.mjs +245 -0
  95. package/foundation/fs/paths.d.mts +16 -0
  96. package/foundation/fs/paths.mjs +36 -0
  97. package/foundation/fs/paths.test.mjs +29 -1
  98. package/foundation/response/response-types.doc.mjs +7 -2
  99. package/package.json +9 -9
  100. package/assets/templates/blocks/components/Stepper/StepperHorizontal.doc.mjs +0 -14
  101. package/assets/templates/blocks/components/Stepper/StepperHorizontal.tsx +0 -24
  102. package/assets/templates/blocks/components/Stepper/StepperMultiStepForm.doc.mjs +0 -14
  103. package/assets/templates/blocks/components/Stepper/StepperMultiStepForm.tsx +0 -92
  104. package/assets/templates/blocks/components/Stepper/StepperVerticalOnboarding.doc.mjs +0 -14
  105. package/assets/templates/blocks/components/Stepper/StepperVerticalOnboarding.tsx +0 -40
@@ -0,0 +1,4093 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * Table page driven by a row of filter selectors.
7
+ *
8
+ * Each filterable field is a Selector, so the closed trigger doubles as the
9
+ * filter chip: unset it reads as the bare field name against the same outlined
10
+ * chrome as the search box, and once set it fills in and expands to the whole
11
+ * clause (`Status is Overdue`) with a clear beside it. Anything the selectors
12
+ * can't express — free numeric comparisons, OR groups — swaps to power search,
13
+ * and because both modes read the same filter array, a filter built either way
14
+ * survives the swap.
15
+ *
16
+ * The same row is where saved views and bulk edit surface: selecting rows
17
+ * replaces it with a selection bar, and the saved-views toggle swaps it for a
18
+ * list of saved views. A saved view is the whole screen — the filters plus the
19
+ * columns, density, sticky edges and grouping — so applying one restores the
20
+ * table as it was, and there is no separate column preset to disagree with it.
21
+ *
22
+ * Clicking a row opens its details in the end panel. That is separate from the
23
+ * checkbox selection driving bulk edit — a row can be open without being
24
+ * selected, and vice versa.
25
+ *
26
+ * @input Deterministic fixtures only (field-service jobs for a mechanical
27
+ * contractor: customers, technicians, fixed ISO schedule times, quotes)
28
+ */
29
+
30
+ import {useCallback, useEffect, useId, useMemo, useRef, useState} from 'react';
31
+ import type {
32
+ KeyboardEvent as ReactKeyboardEvent,
33
+ PointerEvent as ReactPointerEvent,
34
+ SVGProps,
35
+ } from 'react';
36
+ import * as stylex from '@stylexjs/stylex';
37
+ import {
38
+ HStack,
39
+ Layout,
40
+ LayoutContent,
41
+ LayoutHeader,
42
+ LayoutPanel,
43
+ StackItem,
44
+ VStack,
45
+ } from '@astryxdesign/core/Layout';
46
+ import {
47
+ borderVars,
48
+ colorVars,
49
+ durationVars,
50
+ easeVars,
51
+ fontWeightVars,
52
+ radiusVars,
53
+ spacingVars,
54
+ typeScaleVars,
55
+ } from '@astryxdesign/core/theme/tokens.stylex';
56
+ import {Item} from '@astryxdesign/core/Item';
57
+ import {List} from '@astryxdesign/core/List';
58
+ import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
59
+ import {useAnnounce} from '@astryxdesign/core/hooks';
60
+ import {MetadataList, MetadataListItem} from '@astryxdesign/core/MetadataList';
61
+ import {Heading, Text} from '@astryxdesign/core/Text';
62
+ import {Button} from '@astryxdesign/core/Button';
63
+ import {IconButton} from '@astryxdesign/core/IconButton';
64
+ import {ToggleButton} from '@astryxdesign/core/ToggleButton';
65
+ import {Icon} from '@astryxdesign/core/Icon';
66
+ import {Avatar} from '@astryxdesign/core/Avatar';
67
+ import {Token} from '@astryxdesign/core/Token';
68
+ import {Badge} from '@astryxdesign/core/Badge';
69
+ import {StatusDot} from '@astryxdesign/core/StatusDot';
70
+ import {TextInput} from '@astryxdesign/core/TextInput';
71
+ import {Selector} from '@astryxdesign/core/Selector';
72
+ import {RadioList, RadioListItem} from '@astryxdesign/core/RadioList';
73
+ import {Popover} from '@astryxdesign/core/Popover';
74
+ import {Link} from '@astryxdesign/core/Link';
75
+ import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
76
+ import {Divider} from '@astryxdesign/core/Divider';
77
+ import {Section} from '@astryxdesign/core/Section';
78
+ import {EmptyState} from '@astryxdesign/core/EmptyState';
79
+ import {MultiSelector} from '@astryxdesign/core/MultiSelector';
80
+ import {ComplexSelector} from '@astryxdesign/core/ComplexSelector';
81
+ import {ProgressBar} from '@astryxdesign/core/ProgressBar';
82
+ import {ResizeHandle, useResizable} from '@astryxdesign/core/Resizable';
83
+ import {Skeleton} from '@astryxdesign/core/Skeleton';
84
+ import {Slider} from '@astryxdesign/core/Slider';
85
+ import {AspectRatio} from '@astryxdesign/core/AspectRatio';
86
+ import {
87
+ PowerSearch,
88
+ usePowerSearchConfig,
89
+ } from '@astryxdesign/core/PowerSearch';
90
+ import type {PowerSearchFilter} from '@astryxdesign/core/PowerSearch';
91
+ import {
92
+ Table,
93
+ pixel,
94
+ proportional,
95
+ useTableGroupedRows,
96
+ useTableSelection,
97
+ useTableSelectionState,
98
+ useTableSortable,
99
+ useTableStickyColumns,
100
+ } from '@astryxdesign/core/Table';
101
+ import type {TableColumn, TablePlugin} from '@astryxdesign/core/Table';
102
+ import {
103
+ Bookmark,
104
+ BookmarkPlus,
105
+ Calendar,
106
+ ChevronDown,
107
+ CircleX,
108
+ Columns3,
109
+ GripVertical,
110
+ Image as ImageIcon,
111
+ Pencil,
112
+ Pin,
113
+ Plus,
114
+ Search,
115
+ SlidersHorizontal,
116
+ Table as TableIcon,
117
+ Table2,
118
+ UserPlus,
119
+ X,
120
+ } from 'lucide-react';
121
+
122
+ // Lucide draws outlines, so a "filled" counterpart is the same glyph with its
123
+ // interior painted. `Icon` takes any SVG component, which is the supported way
124
+ // to hand it a variant the icon set does not ship as its own export.
125
+ const BookmarkFilled = (props: SVGProps<SVGSVGElement>) => (
126
+ <Bookmark {...props} fill="currentColor" />
127
+ );
128
+ const SlidersHorizontalFilled = (props: SVGProps<SVGSVGElement>) => (
129
+ <SlidersHorizontal {...props} fill="currentColor" />
130
+ );
131
+
132
+ // =============================================================================
133
+ // Data
134
+ // =============================================================================
135
+
136
+ type JobStatus =
137
+ 'scheduled' | 'in_progress' | 'on_hold' | 'overdue' | 'completed';
138
+ type Priority = 'urgent' | 'high' | 'normal';
139
+
140
+ /** The authored shape. Dates are derived on load, so they live on ServiceJob. */
141
+ interface SeedJob {
142
+ id: string;
143
+ summary: string;
144
+ customer: string;
145
+ address: string;
146
+ technician: string;
147
+ status: JobStatus;
148
+ priority: Priority;
149
+ /** ISO timestamp — the column sorts on this, so it stays a string. */
150
+ scheduledAt: string;
151
+ /** Quote in whole dollars, kept numeric so it sorts and filters. */
152
+ quoted: number;
153
+ equipment: string;
154
+ }
155
+
156
+ interface ServiceJob extends SeedJob, Record<string, unknown> {
157
+ /**
158
+ * The same instant as scheduledAt, as a Date. Power search rejects a string
159
+ * date outright, so the date filters read this field while the column reads
160
+ * the string above.
161
+ */
162
+ scheduledOn: Date;
163
+ }
164
+
165
+ /**
166
+ * The hue variants, not the semantic ones. `warning` and `error` paint a solid
167
+ * fill straight from --color-warning / --color-error, and a theme is free to
168
+ * tune those as ink for text rather than as a surface — the neutral theme does,
169
+ * so Overdue comes out a muddy maroon block. The hue variants read their own
170
+ * background/text pair, which is a tint in every theme, so a column of these
171
+ * stays legible wherever the page is themed. Red is left to carry the one state
172
+ * worth chasing.
173
+ *
174
+ * On hold is grey because nothing is wrong with it — the work is parked, not
175
+ * late — and a colour there would pull the eye off the row that is.
176
+ *
177
+ * The happy path walks the hue wheel in the direction the work travels:
178
+ * Scheduled blue, In progress teal, Completed green. Teal is the stop between
179
+ * the other two, so the ramp reads as progress rather than as three unrelated
180
+ * tags.
181
+ */
182
+ const STATUS_META: Record<
183
+ JobStatus,
184
+ {
185
+ label: string;
186
+ badge: 'neutral' | 'blue' | 'teal' | 'green' | 'red';
187
+ }
188
+ > = {
189
+ scheduled: {label: 'Scheduled', badge: 'blue'},
190
+ in_progress: {label: 'In progress', badge: 'teal'},
191
+ on_hold: {label: 'On hold', badge: 'neutral'},
192
+ overdue: {label: 'Overdue', badge: 'red'},
193
+ completed: {label: 'Completed', badge: 'green'},
194
+ };
195
+
196
+ /**
197
+ * Priority is a three-step ramp read down a column, which is what a dot is for:
198
+ * the eye picks out the reds without reading a word. Normal is neutral, so a
199
+ * table of ordinary work is quiet.
200
+ */
201
+ const PRIORITY_META: Record<
202
+ Priority,
203
+ {label: string; dot: 'neutral' | 'warning' | 'error'}
204
+ > = {
205
+ urgent: {label: 'Urgent', dot: 'error'},
206
+ high: {label: 'High', dot: 'warning'},
207
+ normal: {label: 'Normal', dot: 'neutral'},
208
+ };
209
+
210
+ /** Primary detail-pane action per state — keeps the panel feeling like a tool. */
211
+ const NEXT_ACTION: Record<JobStatus, string> = {
212
+ scheduled: 'Dispatch technician',
213
+ in_progress: 'Mark complete',
214
+ on_hold: 'Check parts order',
215
+ overdue: 'Reschedule',
216
+ completed: 'Create follow-up',
217
+ };
218
+
219
+ // Active work first, then recent completed jobs referenced as site history.
220
+ const seedJobs: SeedJob[] = [
221
+ {
222
+ id: 'SJ-2148',
223
+ summary: 'No cooling — rooftop unit 3',
224
+ customer: 'Harborview Grand Hotel',
225
+ address: '1200 Harbor Blvd, Bayside',
226
+ technician: 'Luis Camarena',
227
+ status: 'in_progress',
228
+ priority: 'urgent',
229
+ scheduledAt: '2026-07-02T08:00:00Z',
230
+ quoted: 1480,
231
+ equipment: 'Trane RTU-3, 25-ton (2019)',
232
+ },
233
+ {
234
+ id: 'SJ-2147',
235
+ summary: 'Quarterly preventive maintenance',
236
+ customer: 'Northgate Dental Group',
237
+ address: '88 Northgate Mall, Suite 210',
238
+ technician: 'Dana Whitfield',
239
+ status: 'scheduled',
240
+ priority: 'normal',
241
+ scheduledAt: '2026-07-02T10:30:00Z',
242
+ quoted: 420,
243
+ equipment: 'Carrier split system, 5-ton',
244
+ },
245
+ {
246
+ id: 'SJ-2146',
247
+ summary: 'Walk-in freezer overshooting setpoint',
248
+ customer: 'Beacon Street Bistro',
249
+ address: '412 Beacon St',
250
+ technician: 'Marcus Osei',
251
+ status: 'on_hold',
252
+ priority: 'high',
253
+ scheduledAt: '2026-07-01T13:00:00Z',
254
+ quoted: 2150,
255
+ equipment: 'Kolpak walk-in, Copeland compressor',
256
+ },
257
+ {
258
+ id: 'SJ-2145',
259
+ summary: 'Thermostat replacement, suite 400',
260
+ customer: 'Pinnacle Property Mgmt',
261
+ address: '500 Commerce Tower, Floor 4',
262
+ technician: 'Dana Whitfield',
263
+ status: 'scheduled',
264
+ priority: 'normal',
265
+ scheduledAt: '2026-07-02T14:00:00Z',
266
+ quoted: 310,
267
+ equipment: 'Honeywell T6 Pro (2 units)',
268
+ },
269
+ {
270
+ id: 'SJ-2144',
271
+ summary: 'Boiler pilot fails to stay lit',
272
+ customer: 'Elm & 5th Lofts',
273
+ address: '501 Elm St',
274
+ technician: 'Luis Camarena',
275
+ status: 'overdue',
276
+ priority: 'high',
277
+ scheduledAt: '2026-07-01T09:00:00Z',
278
+ quoted: 760,
279
+ equipment: 'Weil-McLain CGa boiler (2011)',
280
+ },
281
+ {
282
+ id: 'SJ-2143',
283
+ summary: 'Condenser coil cleaning',
284
+ customer: 'Riverside Athletic Club',
285
+ address: '9 Riverside Way',
286
+ technician: 'Priya Raman',
287
+ status: 'scheduled',
288
+ priority: 'normal',
289
+ scheduledAt: '2026-07-03T07:30:00Z',
290
+ quoted: 540,
291
+ equipment: 'Lennox RTU, 20-ton (2018)',
292
+ },
293
+ {
294
+ id: 'SJ-2142',
295
+ summary: 'Intermittent short cycling',
296
+ customer: 'Harborview Grand Hotel',
297
+ address: '1200 Harbor Blvd, Bayside',
298
+ technician: 'Marcus Osei',
299
+ status: 'on_hold',
300
+ priority: 'high',
301
+ scheduledAt: '2026-07-01T15:30:00Z',
302
+ quoted: 890,
303
+ equipment: 'Trane RTU-1, 25-ton (2019)',
304
+ },
305
+ {
306
+ id: 'SJ-2141',
307
+ summary: 'Emergency steam leak, basement',
308
+ customer: 'Elm & 5th Lofts',
309
+ address: '501 Elm St',
310
+ technician: 'Priya Raman',
311
+ status: 'in_progress',
312
+ priority: 'urgent',
313
+ scheduledAt: '2026-07-01T06:00:00Z',
314
+ quoted: 3200,
315
+ equipment: 'Weil-McLain CGa boiler (2011)',
316
+ },
317
+ {
318
+ id: 'SJ-2140',
319
+ summary: 'Annual fire damper inspection',
320
+ customer: 'Pinnacle Property Mgmt',
321
+ address: '500 Commerce Tower, Floor 4',
322
+ technician: 'Dana Whitfield',
323
+ status: 'scheduled',
324
+ priority: 'normal',
325
+ scheduledAt: '2026-07-06T09:00:00Z',
326
+ quoted: 1250,
327
+ equipment: 'Building-wide duct network',
328
+ },
329
+ {
330
+ id: 'SJ-2139',
331
+ summary: 'Ice machine not producing',
332
+ customer: 'Beacon Street Bistro',
333
+ address: '412 Beacon St',
334
+ technician: 'Luis Camarena',
335
+ status: 'overdue',
336
+ priority: 'urgent',
337
+ scheduledAt: '2026-06-30T11:00:00Z',
338
+ quoted: 680,
339
+ equipment: 'Hoshizaki KM-660, air cooled',
340
+ },
341
+ {
342
+ id: 'SJ-2138',
343
+ summary: 'Makeup air unit rebalancing',
344
+ customer: 'Northgate Dental Group',
345
+ address: '88 Northgate Mall, Suite 210',
346
+ technician: 'Priya Raman',
347
+ status: 'scheduled',
348
+ priority: 'normal',
349
+ scheduledAt: '2026-07-07T13:00:00Z',
350
+ quoted: 495,
351
+ equipment: 'Greenheck MAU, 4000 CFM',
352
+ },
353
+ {
354
+ id: 'SJ-2137',
355
+ summary: 'Chiller low refrigerant alarm',
356
+ customer: 'Summit Medical Plaza',
357
+ address: '77 Summit Ridge Rd',
358
+ technician: 'Marcus Osei',
359
+ status: 'in_progress',
360
+ priority: 'urgent',
361
+ scheduledAt: '2026-07-01T08:30:00Z',
362
+ quoted: 4100,
363
+ equipment: 'Daikin air-cooled chiller, 80-ton',
364
+ },
365
+ {
366
+ id: 'SJ-2136',
367
+ summary: 'VAV box actuator replacement',
368
+ customer: 'Summit Medical Plaza',
369
+ address: '77 Summit Ridge Rd',
370
+ technician: 'Dana Whitfield',
371
+ status: 'scheduled',
372
+ priority: 'high',
373
+ scheduledAt: '2026-07-03T10:00:00Z',
374
+ quoted: 720,
375
+ equipment: 'Titus VAV, zones 12–15',
376
+ },
377
+ {
378
+ id: 'SJ-2135',
379
+ summary: 'Exhaust fan bearing noise',
380
+ customer: 'Riverside Athletic Club',
381
+ address: '9 Riverside Way',
382
+ technician: 'Luis Camarena',
383
+ status: 'on_hold',
384
+ priority: 'normal',
385
+ scheduledAt: '2026-06-30T14:30:00Z',
386
+ quoted: 380,
387
+ equipment: 'Greenheck exhaust fan, pool deck',
388
+ },
389
+ {
390
+ id: 'SJ-2134',
391
+ summary: 'Humidifier control board fault',
392
+ customer: 'Summit Medical Plaza',
393
+ address: '77 Summit Ridge Rd',
394
+ technician: 'Priya Raman',
395
+ status: 'overdue',
396
+ priority: 'high',
397
+ scheduledAt: '2026-06-29T09:30:00Z',
398
+ quoted: 1340,
399
+ equipment: 'DriSteem steam humidifier',
400
+ },
401
+ {
402
+ id: 'SJ-2133',
403
+ summary: 'Rooftop economizer stuck closed',
404
+ customer: 'Northgate Dental Group',
405
+ address: '88 Northgate Mall, Suite 210',
406
+ technician: 'Marcus Osei',
407
+ status: 'scheduled',
408
+ priority: 'normal',
409
+ scheduledAt: '2026-07-08T08:00:00Z',
410
+ quoted: 610,
411
+ equipment: 'Carrier split system, 5-ton',
412
+ },
413
+ {
414
+ id: 'SJ-2132',
415
+ summary: 'Hot water recirculation pump seized',
416
+ customer: 'Elm & 5th Lofts',
417
+ address: '501 Elm St',
418
+ technician: 'Dana Whitfield',
419
+ status: 'in_progress',
420
+ priority: 'high',
421
+ scheduledAt: '2026-07-01T12:00:00Z',
422
+ quoted: 950,
423
+ equipment: 'Taco 007 circulator',
424
+ },
425
+ {
426
+ id: 'SJ-2131',
427
+ summary: 'Split system quote, new tenant build',
428
+ customer: 'Pinnacle Property Mgmt',
429
+ address: '500 Commerce Tower, Floor 4',
430
+ technician: 'Priya Raman',
431
+ status: 'scheduled',
432
+ priority: 'normal',
433
+ scheduledAt: '2026-07-09T11:00:00Z',
434
+ quoted: 8600,
435
+ equipment: 'Proposed: Mitsubishi VRF, 6 zones',
436
+ },
437
+ {
438
+ id: 'SJ-2101',
439
+ summary: 'Compressor contactor replacement',
440
+ customer: 'Harborview Grand Hotel',
441
+ address: '1200 Harbor Blvd, Bayside',
442
+ technician: 'Marcus Osei',
443
+ status: 'completed',
444
+ priority: 'high',
445
+ scheduledAt: '2026-06-24T09:00:00Z',
446
+ quoted: 640,
447
+ equipment: 'Trane RTU-3, 25-ton (2019)',
448
+ },
449
+ {
450
+ id: 'SJ-2095',
451
+ summary: 'Pool dehumidifier service',
452
+ customer: 'Riverside Athletic Club',
453
+ address: '9 Riverside Way',
454
+ technician: 'Dana Whitfield',
455
+ status: 'completed',
456
+ priority: 'normal',
457
+ scheduledAt: '2026-06-22T08:00:00Z',
458
+ quoted: 720,
459
+ equipment: 'Seresco NP series dehumidifier',
460
+ },
461
+ {
462
+ id: 'SJ-2088',
463
+ summary: 'Refrigerant leak diagnostic',
464
+ customer: 'Beacon Street Bistro',
465
+ address: '412 Beacon St',
466
+ technician: 'Luis Camarena',
467
+ status: 'completed',
468
+ priority: 'normal',
469
+ scheduledAt: '2026-06-18T11:00:00Z',
470
+ quoted: 390,
471
+ equipment: 'Kolpak walk-in, Copeland compressor',
472
+ },
473
+ {
474
+ id: 'SJ-2081',
475
+ summary: 'Chiller annual certification',
476
+ customer: 'Summit Medical Plaza',
477
+ address: '77 Summit Ridge Rd',
478
+ technician: 'Priya Raman',
479
+ status: 'completed',
480
+ priority: 'high',
481
+ scheduledAt: '2026-06-15T07:00:00Z',
482
+ quoted: 2450,
483
+ equipment: 'Daikin air-cooled chiller, 80-ton',
484
+ },
485
+ {
486
+ id: 'SJ-2067',
487
+ summary: 'RTU belt and filter service',
488
+ customer: 'Pinnacle Property Mgmt',
489
+ address: '500 Commerce Tower, Floor 4',
490
+ technician: 'Dana Whitfield',
491
+ status: 'completed',
492
+ priority: 'normal',
493
+ scheduledAt: '2026-06-12T08:30:00Z',
494
+ quoted: 280,
495
+ equipment: 'York RTU, 15-ton (2016)',
496
+ },
497
+ {
498
+ id: 'SJ-2044',
499
+ summary: 'Cooling tower fan bearing replacement',
500
+ customer: 'Harborview Grand Hotel',
501
+ address: '1200 Harbor Blvd, Bayside',
502
+ technician: 'Marcus Osei',
503
+ status: 'completed',
504
+ priority: 'high',
505
+ scheduledAt: '2026-05-28T07:30:00Z',
506
+ quoted: 1120,
507
+ equipment: 'BAC cooling tower, cell 2',
508
+ },
509
+ {
510
+ id: 'SJ-2019',
511
+ summary: 'Spring maintenance visit',
512
+ customer: 'Northgate Dental Group',
513
+ address: '88 Northgate Mall, Suite 210',
514
+ technician: 'Dana Whitfield',
515
+ status: 'completed',
516
+ priority: 'normal',
517
+ scheduledAt: '2026-04-15T10:00:00Z',
518
+ quoted: 420,
519
+ equipment: 'Carrier split system, 5-ton',
520
+ },
521
+ ];
522
+
523
+ const DAY_MS = 86_400_000;
524
+
525
+ /** Read once at load, so a preset's filter value stays referentially stable. */
526
+ const NOW_MS = Date.now();
527
+
528
+ /**
529
+ * The authored dates are fixed, so they slide into the past as this template
530
+ * ages and an "Upcoming" filter would match nothing. Shifting the whole set on
531
+ * load keeps the backlog straddling today, with the newest job about three
532
+ * weeks out.
533
+ */
534
+ const DATE_SHIFT_MS =
535
+ NOW_MS +
536
+ 21 * DAY_MS -
537
+ Math.max(...seedJobs.map(j => Date.parse(j.scheduledAt)));
538
+
539
+ /**
540
+ * A job in the past can't still be waiting to happen, and one from months ago
541
+ * isn't still under way, so an authored status only survives where the shifted
542
+ * date still supports it.
543
+ */
544
+ function statusForDate(authored: JobStatus, when: number): JobStatus {
545
+ if (when > NOW_MS) {
546
+ return 'scheduled';
547
+ }
548
+ // Recently missed work is still chaseable; anything older has been closed
549
+ // out one way or another.
550
+ const isRecent = when > NOW_MS - 45 * DAY_MS;
551
+ if (authored === 'scheduled') {
552
+ return isRecent ? 'overdue' : 'completed';
553
+ }
554
+ if (authored === 'in_progress' && !isRecent) {
555
+ return 'completed';
556
+ }
557
+ return authored;
558
+ }
559
+
560
+ /**
561
+ * The authored rows are cycled backwards through the calendar into a longer
562
+ * backlog, so the infinite scroll below has several batches to fetch. A real
563
+ * screen gets this from its Relay connection instead.
564
+ */
565
+ const allJobs: ServiceJob[] = Array.from({length: 5}, (_, cycle) =>
566
+ seedJobs.map((job, index) => {
567
+ // The per-row term decorrelates the cycles, so repeat visits to the same
568
+ // site spread across the calendar instead of stacking up next to each
569
+ // other once the table sorts by date.
570
+ const cycleDays = cycle === 0 ? 0 : cycle * 37 + ((index * 13) % 29);
571
+ const when =
572
+ Date.parse(job.scheduledAt) + DATE_SHIFT_MS - cycleDays * DAY_MS;
573
+ const scheduledOn = new Date(when);
574
+ return {
575
+ ...job,
576
+ id:
577
+ cycle === 0
578
+ ? job.id
579
+ : `SJ-${1999 - (cycle - 1) * seedJobs.length - index}`,
580
+ status: statusForDate(job.status, when),
581
+ scheduledAt: scheduledOn.toISOString(),
582
+ scheduledOn,
583
+ quoted: cycle === 0 ? job.quoted : job.quoted + ((index * 17) % 9) * 10,
584
+ };
585
+ }),
586
+ ).flat();
587
+
588
+ const TECHNICIANS = Array.from(new Set(allJobs.map(j => j.technician))).sort();
589
+ const CUSTOMERS = Array.from(new Set(allJobs.map(j => j.customer))).sort();
590
+
591
+ const MONTHS = [
592
+ 'Jan',
593
+ 'Feb',
594
+ 'Mar',
595
+ 'Apr',
596
+ 'May',
597
+ 'Jun',
598
+ 'Jul',
599
+ 'Aug',
600
+ 'Sep',
601
+ 'Oct',
602
+ 'Nov',
603
+ 'Dec',
604
+ ];
605
+
606
+ /**
607
+ * UTC-based formatters, so the fixed fixtures render identically everywhere.
608
+ * A locale-aware Timestamp would shift these by the viewer's offset and then
609
+ * disagree with the table column beside it.
610
+ */
611
+ function formatTime(iso: string): string {
612
+ const d = new Date(iso);
613
+ const hours = d.getUTCHours();
614
+ const minutes = String(d.getUTCMinutes()).padStart(2, '0');
615
+ const suffix = hours < 12 ? 'am' : 'pm';
616
+ const hour12 = hours % 12 === 0 ? 12 : hours % 12;
617
+ return `${hour12}:${minutes}${suffix}`;
618
+ }
619
+
620
+ function formatDate(iso: string, withYear = false): string {
621
+ const d = new Date(iso);
622
+ const base = `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;
623
+ return withYear ? `${base}, ${d.getUTCFullYear()}` : base;
624
+ }
625
+
626
+ const formatScheduled = (iso: string) =>
627
+ `${formatDate(iso)}, ${formatTime(iso)}`;
628
+
629
+ const formatMoney = (amount: number) => `$${amount.toLocaleString('en-US')}`;
630
+
631
+ // =============================================================================
632
+ // Filter configuration
633
+ //
634
+ // One config backs both the token list and power search, so the two are always
635
+ // describing the same filter array.
636
+ // =============================================================================
637
+
638
+ const fieldDefs = [
639
+ {key: 'summary', type: 'string', label: 'Summary'},
640
+ {
641
+ key: 'customer',
642
+ type: 'enum',
643
+ label: 'Customer',
644
+ enumValues: CUSTOMERS.map(v => ({value: v, label: v})),
645
+ },
646
+ {
647
+ key: 'technician',
648
+ type: 'enum',
649
+ label: 'Technician',
650
+ enumValues: TECHNICIANS.map(v => ({value: v, label: v})),
651
+ },
652
+ {
653
+ key: 'status',
654
+ type: 'enum',
655
+ label: 'Status',
656
+ enumValues: (Object.keys(STATUS_META) as JobStatus[]).map(v => ({
657
+ value: v,
658
+ label: STATUS_META[v].label,
659
+ })),
660
+ },
661
+ {
662
+ key: 'priority',
663
+ type: 'enum',
664
+ label: 'Priority',
665
+ enumValues: (Object.keys(PRIORITY_META) as Priority[]).map(v => ({
666
+ value: v,
667
+ label: PRIORITY_META[v].label,
668
+ })),
669
+ },
670
+ {key: 'quoted', type: 'number', label: 'Quote'},
671
+ {key: 'scheduledOn', type: 'date', label: 'Scheduled'},
672
+ ] as const;
673
+
674
+ /** Bounds for the quote range slider, snapped outward to round money. */
675
+ const QUOTE_MIN = 0;
676
+ const QUOTE_MAX =
677
+ Math.ceil(Math.max(...allJobs.map(j => j.quoted)) / 500) * 500;
678
+ const QUOTE_STEP = 100;
679
+
680
+ /**
681
+ * A single-value field in the filter bar, rendered as a Button-triggered
682
+ * dropdown so the closed trigger doubles as the chip. Quote sits outside this
683
+ * list — it is a range, not a pick — and so do the many-of fields below.
684
+ */
685
+ interface FilterField {
686
+ key: string;
687
+ label: string;
688
+ operator: string;
689
+ operatorLabel: string;
690
+ /** Filter value type handed to power search, so both modes agree. */
691
+ valueType: 'enum' | 'integer';
692
+ options: ReadonlyArray<{value: string; label: string}>;
693
+ }
694
+
695
+ const FILTER_FIELDS: readonly FilterField[] = [
696
+ {
697
+ key: 'priority',
698
+ label: 'Priority',
699
+ operator: 'is',
700
+ operatorLabel: 'is',
701
+ valueType: 'enum',
702
+ options: (Object.keys(PRIORITY_META) as Priority[]).map(v => ({
703
+ value: v,
704
+ label: PRIORITY_META[v].label,
705
+ })),
706
+ },
707
+ ];
708
+
709
+ /**
710
+ * Fields a reader narrows by picking several at once — "show me on hold and
711
+ * overdue", "these two crews". Priority stays single because its three steps
712
+ * are a ramp: picking two of three is the same as excluding one, which the
713
+ * table already shows.
714
+ *
715
+ * `is_any_of` is what lets a field hold more than one value. applyFilters ANDs
716
+ * same-field clauses, so a second `is` would contradict the first rather than
717
+ * widen it.
718
+ */
719
+ const MULTI_FILTER_FIELDS: ReadonlyArray<{
720
+ key: string;
721
+ label: string;
722
+ options: ReadonlyArray<{value: string; label: string}>;
723
+ }> = [
724
+ {
725
+ key: 'status',
726
+ label: 'Status',
727
+ options: (Object.keys(STATUS_META) as JobStatus[]).map(v => ({
728
+ value: v,
729
+ label: STATUS_META[v].label,
730
+ })),
731
+ },
732
+ {
733
+ key: 'customer',
734
+ label: 'Customer',
735
+ options: CUSTOMERS.map(v => ({value: v, label: v})),
736
+ },
737
+ {
738
+ key: 'technician',
739
+ label: 'Technician',
740
+ options: TECHNICIANS.map(v => ({value: v, label: v})),
741
+ },
742
+ ];
743
+
744
+ /**
745
+ * A canned filter offered as a one-click toggle, with field, operator and value
746
+ * all fixed up front. Each preset owns a field no other control in the bar
747
+ * touches — a preset on status or priority would fight the dropdown that also
748
+ * writes that field, since one clause per field is all either can express.
749
+ */
750
+ const PRESET_FILTERS: ReadonlyArray<{
751
+ key: string;
752
+ label: string;
753
+ filter: PowerSearchFilter;
754
+ }> = [
755
+ {
756
+ key: 'upcoming',
757
+ label: 'Upcoming',
758
+ filter: {
759
+ field: 'scheduledOn',
760
+ operator: 'after',
761
+ value: {type: 'date_absolute', unixSeconds: Math.floor(NOW_MS / 1000)},
762
+ },
763
+ },
764
+ ];
765
+
766
+ /** Human-readable value for a filter chip label. */
767
+ const VALUE_LABELS: Record<string, string> = {
768
+ ...Object.fromEntries(
769
+ (Object.keys(STATUS_META) as JobStatus[]).map(k => [
770
+ k,
771
+ STATUS_META[k].label,
772
+ ]),
773
+ ),
774
+ ...Object.fromEntries(
775
+ (Object.keys(PRIORITY_META) as Priority[]).map(k => [
776
+ k,
777
+ PRIORITY_META[k].label,
778
+ ]),
779
+ ),
780
+ };
781
+
782
+ // =============================================================================
783
+ // Bulk edit actions
784
+ // =============================================================================
785
+
786
+ const BULK_EDIT_ACTIONS: ReadonlyArray<{
787
+ key: string;
788
+ label: string;
789
+ icon: React.ReactNode;
790
+ onClick: (selected: ServiceJob[]) => void;
791
+ }> = [
792
+ {
793
+ key: 'assign',
794
+ label: 'Assign',
795
+ icon: <Icon icon={UserPlus} size="sm" />,
796
+ onClick: s => alert(`Assign technician: ${s.map(j => j.id).join(', ')}`),
797
+ },
798
+ {
799
+ key: 'reschedule',
800
+ label: 'Reschedule',
801
+ icon: <Icon icon={Calendar} size="sm" />,
802
+ onClick: s => alert(`Reschedule: ${s.map(j => j.id).join(', ')}`),
803
+ },
804
+ {
805
+ key: 'cancel',
806
+ label: 'Cancel',
807
+ icon: <Icon icon={CircleX} size="sm" />,
808
+ onClick: s => alert(`Cancel: ${s.map(j => j.id).join(', ')}`),
809
+ },
810
+ ];
811
+
812
+ // =============================================================================
813
+ // View options
814
+ // =============================================================================
815
+
816
+ type ViewSection = 'columns' | 'density' | 'sticky' | 'grouping';
817
+ type Density = 'compact' | 'balanced' | 'spacious';
818
+ type StickyEdge = 'none' | 'one' | 'two';
819
+ /** Field the rows section under. 'none' leaves the table flat. */
820
+ type GroupField = 'none' | 'status' | 'priority' | 'technician' | 'customer';
821
+
822
+ const VIEW_SECTIONS: ReadonlyArray<{
823
+ key: ViewSection;
824
+ label: string;
825
+ icon: React.ReactNode;
826
+ title: string;
827
+ }> = [
828
+ {
829
+ key: 'columns',
830
+ label: 'Columns',
831
+ icon: <Icon icon={Columns3} size="sm" />,
832
+ title: 'Columns',
833
+ },
834
+ {
835
+ key: 'density',
836
+ label: 'Density',
837
+ icon: <Icon icon={TableIcon} size="sm" />,
838
+ title: 'Density',
839
+ },
840
+ {
841
+ key: 'sticky',
842
+ label: 'Sticky Columns',
843
+ icon: <Icon icon={Pin} size="sm" />,
844
+ title: 'Sticky Columns',
845
+ },
846
+ {
847
+ key: 'grouping',
848
+ label: 'Grouping',
849
+ icon: <Icon icon={Table2} size="sm" />,
850
+ title: 'Grouping',
851
+ },
852
+ ];
853
+
854
+ const DENSITY_OPTIONS: ReadonlyArray<{value: Density; label: string}> = [
855
+ {value: 'compact', label: 'Compact'},
856
+ {value: 'balanced', label: 'Balanced'},
857
+ {value: 'spacious', label: 'Spacious'},
858
+ ];
859
+
860
+ /**
861
+ * Split by edge, because an edge names its own columns — "First two columns"
862
+ * against the start, "Last two columns" against the end. Naming them here is
863
+ * what lets the radio list and a saved view's summary state a frozen edge in
864
+ * the same words.
865
+ */
866
+ const STICKY_START_OPTIONS: ReadonlyArray<{
867
+ value: StickyEdge;
868
+ label: string;
869
+ }> = [
870
+ {value: 'none', label: 'None'},
871
+ {value: 'one', label: 'First column'},
872
+ {value: 'two', label: 'First two columns'},
873
+ ];
874
+
875
+ const STICKY_END_OPTIONS: ReadonlyArray<{value: StickyEdge; label: string}> = [
876
+ {value: 'none', label: 'None'},
877
+ {value: 'one', label: 'Last column'},
878
+ {value: 'two', label: 'Last two columns'},
879
+ ];
880
+
881
+ const GROUPING_OPTIONS: ReadonlyArray<{value: GroupField; label: string}> = [
882
+ {value: 'none', label: 'None'},
883
+ {value: 'status', label: 'Status'},
884
+ {value: 'priority', label: 'Priority'},
885
+ {value: 'technician', label: 'Technician'},
886
+ {value: 'customer', label: 'Customer'},
887
+ ];
888
+
889
+ /**
890
+ * The words a section is titled with. The group key is what the heading prints,
891
+ * so it has to be the label and not the stored value — grouping by status would
892
+ * otherwise head its sections "on_hold". Technician and customer are stored as
893
+ * their own labels already, which is what the fallback covers.
894
+ */
895
+ function groupKeyOf(job: ServiceJob, field: GroupField): string {
896
+ if (field === 'none') {
897
+ return '';
898
+ }
899
+ const stored = String(job[field]);
900
+ return VALUE_LABELS[stored] ?? stored;
901
+ }
902
+
903
+ /**
904
+ * Section order per field. Without it the sections would come out in whatever
905
+ * order the active sort happened to surface them, so status and priority take
906
+ * their canonical severity order and the two name fields stay alphabetical.
907
+ *
908
+ * Listed as labels, because that is what groupKeyOf produces and these have to
909
+ * match it to rank anything.
910
+ */
911
+ const GROUP_ORDERS: Record<GroupField, string[]> = {
912
+ none: [],
913
+ status: Object.keys(STATUS_META).map(k => VALUE_LABELS[k] ?? k),
914
+ priority: Object.keys(PRIORITY_META).map(k => VALUE_LABELS[k] ?? k),
915
+ technician: TECHNICIANS,
916
+ customer: CUSTOMERS,
917
+ };
918
+
919
+ /** Row-key prefix the grouped-rows plugin gives its synthetic header rows. */
920
+ const GROUP_ROW_KEY_PREFIX = '__group_';
921
+
922
+ /** Fed to the grouping hook while grouping is off, so it flattens nothing. */
923
+ const NO_ROWS: ServiceJob[] = [];
924
+
925
+ const COLUMN_LABELS: Record<string, string> = {
926
+ summary: 'Job',
927
+ customer: 'Customer',
928
+ technician: 'Technician',
929
+ status: 'Status',
930
+ priority: 'Priority',
931
+ scheduledAt: 'Scheduled',
932
+ quoted: 'Quote',
933
+ equipment: 'Equipment',
934
+ address: 'Address',
935
+ };
936
+
937
+ const ALL_COLUMN_KEYS = [
938
+ 'summary',
939
+ 'customer',
940
+ 'technician',
941
+ 'status',
942
+ 'priority',
943
+ 'scheduledAt',
944
+ 'quoted',
945
+ 'equipment',
946
+ 'address',
947
+ ];
948
+ const DEFAULT_COLUMN_KEYS = [
949
+ 'summary',
950
+ 'customer',
951
+ 'technician',
952
+ 'status',
953
+ 'priority',
954
+ 'scheduledAt',
955
+ 'quoted',
956
+ ];
957
+
958
+ /**
959
+ * The column that names the row. A table of jobs with no job on it is a table
960
+ * of orphaned attributes, so this one stays: its remove control is disabled and
961
+ * says why. Position is not identity, so it still reorders like any other.
962
+ */
963
+ const LOCKED_COLUMN_KEY = 'summary';
964
+ const LOCKED_COLUMN_MESSAGE = 'Job names every row, so it cannot be removed.';
965
+
966
+ /** Pointer travel before a press on the grip becomes a drag rather than a tap. */
967
+ const REORDER_DRAG_THRESHOLD = 5;
968
+
969
+ /**
970
+ * A reorder in flight. Positions are indices into the *visible* displayed list
971
+ * — the one the reader is looking at — so a search that hides rows cannot make
972
+ * the arrow keys or the drop indicator point somewhere off screen.
973
+ */
974
+ interface ColumnReorderSession {
975
+ key: string;
976
+ mode: 'keyboard' | 'pointer';
977
+ /** Visible order as it was when the drag began; the drag reads from this. */
978
+ originalKeys: string[];
979
+ fromIndex: number;
980
+ toIndex: number;
981
+ pointerId?: number;
982
+ pointerStartY?: number;
983
+ hasPointerMoved?: boolean;
984
+ }
985
+
986
+ /** Draft of every view option, so the panel can Cancel without touching the table. */
987
+ interface ViewState {
988
+ columnKeys: string[];
989
+ density: Density;
990
+ stickyStart: StickyEdge;
991
+ stickyEnd: StickyEdge;
992
+ grouping: GroupField;
993
+ }
994
+
995
+ const INITIAL_VIEW: ViewState = {
996
+ columnKeys: DEFAULT_COLUMN_KEYS,
997
+ density: 'balanced',
998
+ stickyStart: 'one',
999
+ stickyEnd: 'none',
1000
+ grouping: 'customer',
1001
+ };
1002
+
1003
+ /**
1004
+ * The screen opens on open work — every status except Completed. Landing on a
1005
+ * list of closed jobs is a poor first impression, and an unfiltered table
1006
+ * shows the filter bar in its resting state, which hides the chip, the result
1007
+ * count and Clear all behind a click. The saved-view baseline is the same set,
1008
+ * so the template arrives already demonstrating what it is for.
1009
+ */
1010
+ const INITIAL_FILTERS: PowerSearchFilter[] = [
1011
+ {
1012
+ field: 'status',
1013
+ operator: 'is_any_of',
1014
+ value: {
1015
+ type: 'enum_list',
1016
+ value: (Object.keys(STATUS_META) as JobStatus[]).filter(
1017
+ status => status !== 'completed',
1018
+ ),
1019
+ },
1020
+ },
1021
+ ];
1022
+
1023
+ /**
1024
+ * Severity order for the enum columns, because their labels do not sort into
1025
+ * it. Alphabetically Priority runs High, Normal, Urgent — which files the rows
1026
+ * that matter in the middle. Rank is what a reader means by "sort by
1027
+ * priority", so the comparator reaches for this before falling back to a
1028
+ * string compare.
1029
+ */
1030
+ const SORT_RANKS: Record<string, Record<string, number>> = {
1031
+ priority: {urgent: 0, high: 1, normal: 2},
1032
+ status: {overdue: 0, on_hold: 1, in_progress: 2, scheduled: 3, completed: 4},
1033
+ };
1034
+
1035
+ const PAGE_SIZE = 15;
1036
+
1037
+ /** Key the selection plugin gives the checkbox column it prepends. */
1038
+ const SELECTION_COLUMN_KEY = '__xds_selection';
1039
+
1040
+ // =============================================================================
1041
+ // Saved views
1042
+ //
1043
+ // Declared after ViewState so a saved view can carry one. A view is the whole
1044
+ // configuration of the screen, not just its filters: the columns on show and
1045
+ // their order, plus density, sticky edges and grouping. That is what makes a
1046
+ // column preset redundant — the columns travel with the view that named them.
1047
+ // =============================================================================
1048
+
1049
+ interface SavedView {
1050
+ id: string;
1051
+ name: string;
1052
+ filters: PowerSearchFilter[];
1053
+ /** Columns, density, sticky edges and grouping as they were when saved. */
1054
+ view: ViewState;
1055
+ }
1056
+
1057
+ const INITIAL_SAVED_VIEWS: SavedView[] = [
1058
+ {
1059
+ id: 'needs-dispatch',
1060
+ name: 'Needs dispatch',
1061
+ filters: [
1062
+ {
1063
+ field: 'status',
1064
+ operator: 'is',
1065
+ value: {type: 'enum', value: 'scheduled'},
1066
+ },
1067
+ ],
1068
+ view: INITIAL_VIEW,
1069
+ },
1070
+ {
1071
+ id: 'luis-open',
1072
+ name: "Luis's jobs",
1073
+ filters: [
1074
+ {
1075
+ field: 'technician',
1076
+ operator: 'is',
1077
+ value: {type: 'enum', value: 'Luis Camarena'},
1078
+ },
1079
+ ],
1080
+ // Technician is redundant once every row is Luis, and the shape of the day
1081
+ // is what this view is for, so it drops that column and groups by status.
1082
+ view: {
1083
+ ...INITIAL_VIEW,
1084
+ columnKeys: DEFAULT_COLUMN_KEYS.filter(k => k !== 'technician'),
1085
+ grouping: 'status',
1086
+ },
1087
+ },
1088
+ {
1089
+ id: 'high-value',
1090
+ name: 'High value',
1091
+ filters: [
1092
+ {
1093
+ // Same operator the range slider writes, so loading this view moves
1094
+ // the slider's low thumb instead of leaving it looking untouched.
1095
+ field: 'quoted',
1096
+ operator: 'greater_than_or_equal',
1097
+ value: {type: 'integer', value: 1000},
1098
+ },
1099
+ ],
1100
+ // Money is the point here, so the quote column is pinned to the end edge
1101
+ // and the rows tighten up to get more of them under the eye at once.
1102
+ view: {...INITIAL_VIEW, density: 'compact', stickyEnd: 'one'},
1103
+ },
1104
+ ];
1105
+
1106
+ // =============================================================================
1107
+ // Helpers
1108
+ // =============================================================================
1109
+
1110
+ /**
1111
+ * Stands in for Relay's `usePaginationFragment` so the table can grow by
1112
+ * batches instead of flipping pages. It returns the same
1113
+ * `{data, hasNext, isLoadingNext, loadNext}` shape a Relay connection does, so
1114
+ * a real screen swaps this line for `usePaginationFragment(QUERY, query)` and
1115
+ * keeps everything below unchanged.
1116
+ */
1117
+ function useInfiniteBatches<T>(
1118
+ items: readonly T[],
1119
+ pageSize: number,
1120
+ getGroupKey?: ((item: T) => string) | null,
1121
+ ): {
1122
+ data: T[];
1123
+ hasNext: boolean;
1124
+ isLoadingNext: boolean;
1125
+ loadNext: (count: number) => void;
1126
+ } {
1127
+ const [loadedCount, setLoadedCount] = useState(pageSize);
1128
+ const [isLoadingNext, setIsLoadingNext] = useState(false);
1129
+ const inFlightRef = useRef(false);
1130
+
1131
+ // A new filter/sort result is a new connection: start from the first batch.
1132
+ useEffect(() => {
1133
+ inFlightRef.current = false;
1134
+ setIsLoadingNext(false);
1135
+ setLoadedCount(pageSize);
1136
+ }, [items, pageSize]);
1137
+
1138
+ // A cut that lands mid-section leaves that section half-filled, and a reader
1139
+ // has no way to tell one that is still arriving from one that is genuinely
1140
+ // short — so the rows they can see are not a section they can trust. Carry
1141
+ // the cut to the end of whichever section it lands in. Sections are
1142
+ // contiguous because `results` sorts by group first, so this walks the tail
1143
+ // of one section and stops.
1144
+ const visibleCount = useMemo(() => {
1145
+ if (loadedCount >= items.length) {
1146
+ return items.length;
1147
+ }
1148
+ if (getGroupKey == null) {
1149
+ return loadedCount;
1150
+ }
1151
+ const openKey = getGroupKey(items[loadedCount - 1]);
1152
+ let end = loadedCount;
1153
+ while (end < items.length && getGroupKey(items[end]) === openKey) {
1154
+ end++;
1155
+ }
1156
+ return end;
1157
+ }, [getGroupKey, items, loadedCount]);
1158
+
1159
+ // The next batch counts from what is on screen, not from the raw cut, so
1160
+ // rows a snap already pulled in are not paid for twice. Held in a ref to
1161
+ // keep loadNext stable — it is an effect dependency of the sentinel.
1162
+ const visibleCountRef = useRef(visibleCount);
1163
+ visibleCountRef.current = visibleCount;
1164
+
1165
+ const loadNext = useCallback((count: number) => {
1166
+ if (inFlightRef.current) {
1167
+ return;
1168
+ }
1169
+ inFlightRef.current = true;
1170
+ setIsLoadingNext(true);
1171
+ // Stands in for the network round trip Relay would make here.
1172
+ setTimeout(() => {
1173
+ setLoadedCount(visibleCountRef.current + count);
1174
+ setIsLoadingNext(false);
1175
+ inFlightRef.current = false;
1176
+ }, 500);
1177
+ }, []);
1178
+
1179
+ const data = useMemo(
1180
+ () => items.slice(0, visibleCount),
1181
+ [items, visibleCount],
1182
+ );
1183
+
1184
+ return {data, hasNext: visibleCount < items.length, isLoadingNext, loadNext};
1185
+ }
1186
+
1187
+ /**
1188
+ * Each value is looked up before the join, not after: a many-of clause carries
1189
+ * a list of enum keys, and joining first would hand VALUE_LABELS the string
1190
+ * "on_hold, overdue", miss, and print the wire names.
1191
+ */
1192
+ function filterValueText(filter: PowerSearchFilter): string {
1193
+ const raw = (filter.value as {value?: unknown}).value;
1194
+ const label = (v: unknown) => {
1195
+ const key = String(v ?? '');
1196
+ return VALUE_LABELS[key] ?? key;
1197
+ };
1198
+ return Array.isArray(raw) ? raw.map(label).join(', ') : label(raw);
1199
+ }
1200
+
1201
+ /** Reads as the operator does in a power search token, not as its wire name. */
1202
+ const OPERATOR_LABELS: Record<string, string> = {
1203
+ is: 'is',
1204
+ is_any_of: 'is any of',
1205
+ greater_than_or_equal: '≥',
1206
+ less_than_or_equal: '≤',
1207
+ after: 'after',
1208
+ };
1209
+
1210
+ /**
1211
+ * One clause phrased the way power search phrases it, so a saved view's filters
1212
+ * read the same wherever they are shown. Money is formatted per field rather
1213
+ * than by sniffing the number, since a bare 1000 under Quote and under a count
1214
+ * are different things.
1215
+ */
1216
+ function filterTokenLabel(filter: PowerSearchFilter): string {
1217
+ const field = fieldDefs.find(f => f.key === filter.field);
1218
+ const name = field?.label ?? filter.field;
1219
+ const operator = OPERATOR_LABELS[filter.operator] ?? filter.operator;
1220
+ const raw = filter.value as {value?: unknown; unixSeconds?: number};
1221
+ let value: string;
1222
+ if (typeof raw.unixSeconds === 'number') {
1223
+ // A date clause carries unixSeconds rather than `value`, so it would
1224
+ // otherwise render as a bare "Scheduled after" with nothing after it.
1225
+ value = formatDate(new Date(raw.unixSeconds * 1000).toISOString(), true);
1226
+ } else if (Array.isArray(raw.value) && raw.value.length > 1) {
1227
+ // Spelling out every selection makes a token wider than the dialog, and a
1228
+ // token that ellipsises mid-name is worse than an honest count.
1229
+ const [first, ...rest] = raw.value.map(v => VALUE_LABELS[String(v)] ?? v);
1230
+ value = `${first} +${rest.length}`;
1231
+ } else if (field?.type === 'number' && typeof raw.value === 'number') {
1232
+ value = formatMoney(raw.value);
1233
+ } else {
1234
+ value = filterValueText(filter);
1235
+ }
1236
+ return `${name} ${operator} ${value}`;
1237
+ }
1238
+
1239
+ const stickyKeys = (
1240
+ edge: StickyEdge,
1241
+ keys: string[],
1242
+ fromEnd: boolean,
1243
+ ): string[] => {
1244
+ const count = edge === 'one' ? 1 : edge === 'two' ? 2 : 0;
1245
+ if (count === 0) {
1246
+ return [];
1247
+ }
1248
+ return fromEnd ? keys.slice(-count) : keys.slice(0, count);
1249
+ };
1250
+
1251
+ const styles = stylex.create({
1252
+ // height="fill" resolves to height:100%, which needs a definite height the
1253
+ // host's <html>/<body> don't set. Without it the shell grows to fit the
1254
+ // table, the document scrolls, and the end panel rides away with it.
1255
+ pageShell: {
1256
+ height: '100dvh',
1257
+ // Layout is transparent by design, so without this the shell inherits
1258
+ // whatever the host paints. That reads fine on a bare UA canvas but puts
1259
+ // dark-mode text on a light page the moment the host has a background of
1260
+ // its own. Owning the surface makes the page legible wherever it is
1261
+ // embedded.
1262
+ backgroundColor: colorVars['--color-background-surface'],
1263
+ // A pinned cell paints an opaque base so scrolled content cannot show
1264
+ // through it, and that base defaults to the card token — right for a table
1265
+ // inside a card, wrong for this one, which sits on the page surface. Both
1266
+ // tokens are the same white in light mode, so the mismatch only surfaces
1267
+ // in dark, as a darker band exactly where the frozen columns are.
1268
+ '--table-sticky-background': colorVars['--color-background-surface'],
1269
+ },
1270
+ // A wrapped row's `gap` sets both axes, and 8px between stacked lines is more
1271
+ // air than these need; the row gap is tightened back to 6 on its own.
1272
+ bar: {
1273
+ rowGap: 6,
1274
+ },
1275
+ // Wraps rather than collapsing (see the note in filterBar): clauses that no
1276
+ // longer fit move to a second line, keeping the count beside the last one.
1277
+ filterRow: {
1278
+ rowGap: 6,
1279
+ },
1280
+ // Queried rather than @media: opening the detail panel narrows this header
1281
+ // without the viewport changing, and that is the width the toolbar actually
1282
+ // has to fit in.
1283
+ toolbarContainer: {
1284
+ containerName: 'toolbar',
1285
+ containerType: 'inline-size',
1286
+ },
1287
+ // Taking the full line forces the trailing cluster onto a second row. Below
1288
+ // this the filter row and the view controls were splitting the width so
1289
+ // evenly that neither had enough: the filters were collapsing into overflow
1290
+ // while the buttons still had room to spare.
1291
+ toolbarPrimary: {
1292
+ flexBasis: {
1293
+ default: 0,
1294
+ '@container toolbar (max-width: 860px)': '100%',
1295
+ },
1296
+ },
1297
+ // Holds the cluster against the end edge on its own row, where it would
1298
+ // otherwise sit under the filters at the start.
1299
+ toolbarEnd: {
1300
+ marginInlineStart: 'auto',
1301
+ },
1302
+ filterMeta: {
1303
+ flexShrink: 0,
1304
+ },
1305
+ // A saved view can carry more clauses than the dialog is wide.
1306
+ tokenWrap: {
1307
+ flexWrap: 'wrap',
1308
+ rowGap: 4,
1309
+ },
1310
+ // Token ellipsises on overflow, but only once it is allowed to shrink below
1311
+ // its content; without this a long clause runs past the dialog edge instead.
1312
+ filterToken: {
1313
+ minWidth: 0,
1314
+ maxWidth: '100%',
1315
+ },
1316
+ progress: {
1317
+ position: 'absolute',
1318
+ insetBlockStart: 0,
1319
+ insetInline: 0,
1320
+ },
1321
+ headerWrap: {
1322
+ position: 'relative',
1323
+ },
1324
+ // The bulk bar now sits inside the header's 16px gutter, so it needs a
1325
+ // corner radius to read as a band rather than a clipped full-bleed strip.
1326
+ bulkBand: {
1327
+ borderRadius: radiusVars['--radius-element'],
1328
+ },
1329
+ // The bar mounts the moment the first row is checked, so its entry is a
1330
+ // `@starting-style` transition rather than a keyframe or a mount flag: the
1331
+ // settled value is the one written here, so an interrupted transition still
1332
+ // lands correctly. Reduced motion collapses the duration instead of the
1333
+ // property — a media query can't nest inside `@starting-style`, so the
1334
+ // duration is what gets conditioned (same form as ToastViewport/Markdown).
1335
+ bulkBandEnter: {
1336
+ opacity: 1,
1337
+ transform: 'translateY(0)',
1338
+ transitionProperty: 'opacity, transform',
1339
+ transitionDuration: {
1340
+ default: durationVars['--duration-medium'],
1341
+ '@media (prefers-reduced-motion: reduce)': '0s',
1342
+ },
1343
+ transitionTimingFunction: easeVars['--ease-standard'],
1344
+ '@starting-style': {
1345
+ opacity: 0,
1346
+ transform: `translateY(${spacingVars['--spacing-3']})`,
1347
+ },
1348
+ },
1349
+ // Sits at the width of the filter selectors beside it so the row reads as
1350
+ // one family of controls, and widens on focus for typing room. `xstyle`
1351
+ // lands on the input's own wrapper, so `:focus-within` is already in scope
1352
+ // and no React state is needed. TextInput's `width` prop is the sanctioned
1353
+ // way to size a field, but it takes one static value and cannot express a
1354
+ // focus variant, so the width lives here instead.
1355
+ searchInput: {
1356
+ width: {
1357
+ default: 120,
1358
+ ':focus-within': 240,
1359
+ },
1360
+ transitionProperty: 'width',
1361
+ transitionDuration: {
1362
+ default: durationVars['--duration-fast'],
1363
+ '@media (prefers-reduced-motion: reduce)': '0s',
1364
+ },
1365
+ transitionTimingFunction: easeVars['--ease-standard'],
1366
+ },
1367
+ // ButtonVariantMap has no outline variant, so the Button-backed toggles in
1368
+ // the row paint the field chrome the selectors beside them draw natively —
1369
+ // same border token, same radius — rather than reading as a different
1370
+ // family of control.
1371
+ filterChrome: {
1372
+ borderWidth: borderVars['--border-width'],
1373
+ borderStyle: 'solid',
1374
+ borderColor: colorVars['--color-border-emphasized'],
1375
+ borderRadius: radiusVars['--radius-element'],
1376
+ },
1377
+ filterSurface: {
1378
+ backgroundColor: colorVars['--color-background-surface'],
1379
+ },
1380
+ // The fill a control switches to once it carries a value. This is the token
1381
+ // ToggleButton paints for its own pressed state, so a set selector and a
1382
+ // pressed preset land on the same value over the same backdrop instead of
1383
+ // two neighbouring shades of engaged.
1384
+ filterFill: {
1385
+ backgroundColor: colorVars['--color-overlay-pressed'],
1386
+ },
1387
+ // ComplexSelector's popup only floors its width at the trigger's, and the
1388
+ // Quote trigger is as narrow as its label. A range slider needs a track long
1389
+ // enough to resolve $100 steps under the thumb, so the popup states one.
1390
+ quotePopover: {
1391
+ width: 300,
1392
+ },
1393
+ // A Selector states its own emptiness typographically — placeholder weight
1394
+ // and secondary text unset, body weight and primary text once it holds a
1395
+ // value. Button labels are medium and always primary, so without this the
1396
+ // unpressed toggles sit heavier and darker than the unset selectors they
1397
+ // are meant to match.
1398
+ filterLabelValue: {
1399
+ fontWeight: fontWeightVars['--font-weight-normal'],
1400
+ },
1401
+ filterLabelEmpty: {
1402
+ fontWeight: fontWeightVars['--font-weight-normal'],
1403
+ color: colorVars['--color-text-secondary'],
1404
+ },
1405
+ // Popover paints a 12px gutter on its content container and publishes no
1406
+ // padding prop, so the only way to a flush container is xstyle. It lands on
1407
+ // that same container, and these are the exact longhands Popover sets — a
1408
+ // `padding` shorthand would compile to the physical properties and leave the
1409
+ // logical ones standing. Flush, the rail's divider and the footer rule reach
1410
+ // the popover edges, and each region below re-states its own gutter.
1411
+ viewPopoverSurface: {
1412
+ paddingBlockStart: 0,
1413
+ paddingBlockEnd: 0,
1414
+ paddingInlineStart: 0,
1415
+ paddingInlineEnd: 0,
1416
+ },
1417
+ // The popover's height is set here rather than left to each section, so it
1418
+ // doesn't resize under the pointer as the reader moves down the rail — the
1419
+ // trigger is at the top right, so a shrinking panel would walk the rail out
1420
+ // from under the cursor. Columns fills this height and scrolls inside it;
1421
+ // the other three are shorter and are padded out to it.
1422
+ //
1423
+ // A fixed height, not a minimum: min-height alone is a floor, so a long
1424
+ // column list would still push the popover past it, and a panel can only
1425
+ // scroll once some ancestor gives it a height to scroll within.
1426
+ viewPopover: {
1427
+ blockSize: 448,
1428
+ },
1429
+ // ESCAPE HATCH (see report): DialogHeader takes no `padding`, and inside a
1430
+ // Dialog its LayoutHeader adds the dialog's own gutter a second time — the
1431
+ // title lands at 32px while a Section body lands at 16px. Section escapes
1432
+ // this by negative-margining out of the container padding before it pads
1433
+ // itself; Layout does the same, which is why the canonical block puts one
1434
+ // between Dialog and DialogHeader. Same escape, applied to the header
1435
+ // directly, so its own 16px is the only gutter. Block-end is left alone —
1436
+ // the header's bottom padding is real spacing, not an edge to cancel.
1437
+ dialogHeaderBleed: {
1438
+ marginInlineStart: 'calc(-1 * var(--container-padding-inline-start, 0px))',
1439
+ marginInlineEnd: 'calc(-1 * var(--container-padding-inline-end, 0px))',
1440
+ marginBlockStart: 'calc(-1 * var(--container-padding-block-start, 0px))',
1441
+ },
1442
+ // Wide enough for "Sticky Columns" on one line once the rail carries its own
1443
+ // gutter rather than borrowing the popover's.
1444
+ viewRail: {
1445
+ width: 184,
1446
+ flexShrink: 0,
1447
+ },
1448
+ // Pairs with the rail's 4px gutter: the item keeps Button's own
1449
+ // --radius-element and sits inside the popover's --radius-container, so the
1450
+ // two corners curve together instead of the item squaring off in a rounded
1451
+ // box. Both tokens move with the theme's radius multiplier, so the pairing
1452
+ // survives a theme swap.
1453
+ railItem: {
1454
+ width: '100%',
1455
+ justifyContent: 'flex-start',
1456
+ },
1457
+ // Full height so the transfer panels can reach the popover floor: sized to
1458
+ // its content instead, the pane stops short and the rule between the panels
1459
+ // ends in mid-air — most visibly when a search empties both sides.
1460
+ viewPanel: {
1461
+ blockSize: '100%',
1462
+ flexGrow: 1,
1463
+ minWidth: 0,
1464
+ },
1465
+ // --- Columns transfer list -------------------------------------------------
1466
+ // Copies the lab TransferList's panel chrome rather than importing it: that
1467
+ // package is unpublished and does not resolve from the template viewer, so
1468
+ // the visual contract is restated here against the same tokens.
1469
+ // The lab component stacks its panels under `@container (max-width: 40rem)`.
1470
+ // That query is not carried over: this pane is the popover's 660px less the
1471
+ // 184px rail and its gutters — about 28rem — so the stacked branch would be
1472
+ // the only one that ever ran, and side by side is the layout this panel is
1473
+ // for. The pane has no other width to respond to; the popover is fixed.
1474
+ transferRoot: {
1475
+ flexGrow: 1,
1476
+ minHeight: 0,
1477
+ minWidth: 0,
1478
+ },
1479
+ // The pair runs to the pane's edges, so the rule between them is full height
1480
+ // and each panel's header rule reaches its own edges. Nothing here is padded:
1481
+ // the gutter lives on the header, the rows and the empty state instead, via
1482
+ // the two Pad styles below.
1483
+ transferPanels: {
1484
+ flexGrow: 1,
1485
+ minHeight: 0,
1486
+ minWidth: 0,
1487
+ },
1488
+ // flexBasis 0 with grow splits the row into two equal halves; the two minimums
1489
+ // then let a half shrink below its content instead of pushing the popover
1490
+ // wider or taller than the height every other section is held to, which is
1491
+ // what lets a long column list scroll inside its own panel.
1492
+ transferPanel: {
1493
+ flexBasis: 0,
1494
+ flexGrow: 1,
1495
+ minBlockSize: 0,
1496
+ minWidth: 0,
1497
+ overflow: 'hidden',
1498
+ },
1499
+ // 16px against the pane's outer edge, 12px against the divider: the outer
1500
+ // gutter matches the heading above, and the inner one only has to clear a
1501
+ // 1px rule.
1502
+ transferPadStart: {
1503
+ paddingInlineEnd: spacingVars['--spacing-3'],
1504
+ paddingInlineStart: spacingVars['--spacing-4'],
1505
+ },
1506
+ transferPadEnd: {
1507
+ paddingInlineEnd: spacingVars['--spacing-4'],
1508
+ paddingInlineStart: spacingVars['--spacing-3'],
1509
+ },
1510
+ transferPanelDivider: {
1511
+ borderInlineStartColor: colorVars['--color-border'],
1512
+ borderInlineStartStyle: 'solid',
1513
+ borderInlineStartWidth: borderVars['--border-width'],
1514
+ },
1515
+ transferPanelHeader: {
1516
+ borderBlockEndColor: colorVars['--color-border'],
1517
+ borderBlockEndStyle: 'solid',
1518
+ borderBlockEndWidth: borderVars['--border-width'],
1519
+ },
1520
+ // The bulk action sits on the header rule beside a label, where button chrome
1521
+ // would outweigh the list it acts on. Stripped to a text link, it reads as
1522
+ // secondary to the rows without losing the accent that marks it actionable.
1523
+ transferHeaderAction: {
1524
+ backgroundImage: {
1525
+ default: 'none',
1526
+ ':hover:where(:not(:disabled,[aria-disabled="true"]))': 'none',
1527
+ ':active': 'none',
1528
+ },
1529
+ borderRadius: 0,
1530
+ color: colorVars['--color-text-accent'],
1531
+ fontSize: typeScaleVars['--text-body-size'],
1532
+ fontWeight: typeScaleVars['--text-body-weight'],
1533
+ height: 'auto',
1534
+ lineHeight: typeScaleVars['--text-body-leading'],
1535
+ paddingBlock: 0,
1536
+ paddingInline: 0,
1537
+ textDecoration: {
1538
+ default: 'none',
1539
+ ':hover:where(:not(:disabled,[aria-disabled="true"]))': 'underline',
1540
+ },
1541
+ },
1542
+ // Each panel scrolls its own rows within whatever height the pane gives it,
1543
+ // so adding columns lengthens a list instead of growing the popover past the
1544
+ // viewport. The floor is the popover's own minHeight rather than a size set
1545
+ // here, which is what keeps the four tabs the same height.
1546
+ transferPanelBody: {
1547
+ flexGrow: 1,
1548
+ minBlockSize: 0,
1549
+ overflowY: 'auto',
1550
+ overscrollBehavior: 'contain',
1551
+ scrollbarGutter: 'stable',
1552
+ },
1553
+ // Padded by transferPadStart/End rather than Item's own gutter, so a row's
1554
+ // hover and selected fill runs the full width of the panel while its text
1555
+ // stays on the header's start line. The grip and the end action then pull
1556
+ // back by their own button inset (transferGrip, transferEndAction) so it is
1557
+ // the glyph that lands on the line, not the invisible button box.
1558
+ transferItem: {
1559
+ minWidth: 0,
1560
+ transitionDuration: {
1561
+ default: durationVars['--duration-fast'],
1562
+ '@media (prefers-reduced-motion: reduce)': '0s',
1563
+ },
1564
+ transitionProperty: 'background-color, opacity',
1565
+ transitionTimingFunction: easeVars['--ease-standard'],
1566
+ },
1567
+ // A keyboard pick-up has no pointer under it to say what is moving, so the
1568
+ // row itself carries the state.
1569
+ transferItemPicked: {
1570
+ backgroundColor: colorVars['--color-accent-muted'],
1571
+ },
1572
+ transferEmpty: {
1573
+ textAlign: 'center',
1574
+ },
1575
+ // A ghost IconButton insets its glyph by --spacing-1-5; cancelling that puts
1576
+ // the grip itself on the panel's start line rather than the button box.
1577
+ transferGrip: {
1578
+ marginInlineStart: `calc(-1 * ${spacingVars['--spacing-1-5']})`,
1579
+ },
1580
+ // Same cancellation at the other edge, so the row's action lands on the end
1581
+ // line that "Restore" and "Select all" sit on.
1582
+ transferEndAction: {
1583
+ marginInlineEnd: `calc(-1 * ${spacingVars['--spacing-1-5']})`,
1584
+ },
1585
+ clickableRow: {
1586
+ cursor: 'pointer',
1587
+ },
1588
+ // One step past hover in the same neutral ramp. A muted fill can't carry
1589
+ // this on its own — --color-background-muted resolves to the identical
1590
+ // value as --color-overlay-hover. Row background belongs to this state
1591
+ // alone: selection opts out of its wash so the two never compete.
1592
+ activeRow: {
1593
+ backgroundColor: colorVars['--color-overlay-pressed'],
1594
+ // A pinned cell paints an opaque background of its own, which would cover
1595
+ // the row's. Publishing the overlay is how the sticky plugin picks it up
1596
+ // and replays it, the same way TableRow does for striping and hover.
1597
+ '--table-row-overlay': colorVars['--color-overlay-pressed'],
1598
+ },
1599
+ // A flex child has no definite width for AspectRatio to derive a height
1600
+ // from, so the tile is pinned here and told not to give the width back when
1601
+ // the cell runs short.
1602
+ jobMedia: {
1603
+ backgroundColor: colorVars['--color-background-muted'],
1604
+ borderRadius: radiusVars['--radius-element'],
1605
+ flexShrink: 0,
1606
+ width: spacingVars['--spacing-12'],
1607
+ },
1608
+ detailPanel: {
1609
+ minWidth: 0,
1610
+ },
1611
+ skeletonRow: {
1612
+ // The table drops the divider under its last row, so each placeholder
1613
+ // carries its own on top — one line between every row, none trailing.
1614
+ borderBlockStartColor: colorVars['--color-border'],
1615
+ borderBlockStartStyle: 'solid',
1616
+ borderBlockStartWidth: borderVars['--border-width'],
1617
+ },
1618
+ // The table bleeds past this container to the layout edge and re-applies the
1619
+ // inset on its outer cells. Matching that keeps the two grids on one axis.
1620
+ skeletonBleed: {
1621
+ marginInline: 'calc(-1 * var(--container-padding-inline-start, 0px))',
1622
+ },
1623
+ skeletonEdgeStart: {
1624
+ paddingInlineStart: 'var(--container-padding-inline-start, 0px)',
1625
+ },
1626
+ skeletonEdgeEnd: {
1627
+ paddingInlineEnd: 'var(--container-padding-inline-end, 0px)',
1628
+ },
1629
+ });
1630
+
1631
+ /**
1632
+ * The insertion rule the drop indicator draws. Absolute inside the row it marks
1633
+ * and pulled half its own height clear of it, so it lands in the gap between
1634
+ * two rows rather than on top of either.
1635
+ */
1636
+ const reorderIndicator = {
1637
+ backgroundColor: colorVars['--color-accent'],
1638
+ borderRadius: radiusVars['--radius-full'],
1639
+ content: '""',
1640
+ height: spacingVars['--spacing-0-5'],
1641
+ insetInline: 0,
1642
+ pointerEvents: 'none' as const,
1643
+ position: 'absolute' as const,
1644
+ zIndex: 2,
1645
+ };
1646
+
1647
+ /**
1648
+ * The drag states a reorderable row moves through, mirroring the lab package's
1649
+ * shared reorderStyles: the row being dragged fades to half, the row the
1650
+ * pointer is over grows an accent rule on the side the drop would land, and the
1651
+ * grip carries the grab cursors.
1652
+ */
1653
+ const reorderStyles = stylex.create({
1654
+ source: {
1655
+ opacity: 0.5,
1656
+ userSelect: 'none',
1657
+ },
1658
+ dropBefore: {
1659
+ position: 'relative',
1660
+ '::before': {
1661
+ ...reorderIndicator,
1662
+ insetBlockStart: `calc(-1 * ${spacingVars['--spacing-0-5']})`,
1663
+ },
1664
+ },
1665
+ dropAfter: {
1666
+ position: 'relative',
1667
+ '::after': {
1668
+ ...reorderIndicator,
1669
+ insetBlockEnd: `calc(-1 * ${spacingVars['--spacing-0-5']})`,
1670
+ },
1671
+ },
1672
+ handle: {cursor: 'grab', touchAction: 'none'},
1673
+ handleActive: {cursor: 'grabbing', touchAction: 'none'},
1674
+ });
1675
+
1676
+ /**
1677
+ * Widths come straight from the column definitions, so a placeholder cell
1678
+ * occupies the same track as the real one whatever the reader has configured.
1679
+ */
1680
+ const skeletonCell = stylex.create({
1681
+ row: (paddingBlock: number) => ({paddingBlock}),
1682
+ fixed: (width: number, paddingInline: number) => ({
1683
+ flexGrow: 0,
1684
+ flexShrink: 0,
1685
+ paddingInline,
1686
+ width,
1687
+ }),
1688
+ flexible: (flexGrow: number, minWidth: number, paddingInline: number) => ({
1689
+ flexBasis: 0,
1690
+ flexGrow,
1691
+ minWidth,
1692
+ paddingInline,
1693
+ }),
1694
+ });
1695
+
1696
+ /** Placeholder rows standing in for the batch being fetched. */
1697
+ const SKELETON_ROWS = 5;
1698
+
1699
+ /**
1700
+ * Width of the injected selection column. Wide enough to land its checkbox on
1701
+ * the 16px content gutter LayoutContent gives every other first cell.
1702
+ */
1703
+ const SELECTION_COLUMN_WIDTH = 48;
1704
+
1705
+ /** Cell padding per density, mirroring the table's own scale. */
1706
+ const DENSITY_PADDING: Record<Density, number> = {
1707
+ compact: 8,
1708
+ balanced: 12,
1709
+ spacious: 16,
1710
+ };
1711
+
1712
+ /**
1713
+ * The batch still in flight, drawn as rows rather than a spinner so the page
1714
+ * keeps its shape while it loads. Cells are sized from the live column
1715
+ * definitions, so each bar sits under the column it is about to fill.
1716
+ */
1717
+ function LoadingRows({
1718
+ columns,
1719
+ density,
1720
+ }: {
1721
+ columns: TableColumn<ServiceJob>[];
1722
+ density: Density;
1723
+ }) {
1724
+ const pad = DENSITY_PADDING[density];
1725
+ return (
1726
+ <VStack
1727
+ gap={0}
1728
+ role="status"
1729
+ aria-label="Loading more jobs"
1730
+ xstyle={styles.skeletonBleed}>
1731
+ {Array.from({length: SKELETON_ROWS}, (_, row) => (
1732
+ <HStack
1733
+ key={row}
1734
+ gap={0}
1735
+ aria-hidden
1736
+ xstyle={[styles.skeletonRow, skeletonCell.row(pad)]}>
1737
+ <VStack
1738
+ gap={0}
1739
+ vAlign="center"
1740
+ xstyle={[
1741
+ skeletonCell.fixed(SELECTION_COLUMN_WIDTH, 0),
1742
+ styles.skeletonEdgeStart,
1743
+ ]}>
1744
+ <Skeleton width={16} height={16} radius={1} index={row} />
1745
+ </VStack>
1746
+ {columns.map((col, index) => (
1747
+ <VStack
1748
+ key={col.key}
1749
+ gap={0}
1750
+ vAlign="center"
1751
+ xstyle={[
1752
+ col.width?.type === 'pixel'
1753
+ ? skeletonCell.fixed(col.width.value, pad)
1754
+ : skeletonCell.flexible(
1755
+ col.width?.value ?? 1,
1756
+ col.width?.minWidth ?? 120,
1757
+ pad,
1758
+ ),
1759
+ index === columns.length - 1 && styles.skeletonEdgeEnd,
1760
+ ]}>
1761
+ {col.key === 'summary' && density !== 'compact' ? (
1762
+ // The Job cell runs to two lines everywhere but compact;
1763
+ // matching that keeps the placeholders on the same rhythm as
1764
+ // the rows above.
1765
+ <VStack gap={2}>
1766
+ <Skeleton height={14} index={row * 4 + index} />
1767
+ <Skeleton
1768
+ width="40%"
1769
+ height={11}
1770
+ index={row * 4 + index + 1}
1771
+ />
1772
+ </VStack>
1773
+ ) : (
1774
+ <Skeleton height={12} index={row * 4 + index} />
1775
+ )}
1776
+ </VStack>
1777
+ ))}
1778
+ </HStack>
1779
+ ))}
1780
+ </VStack>
1781
+ );
1782
+ }
1783
+
1784
+ /**
1785
+ * Everything a saved view carries besides its filters, as key/value rows, so
1786
+ * the dialogs can state what is being captured instead of leaving the reader
1787
+ * to assume a view is only a filter list.
1788
+ *
1789
+ * A setting still gets a row at its default value. Naming only the
1790
+ * non-defaults reads as a diff against a baseline the reader cannot see, and
1791
+ * leaves them guessing whether an absent setting is at its default or simply
1792
+ * not something a view carries.
1793
+ */
1794
+ function ViewSummaryList({
1795
+ view,
1796
+ filters,
1797
+ }: {
1798
+ view: ViewState;
1799
+ /**
1800
+ * Shown as the clauses themselves rather than a count: "3 filters" says a
1801
+ * view narrows the table without saying to what, which is the one thing the
1802
+ * reader is deciding on.
1803
+ */
1804
+ filters: readonly PowerSearchFilter[];
1805
+ }) {
1806
+ const density = DENSITY_OPTIONS.find(o => o.value === view.density);
1807
+ const grouping = GROUPING_OPTIONS.find(o => o.value === view.grouping);
1808
+ const stickyStart = STICKY_START_OPTIONS.find(
1809
+ o => o.value === view.stickyStart,
1810
+ );
1811
+ const stickyEnd = STICKY_END_OPTIONS.find(o => o.value === view.stickyEnd);
1812
+
1813
+ return (
1814
+ <MetadataList columns="single" label={{position: 'start', width: 104}}>
1815
+ <MetadataListItem label="Filters">
1816
+ {filters.length === 0 ? (
1817
+ <Text type="body">None</Text>
1818
+ ) : (
1819
+ <HStack gap={1} xstyle={styles.tokenWrap}>
1820
+ {filters.map(f => (
1821
+ <Token
1822
+ key={`${f.field}-${f.operator}-${filterValueText(f)}`}
1823
+ label={filterTokenLabel(f)}
1824
+ description={`${f.field} ${f.operator} ${filterValueText(f)}`}
1825
+ size="sm"
1826
+ xstyle={styles.filterToken}
1827
+ />
1828
+ ))}
1829
+ </HStack>
1830
+ )}
1831
+ </MetadataListItem>
1832
+ <MetadataListItem label="Columns">
1833
+ <Text type="body">
1834
+ {view.columnKeys.length} of {ALL_COLUMN_KEYS.length}
1835
+ </Text>
1836
+ </MetadataListItem>
1837
+ <MetadataListItem label="Density">
1838
+ <Text type="body">{density?.label ?? view.density}</Text>
1839
+ </MetadataListItem>
1840
+ <MetadataListItem label="Grouping">
1841
+ <Text type="body">{grouping?.label ?? view.grouping}</Text>
1842
+ </MetadataListItem>
1843
+ <MetadataListItem label="Frozen start">
1844
+ <Text type="body">{stickyStart?.label ?? view.stickyStart}</Text>
1845
+ </MetadataListItem>
1846
+ <MetadataListItem label="Frozen end">
1847
+ <Text type="body">{stickyEnd?.label ?? view.stickyEnd}</Text>
1848
+ </MetadataListItem>
1849
+ </MetadataList>
1850
+ );
1851
+ }
1852
+
1853
+ // =============================================================================
1854
+ // Template
1855
+ // =============================================================================
1856
+
1857
+ export default function TableFilterTemplate() {
1858
+ // --- Filtering -------------------------------------------------------------
1859
+ const [filters, setFilters] = useState<PowerSearchFilter[]>(INITIAL_FILTERS);
1860
+ const [isPowerSearch, setIsPowerSearch] = useState(false);
1861
+ const [query, setQuery] = useState('');
1862
+
1863
+ // --- Saved views -----------------------------------------------------------
1864
+ const [savedViews, setSavedViews] = useState(INITIAL_SAVED_VIEWS);
1865
+ const [isSavedViewsBarOpen, setIsSavedViewsBarOpen] = useState(false);
1866
+ const [activeSavedViewId, setActiveSavedViewId] = useState<string | null>(
1867
+ null,
1868
+ );
1869
+ const [creatingName, setCreatingName] = useState<string | null>(null);
1870
+ const [editing, setEditing] = useState<SavedView | null>(null);
1871
+
1872
+ // --- Selection -------------------------------------------------------------
1873
+ const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
1874
+
1875
+ /** Row whose details are open in the end panel. Independent of bulk selection. */
1876
+ const [activeJobId, setActiveJobId] = useState<string | null>(null);
1877
+
1878
+ // The floor keeps the MetadataList's 104px label column from squeezing its
1879
+ // values below a readable line; the ceiling stops the panel from taking so
1880
+ // much room that the table loses its sortable columns.
1881
+ const detailWidth = useResizable({
1882
+ defaultSize: 380,
1883
+ minSizePx: 320,
1884
+ maxSizePx: 560,
1885
+ });
1886
+
1887
+ // --- Sorting ---------------------------------------------------------------
1888
+ const [sort, setSort] = useState<
1889
+ Array<{sortKey: string; direction: 'ascending' | 'descending'}>
1890
+ >([{sortKey: 'priority', direction: 'ascending'}]);
1891
+
1892
+ // --- View options ----------------------------------------------------------
1893
+ const [view, setView] = useState<ViewState>(INITIAL_VIEW);
1894
+ const [viewSection, setViewSection] = useState<ViewSection>('columns');
1895
+ const [isViewOpen, setIsViewOpen] = useState(false);
1896
+ /**
1897
+ * The column reorder in flight, if any. Held in a ref as well as state
1898
+ * because a pointermove reads the session it is continuing before React has
1899
+ * re-rendered the one the previous move wrote.
1900
+ */
1901
+ const [reorderSession, setReorderSessionState] =
1902
+ useState<ColumnReorderSession | null>(null);
1903
+ const reorderSessionRef = useRef<ColumnReorderSession | null>(null);
1904
+ /** Live row elements of the displayed list, so a drag can measure them. */
1905
+ const columnRowRefs = useRef(new Map<string, HTMLElement>());
1906
+ /** A drag ends in a click the grip must not read as a keyboard pick-up. */
1907
+ const suppressGripClickRef = useRef(false);
1908
+ const announce = useAnnounce();
1909
+ const columnPanelId = useId();
1910
+ /** Group keys the reader has folded shut. Empty means every section is open. */
1911
+ const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
1912
+ () => new Set(),
1913
+ );
1914
+
1915
+ // --- Misc ------------------------------------------------------------------
1916
+ const [isLoading, setIsLoading] = useState(false);
1917
+
1918
+ const {config, applyFilters} = usePowerSearchConfig(fieldDefs, 'Jobs');
1919
+
1920
+ // A filter change refetches; the top progress bar stands in for that.
1921
+ useEffect(() => {
1922
+ setIsLoading(true);
1923
+ const timer = setTimeout(() => setIsLoading(false), 450);
1924
+ return () => clearTimeout(timer);
1925
+ }, [filters, query]);
1926
+
1927
+ const groupField = view.grouping;
1928
+ const isGrouped = groupField !== 'none';
1929
+
1930
+ const results = useMemo(() => {
1931
+ const byFilters = applyFilters(filters, allJobs);
1932
+ const q = query.trim().toLowerCase();
1933
+ const byQuery = q
1934
+ ? byFilters.filter(
1935
+ j =>
1936
+ j.summary.toLowerCase().includes(q) ||
1937
+ j.customer.toLowerCase().includes(q) ||
1938
+ j.id.toLowerCase().includes(q),
1939
+ )
1940
+ : byFilters;
1941
+ if (sort.length === 0 && !isGrouped) {
1942
+ return byQuery;
1943
+ }
1944
+ // Grouping sorts before the user's own keys, which is what keeps a
1945
+ // section's rows contiguous in the result set. Batches then fill the
1946
+ // sections in order and always land at the bottom of the table; grouping
1947
+ // whatever a row-ordered slice happened to contain would instead scatter
1948
+ // every section across every batch and splice new rows in above the fold.
1949
+ // A real backend does the same thing — ORDER BY group, then by sort.
1950
+ const groupRank = isGrouped
1951
+ ? new Map(GROUP_ORDERS[groupField].map((key, i) => [key, i]))
1952
+ : null;
1953
+ return [...byQuery].sort((a, b) => {
1954
+ if (groupRank != null) {
1955
+ const cmp =
1956
+ (groupRank.get(groupKeyOf(a, groupField)) ??
1957
+ Number.MAX_SAFE_INTEGER) -
1958
+ (groupRank.get(groupKeyOf(b, groupField)) ?? Number.MAX_SAFE_INTEGER);
1959
+ if (cmp !== 0) {
1960
+ return cmp;
1961
+ }
1962
+ }
1963
+ for (const {sortKey, direction} of sort) {
1964
+ const av = a[sortKey];
1965
+ const bv = b[sortKey];
1966
+ const rank = SORT_RANKS[sortKey];
1967
+ let cmp: number;
1968
+ if (rank != null) {
1969
+ // An unranked value sorts last rather than first, so a value the map
1970
+ // has not heard of cannot displace Urgent at the top.
1971
+ cmp =
1972
+ (rank[String(av)] ?? Number.MAX_SAFE_INTEGER) -
1973
+ (rank[String(bv)] ?? Number.MAX_SAFE_INTEGER);
1974
+ } else if (typeof av === 'number' && typeof bv === 'number') {
1975
+ cmp = av - bv;
1976
+ } else {
1977
+ cmp = String(av ?? '').localeCompare(String(bv ?? ''));
1978
+ }
1979
+ if (cmp !== 0) {
1980
+ return direction === 'ascending' ? cmp : -cmp;
1981
+ }
1982
+ }
1983
+ return 0;
1984
+ });
1985
+ }, [applyFilters, filters, groupField, isGrouped, query, sort]);
1986
+
1987
+ // Grouped, the page unit is the section; flat, it is the row. Passing the
1988
+ // key only while grouping keeps an ungrouped view on even page sizes.
1989
+ const batchGroupKey = useMemo(
1990
+ () => (isGrouped ? (job: ServiceJob) => groupKeyOf(job, groupField) : null),
1991
+ [groupField, isGrouped],
1992
+ );
1993
+
1994
+ const {
1995
+ data: rows,
1996
+ hasNext,
1997
+ isLoadingNext,
1998
+ loadNext,
1999
+ } = useInfiniteBatches(results, PAGE_SIZE, batchGroupKey);
2000
+
2001
+ // The sentinel sits under the last row; when it scrolls into view the next
2002
+ // batch loads. The root stays the viewport so this works whether the page
2003
+ // scrolls or the LayoutContent scrolls inside a height-constrained shell.
2004
+ const sentinelRef = useRef<HTMLElement>(null);
2005
+
2006
+ useEffect(() => {
2007
+ const sentinel = sentinelRef.current;
2008
+ if (sentinel == null || !hasNext) {
2009
+ return;
2010
+ }
2011
+ const observer = new IntersectionObserver(
2012
+ entries => {
2013
+ if (entries.some(entry => entry.isIntersecting)) {
2014
+ loadNext(PAGE_SIZE);
2015
+ }
2016
+ },
2017
+ {rootMargin: '240px'},
2018
+ );
2019
+ observer.observe(sentinel);
2020
+ return () => observer.disconnect();
2021
+ }, [hasNext, loadNext]);
2022
+
2023
+ // --- Filter mutation -------------------------------------------------------
2024
+ const setFieldFilter = useCallback(
2025
+ (fieldKey: string, next: PowerSearchFilter | null) => {
2026
+ setActiveSavedViewId(null);
2027
+ setFilters(current => {
2028
+ const rest = current.filter(f => f.field !== fieldKey);
2029
+ return next ? [...rest, next] : rest;
2030
+ });
2031
+ },
2032
+ [],
2033
+ );
2034
+
2035
+ const togglePreset = useCallback((filter: PowerSearchFilter) => {
2036
+ setActiveSavedViewId(null);
2037
+ setFilters(current =>
2038
+ current.some(f => JSON.stringify(f) === JSON.stringify(filter))
2039
+ ? current.filter(f => JSON.stringify(f) !== JSON.stringify(filter))
2040
+ : [...current.filter(f => f.field !== filter.field), filter],
2041
+ );
2042
+ }, []);
2043
+
2044
+ const clearAll = useCallback(() => {
2045
+ setFilters([]);
2046
+ setQuery('');
2047
+ setActiveSavedViewId(null);
2048
+ }, []);
2049
+
2050
+ // --- Many-of fields --------------------------------------------------------
2051
+ /**
2052
+ * One pass over the clause list keyed by field, rather than a pair of hooks
2053
+ * per field, so adding a many-of field is a MULTI_FILTER_FIELDS entry and
2054
+ * nothing else. A saved view or a hand-typed power-search clause may have
2055
+ * written a single `is`, so a scalar reads back as a one-item list.
2056
+ */
2057
+ const multiValues = useMemo(() => {
2058
+ const byField: Record<string, string[]> = {};
2059
+ for (const field of MULTI_FILTER_FIELDS) {
2060
+ const active = filters.find(f => f.field === field.key);
2061
+ const raw = active ? (active.value as {value?: unknown}).value : null;
2062
+ byField[field.key] = Array.isArray(raw)
2063
+ ? raw.map(String)
2064
+ : raw == null
2065
+ ? []
2066
+ : [String(raw)];
2067
+ }
2068
+ return byField;
2069
+ }, [filters]);
2070
+
2071
+ const setMultiValues = useCallback(
2072
+ (field: string, next: string[]) => {
2073
+ setFieldFilter(
2074
+ field,
2075
+ next.length === 0
2076
+ ? null
2077
+ : {
2078
+ field,
2079
+ operator: 'is_any_of',
2080
+ value: {type: 'enum_list', value: next},
2081
+ },
2082
+ );
2083
+ },
2084
+ [setFieldFilter],
2085
+ );
2086
+
2087
+ // --- Quote: range ----------------------------------------------------------
2088
+ // Two bounds rather than one clause: power search has no numeric `between`,
2089
+ // and applyFilters ANDs same-field clauses, so a pair reads as a range in
2090
+ // both modes.
2091
+ const boundValue = (operator: string, fallback: number) => {
2092
+ const found = filters.find(
2093
+ f => f.field === 'quoted' && f.operator === operator,
2094
+ );
2095
+ return found ? Number((found.value as {value: number}).value) : fallback;
2096
+ };
2097
+ const quoteLow = boundValue('greater_than_or_equal', QUOTE_MIN);
2098
+ const quoteHigh = boundValue('less_than_or_equal', QUOTE_MAX);
2099
+
2100
+ // Dragging updates a draft; the committed filter waits for onChangeEnd so
2101
+ // the table and its loading bar don't rerun on every pointer move.
2102
+ const [quoteDraft, setQuoteDraft] = useState<[number, number]>([
2103
+ quoteLow,
2104
+ quoteHigh,
2105
+ ]);
2106
+ useEffect(() => setQuoteDraft([quoteLow, quoteHigh]), [quoteLow, quoteHigh]);
2107
+
2108
+ const commitQuote = useCallback((next: [number, number]) => {
2109
+ setActiveSavedViewId(null);
2110
+ setFilters(current => {
2111
+ const rest = current.filter(f => f.field !== 'quoted');
2112
+ const bounds: PowerSearchFilter[] = [];
2113
+ if (next[0] > QUOTE_MIN) {
2114
+ bounds.push({
2115
+ field: 'quoted',
2116
+ operator: 'greater_than_or_equal',
2117
+ value: {type: 'integer', value: next[0]},
2118
+ });
2119
+ }
2120
+ if (next[1] < QUOTE_MAX) {
2121
+ bounds.push({
2122
+ field: 'quoted',
2123
+ operator: 'less_than_or_equal',
2124
+ value: {type: 'integer', value: next[1]},
2125
+ });
2126
+ }
2127
+ return [...rest, ...bounds];
2128
+ });
2129
+ }, []);
2130
+
2131
+ const hasQuoteFilter = quoteLow > QUOTE_MIN || quoteHigh < QUOTE_MAX;
2132
+
2133
+ /**
2134
+ * The clause the closed trigger reads as, matching the selectors beside it.
2135
+ * Undefined while the range is untouched, so ComplexSelector falls back to
2136
+ * its own placeholder and paints the unset treatment itself.
2137
+ */
2138
+ const quoteLabel = !hasQuoteFilter
2139
+ ? undefined
2140
+ : quoteLow > QUOTE_MIN && quoteHigh < QUOTE_MAX
2141
+ ? `Quote ${formatMoney(quoteLow)} – ${formatMoney(quoteHigh)}`
2142
+ : quoteLow > QUOTE_MIN
2143
+ ? `Quote over ${formatMoney(quoteLow)}`
2144
+ : `Quote under ${formatMoney(quoteHigh)}`;
2145
+
2146
+ // --- Saved view actions ----------------------------------------------------
2147
+ /**
2148
+ * A saved view is the whole screen, so applying one restores the view
2149
+ * configuration alongside the filters.
2150
+ *
2151
+ * Passing null is the All chip: it stands for no saved view, not for the
2152
+ * default configuration, so it drops the filters and leaves the columns,
2153
+ * density, sticky edges and grouping exactly as the reader has them.
2154
+ */
2155
+ const applySavedView = useCallback((saved: SavedView | null) => {
2156
+ setActiveSavedViewId(saved?.id ?? null);
2157
+ setFilters(saved ? [...saved.filters] : []);
2158
+ // A saved view carries structured filters, which power search cannot show.
2159
+ // Leaving the query bar up would strand the reader on a mode that no longer
2160
+ // describes what the table is doing, so choosing a view wins over the mode
2161
+ // they were in.
2162
+ setIsPowerSearch(false);
2163
+ if (saved == null) {
2164
+ return;
2165
+ }
2166
+ const restored: ViewState = {
2167
+ ...saved.view,
2168
+ columnKeys: [...saved.view.columnKeys],
2169
+ };
2170
+ setView(restored);
2171
+ // A collapsed key only means something under the field it came from, so
2172
+ // arriving on a different grouping opens every section again.
2173
+ setCollapsedGroups(new Set());
2174
+ }, []);
2175
+
2176
+ const createSavedView = useCallback(
2177
+ (name: string) => {
2178
+ const trimmed = name.trim();
2179
+ if (!trimmed) {
2180
+ return;
2181
+ }
2182
+ const id = `saved-${Date.now()}`;
2183
+ setSavedViews(list => [
2184
+ ...list,
2185
+ {
2186
+ id,
2187
+ name: trimmed,
2188
+ filters: [...filters],
2189
+ view: {...view, columnKeys: [...view.columnKeys]},
2190
+ },
2191
+ ]);
2192
+ setActiveSavedViewId(id);
2193
+ setCreatingName(null);
2194
+ },
2195
+ [filters, view],
2196
+ );
2197
+
2198
+ const saveEditedView = useCallback((edited: SavedView) => {
2199
+ setSavedViews(list =>
2200
+ list.map(s => (s.id === edited.id ? {...s, name: edited.name} : s)),
2201
+ );
2202
+ setEditing(null);
2203
+ }, []);
2204
+
2205
+ const deleteSavedView = useCallback((id: string) => {
2206
+ setSavedViews(list => list.filter(s => s.id !== id));
2207
+ setActiveSavedViewId(current => (current === id ? null : current));
2208
+ setEditing(null);
2209
+ }, []);
2210
+
2211
+ /**
2212
+ * Every view control writes straight through to the live table: the panel has
2213
+ * no commit step, so what the reader sees behind the popover is always what
2214
+ * the controls say.
2215
+ *
2216
+ * With no Apply to hang it off, stepping off the active saved view moves here.
2217
+ * That makes the first toggle detach from the view rather than the last one,
2218
+ * which is the trade instant apply asks for — reconfiguring the table is what
2219
+ * leaves a saved view, and now there is no moment to defer it to.
2220
+ */
2221
+ const updateView = useCallback(
2222
+ (patch: (current: ViewState) => ViewState) => {
2223
+ const next = patch(view);
2224
+ // A collapsed key only means something under the field it came from, so
2225
+ // regrouping opens every section again.
2226
+ if (next.grouping !== view.grouping) {
2227
+ setCollapsedGroups(new Set());
2228
+ }
2229
+ setView(next);
2230
+ setActiveSavedViewId(null);
2231
+ },
2232
+ [view],
2233
+ );
2234
+
2235
+ // --- Table plugins ---------------------------------------------------------
2236
+ const {selectionConfig} = useTableSelectionState({
2237
+ data: rows,
2238
+ idKey: 'id',
2239
+ selectedKeys,
2240
+ setSelectedKeys,
2241
+ });
2242
+ const selectionPlugin = useTableSelection<ServiceJob>({
2243
+ ...selectionConfig,
2244
+ getRowLabel: (item: ServiceJob) => `${item.id} ${item.summary}`,
2245
+ // Row background already means "open in the panel" here, so checking a box
2246
+ // must not claim it too — two row states sharing one signal reads as one
2247
+ // confused state. The tick is the selection; aria-selected is still set
2248
+ // either way, so this costs assistive tech nothing.
2249
+ hasRowHighlight: false,
2250
+ });
2251
+ const sortablePlugin = useTableSortable<ServiceJob>({
2252
+ sort,
2253
+ onSortChange: setSort,
2254
+ allowUnsortedState: true,
2255
+ isMultiSortEnabled: true,
2256
+ });
2257
+ // The checkbox column needs no mention here: the plugin pins the whole
2258
+ // contiguous run from the first column through the last key it is given, so
2259
+ // naming the first data column carries the selection column with it.
2260
+ const stickyPlugin = useTableStickyColumns<ServiceJob>({
2261
+ startKeys: stickyKeys(view.stickyStart, view.columnKeys, false),
2262
+ endKeys: stickyKeys(view.stickyEnd, view.columnKeys, true),
2263
+ });
2264
+
2265
+ // --- Grouping --------------------------------------------------------------
2266
+ const toggleGroup = useCallback((groupKey: string) => {
2267
+ setCollapsedGroups(current => {
2268
+ const next = new Set(current);
2269
+ if (!next.delete(groupKey)) {
2270
+ next.add(groupKey);
2271
+ }
2272
+ return next;
2273
+ });
2274
+ }, []);
2275
+
2276
+ const groupBy = useCallback(
2277
+ (item: ServiceJob) => groupKeyOf(item, groupField),
2278
+ [groupField],
2279
+ );
2280
+
2281
+ const getRowKey = useCallback((item: ServiceJob) => item.id, []);
2282
+
2283
+ const {
2284
+ plugin: groupPlugin,
2285
+ data: groupedRows,
2286
+ idKey: groupRowKey,
2287
+ } = useTableGroupedRows<ServiceJob>({
2288
+ // The hook groups the loaded batch, and the batch always ends on a section
2289
+ // boundary, so every section it sees is entire. With grouping off it is
2290
+ // handed nothing, so it flattens nothing and its plugin goes unused.
2291
+ data: isGrouped ? rows : NO_ROWS,
2292
+ groupBy,
2293
+ collapsedGroups,
2294
+ onToggleGroup: toggleGroup,
2295
+ // No renderGroupHeader on purpose. The plugin only pins the heading to the
2296
+ // start edge for its built-in one — a custom heading is left full width,
2297
+ // which leaves a sticky element no room to travel, so chevron and label
2298
+ // scroll off a sideways-scrolled table. The default already sets the label
2299
+ // and its count in two weights, which is what a custom one was doing here.
2300
+ getRowKey,
2301
+ groupOrder: GROUP_ORDERS[groupField],
2302
+ });
2303
+
2304
+ const isGroupHeaderRow = useCallback(
2305
+ (item: ServiceJob) => groupRowKey(item).startsWith(GROUP_ROW_KEY_PREFIX),
2306
+ [groupRowKey],
2307
+ );
2308
+
2309
+ /**
2310
+ * The panel opens on the first row, so the template arrives showing its
2311
+ * detail view instead of a bare rail the reader has to discover. It reads
2312
+ * the grouped order rather than `results` because that is the order on
2313
+ * screen, and skips the synthetic section headers.
2314
+ *
2315
+ * One-shot through a ref: without it, every filter change would reopen the
2316
+ * panel, and closing it would only make the next render open it again.
2317
+ */
2318
+ const hasOpenedFirstRow = useRef(false);
2319
+ useEffect(() => {
2320
+ if (hasOpenedFirstRow.current) {
2321
+ return;
2322
+ }
2323
+ const first = (isGrouped ? groupedRows : rows).find(
2324
+ row => !isGroupHeaderRow(row),
2325
+ );
2326
+ if (first == null) {
2327
+ return;
2328
+ }
2329
+ hasOpenedFirstRow.current = true;
2330
+ setActiveJobId(first.id);
2331
+ }, [groupedRows, isGroupHeaderRow, isGrouped, rows]);
2332
+
2333
+ /**
2334
+ * A section header is a synthetic row, and the table renders every column's
2335
+ * cell against it before the plugin can discard them. Its fields read as
2336
+ * `''` there, which a cell that prints the value survives — but not the
2337
+ * commoner lookup: `STATUS_META['']` is undefined, and reading `.badge` off
2338
+ * it takes the page down. The plugin offers no way to skip those cells, so the
2339
+ * columns are wrapped to return nothing for the ones about to be thrown
2340
+ * away.
2341
+ */
2342
+ const groupedPlugin = useMemo<TablePlugin<ServiceJob>>(
2343
+ () => ({
2344
+ ...groupPlugin,
2345
+ transformColumns: cols =>
2346
+ cols.map(col => {
2347
+ const {renderCell} = col;
2348
+ return renderCell == null
2349
+ ? col
2350
+ : {
2351
+ ...col,
2352
+ renderCell: (item: ServiceJob) =>
2353
+ isGroupHeaderRow(item) ? null : renderCell(item),
2354
+ };
2355
+ }),
2356
+ }),
2357
+ [groupPlugin, isGroupHeaderRow],
2358
+ );
2359
+
2360
+ /**
2361
+ * Opens the detail panel for the clicked row. There is no first-class row
2362
+ * activation plugin, so this reaches the `<tr>` through transformBodyRow.
2363
+ */
2364
+ const rowActivationPlugin = useMemo<TablePlugin<ServiceJob>>(
2365
+ () => ({
2366
+ // The selection column ships at 36px, which centres its checkbox 6px
2367
+ // from the table edge. 48px lands it on the 16px content gutter that
2368
+ // LayoutContent gives every other first cell.
2369
+ transformColumns: cols =>
2370
+ cols.map((col, index) =>
2371
+ index === 0 && col.key === SELECTION_COLUMN_KEY
2372
+ ? {...col, width: pixel(SELECTION_COLUMN_WIDTH)}
2373
+ : col,
2374
+ ),
2375
+ transformBodyRow: (props, item) => {
2376
+ // A section header is not a row: it must never open the panel or take
2377
+ // the active highlight, and its own toggle owns the click. The
2378
+ // grouping plugin publishes no predicate for its synthetic headers, so
2379
+ // this reads the row key it documents for them.
2380
+ if (isGroupHeaderRow(item)) {
2381
+ return props;
2382
+ }
2383
+ const isActive = item.id === activeJobId;
2384
+ return {
2385
+ ...props,
2386
+ htmlProps: {
2387
+ ...props.htmlProps,
2388
+ tabIndex: 0,
2389
+ 'aria-current': isActive ? true : undefined,
2390
+ onClick: event => {
2391
+ // Checkboxes and other cell controls own their own clicks.
2392
+ if (
2393
+ (event.target as HTMLElement).closest(
2394
+ 'input, button, a, select, textarea',
2395
+ ) != null
2396
+ ) {
2397
+ return;
2398
+ }
2399
+ setActiveJobId(item.id);
2400
+ },
2401
+ onKeyDown: event => {
2402
+ // Only when the row itself has focus, so a checkbox keeps Space.
2403
+ if (event.target !== event.currentTarget) {
2404
+ return;
2405
+ }
2406
+ if (event.key === 'Enter' || event.key === ' ') {
2407
+ event.preventDefault();
2408
+ setActiveJobId(item.id);
2409
+ }
2410
+ },
2411
+ },
2412
+ xstyle: isActive
2413
+ ? [...props.xstyle, styles.clickableRow, styles.activeRow]
2414
+ : [...props.xstyle, styles.clickableRow],
2415
+ };
2416
+ },
2417
+ }),
2418
+ [activeJobId, isGroupHeaderRow],
2419
+ );
2420
+
2421
+ const plugins = useMemo<Record<string, TablePlugin<ServiceJob>>>(
2422
+ () => ({
2423
+ selection: selectionPlugin,
2424
+ sortable: sortablePlugin,
2425
+ sticky: stickyPlugin,
2426
+ rowActivation: rowActivationPlugin,
2427
+ // Last in the record, so the header row's one full-width cell is the
2428
+ // final word on what a header renders. Left out entirely while grouping
2429
+ // is off, so the table runs the pipeline it ran before.
2430
+ ...(isGrouped ? {grouped: groupedPlugin} : null),
2431
+ }),
2432
+ [
2433
+ selectionPlugin,
2434
+ sortablePlugin,
2435
+ stickyPlugin,
2436
+ rowActivationPlugin,
2437
+ isGrouped,
2438
+ groupedPlugin,
2439
+ ],
2440
+ );
2441
+
2442
+ // --- Columns ---------------------------------------------------------------
2443
+ /**
2444
+ * Compact is a one-line row, so the Job cell drops its second line and every
2445
+ * text cell clamps rather than wrapping — otherwise a long customer name
2446
+ * would out-height the row the density just tightened. `maxLines` carries its
2447
+ * own hover tooltip for whatever it cut off, which hand-rolled ellipsis
2448
+ * would not.
2449
+ */
2450
+ const isCompact = view.density === 'compact';
2451
+ const cellLines = isCompact ? 1 : 0;
2452
+ /**
2453
+ * Spacious buys the row enough height for a site photo, so the Job cell
2454
+ * leads with one. Wire the tile to your own image; the placeholder stands in
2455
+ * for it here so the row keeps the height the density promised either way.
2456
+ */
2457
+ const isSpacious = view.density === 'spacious';
2458
+
2459
+ const allColumns: Record<string, TableColumn<ServiceJob>> = useMemo(
2460
+ () => ({
2461
+ summary: {
2462
+ key: 'summary',
2463
+ header: 'Job',
2464
+ width: proportional(2, {minWidth: 240}),
2465
+ sortable: true,
2466
+ renderCell: (item: ServiceJob) =>
2467
+ isCompact ? (
2468
+ <Text type="body" maxLines={1}>
2469
+ {item.summary}
2470
+ </Text>
2471
+ ) : (
2472
+ <HStack gap={3} vAlign="center">
2473
+ {isSpacious ? (
2474
+ // AspectRatio derives its height from a definite width, which
2475
+ // a flex child does not have — hence the pinned box.
2476
+ <AspectRatio
2477
+ ratio={4 / 3}
2478
+ fit="center"
2479
+ xstyle={styles.jobMedia}>
2480
+ <Icon icon={ImageIcon} size="sm" color="secondary" />
2481
+ </AspectRatio>
2482
+ ) : null}
2483
+ <VStack gap={0}>
2484
+ <Text type="body">{item.summary}</Text>
2485
+ <Text type="supporting" color="secondary">
2486
+ {item.id}
2487
+ </Text>
2488
+ </VStack>
2489
+ </HStack>
2490
+ ),
2491
+ },
2492
+ customer: {
2493
+ key: 'customer',
2494
+ header: 'Customer',
2495
+ width: proportional(1, {minWidth: 180}),
2496
+ sortable: true,
2497
+ renderCell: (item: ServiceJob) => (
2498
+ <Text type="body" maxLines={cellLines}>
2499
+ {item.customer}
2500
+ </Text>
2501
+ ),
2502
+ },
2503
+ technician: {
2504
+ key: 'technician',
2505
+ header: 'Technician',
2506
+ width: pixel(200),
2507
+ sortable: true,
2508
+ renderCell: (item: ServiceJob) => (
2509
+ <HStack gap={2} vAlign="center">
2510
+ <Avatar name={item.technician} size="sm" />
2511
+ <Text type="body" maxLines={cellLines}>
2512
+ {item.technician}
2513
+ </Text>
2514
+ </HStack>
2515
+ ),
2516
+ },
2517
+ status: {
2518
+ key: 'status',
2519
+ header: 'Status',
2520
+ width: pixel(150),
2521
+ sortable: true,
2522
+ renderCell: (item: ServiceJob) => {
2523
+ const meta = STATUS_META[item.status];
2524
+ return <Badge variant={meta.badge} label={meta.label} />;
2525
+ },
2526
+ },
2527
+ priority: {
2528
+ key: 'priority',
2529
+ header: 'Priority',
2530
+ width: pixel(120),
2531
+ sortable: true,
2532
+ renderCell: (item: ServiceJob) => {
2533
+ const meta = PRIORITY_META[item.priority];
2534
+ return (
2535
+ <HStack gap={2} vAlign="center">
2536
+ <StatusDot
2537
+ variant={meta.dot}
2538
+ label={meta.label}
2539
+ isPulsing={item.priority === 'urgent'}
2540
+ />
2541
+ <Text type="body" maxLines={cellLines}>
2542
+ {meta.label}
2543
+ </Text>
2544
+ </HStack>
2545
+ );
2546
+ },
2547
+ },
2548
+ scheduledAt: {
2549
+ key: 'scheduledAt',
2550
+ header: 'Scheduled',
2551
+ width: pixel(160),
2552
+ sortable: true,
2553
+ renderCell: (item: ServiceJob) => (
2554
+ <Text type="body" maxLines={cellLines}>
2555
+ {formatScheduled(item.scheduledAt)}
2556
+ </Text>
2557
+ ),
2558
+ },
2559
+ quoted: {
2560
+ key: 'quoted',
2561
+ header: 'Quote',
2562
+ width: pixel(110),
2563
+ align: 'end',
2564
+ sortable: true,
2565
+ renderCell: (item: ServiceJob) => (
2566
+ <Text type="body" maxLines={cellLines}>
2567
+ {formatMoney(item.quoted)}
2568
+ </Text>
2569
+ ),
2570
+ },
2571
+ equipment: {
2572
+ key: 'equipment',
2573
+ header: 'Equipment',
2574
+ width: proportional(1, {minWidth: 200}),
2575
+ renderCell: (item: ServiceJob) => (
2576
+ <Text type="body" maxLines={cellLines}>
2577
+ {item.equipment}
2578
+ </Text>
2579
+ ),
2580
+ },
2581
+ address: {
2582
+ key: 'address',
2583
+ header: 'Address',
2584
+ width: proportional(1, {minWidth: 200}),
2585
+ renderCell: (item: ServiceJob) => (
2586
+ <Text type="body" maxLines={cellLines}>
2587
+ {item.address}
2588
+ </Text>
2589
+ ),
2590
+ },
2591
+ }),
2592
+ [cellLines, isCompact, isSpacious],
2593
+ );
2594
+
2595
+ const columns = useMemo(
2596
+ () => view.columnKeys.map(key => allColumns[key]).filter(Boolean),
2597
+ [view.columnKeys, allColumns],
2598
+ );
2599
+
2600
+ const selectedCount = selectedKeys.size;
2601
+ const selectedJobs = allJobs.filter(j => selectedKeys.has(j.id));
2602
+ // The search box narrows the results like any other filter, so it counts
2603
+ // towards offering Clear all — otherwise a search with no filters set leaves
2604
+ // the reader with a narrowed table and no single way back.
2605
+ const hasFilters = filters.length > 0 || query !== '';
2606
+
2607
+ // Hoisted so the accessible name and the tooltip can't drift apart as the
2608
+ // mode flips.
2609
+ const powerSearchLabel = isPowerSearch
2610
+ ? 'Switch to filter tokens'
2611
+ : 'Advanced search';
2612
+
2613
+ /**
2614
+ * Declared once and rendered in whichever bar is on screen: alongside the
2615
+ * filter controls it swaps, and in the toolbar while power search is up —
2616
+ * that bar has no filter row to sit in, and without it there would be no way
2617
+ * back short of picking a saved view.
2618
+ *
2619
+ * `tooltip` is stated rather than left to isIconOnly: the automatic tooltip
2620
+ * the prop documents never reaches Button, so an icon-only toggle shows
2621
+ * nothing on hover without it.
2622
+ */
2623
+ const powerSearchToggle = (
2624
+ <ToggleButton
2625
+ label={powerSearchLabel}
2626
+ tooltip={powerSearchLabel}
2627
+ isIconOnly
2628
+ size="sm"
2629
+ isPressed={isPowerSearch}
2630
+ icon={<Icon icon={SlidersHorizontal} size="sm" />}
2631
+ pressedIcon={<Icon icon={SlidersHorizontalFilled} size="sm" />}
2632
+ onPressedChange={next => setIsPowerSearch(next)}
2633
+ />
2634
+ );
2635
+
2636
+ const activeJob = allJobs.find(j => j.id === activeJobId) ?? null;
2637
+ /** Other jobs at the same site, newest first — the panel's history list. */
2638
+ const siteHistory = useMemo(
2639
+ () =>
2640
+ activeJob == null
2641
+ ? []
2642
+ : allJobs
2643
+ .filter(
2644
+ j => j.customer === activeJob.customer && j.id !== activeJob.id,
2645
+ )
2646
+ .sort((a, b) => b.scheduledAt.localeCompare(a.scheduledAt)),
2647
+ [activeJob],
2648
+ );
2649
+
2650
+ // ---------------------------------------------------------------------------
2651
+ // Columns panel
2652
+ // ---------------------------------------------------------------------------
2653
+
2654
+ /**
2655
+ * The reader's own order, which is what reordering acts on — so it is the
2656
+ * stored list, not a derived one.
2657
+ */
2658
+ const displayedColumns = view.columnKeys;
2659
+
2660
+ const availableColumns = useMemo(
2661
+ () => ALL_COLUMN_KEYS.filter(k => !view.columnKeys.includes(k)),
2662
+ [view.columnKeys],
2663
+ );
2664
+
2665
+ const setReorderSession = (next: ColumnReorderSession | null) => {
2666
+ reorderSessionRef.current = next;
2667
+ setReorderSessionState(next);
2668
+ };
2669
+
2670
+ const commitColumnOrder = (nextKeys: readonly string[]) => {
2671
+ updateView(v => ({...v, columnKeys: [...nextKeys]}));
2672
+ };
2673
+
2674
+ const moveColumn = (key: string, requestedIndex: number) => {
2675
+ const from = displayedColumns.indexOf(key);
2676
+ const to = Math.max(
2677
+ 0,
2678
+ Math.min(displayedColumns.length - 1, requestedIndex),
2679
+ );
2680
+ if (from < 0 || to === from) {
2681
+ return false;
2682
+ }
2683
+ const nextKeys = [...displayedColumns];
2684
+ nextKeys.splice(from, 1);
2685
+ nextKeys.splice(to, 0, key);
2686
+ commitColumnOrder(nextKeys);
2687
+ return true;
2688
+ };
2689
+
2690
+ /**
2691
+ * A click with no drag behind it picks the row up for the keyboard, and a
2692
+ * second one drops it. That gives the grip the same two-step a screen reader
2693
+ * is told about, without stranding a pointer user who clicked it by habit.
2694
+ */
2695
+ const handleGripClick = (key: string) => {
2696
+ if (suppressGripClickRef.current) {
2697
+ suppressGripClickRef.current = false;
2698
+ return;
2699
+ }
2700
+ const index = displayedColumns.indexOf(key);
2701
+ if (reorderSessionRef.current?.key === key) {
2702
+ setReorderSession(null);
2703
+ announce(
2704
+ `${COLUMN_LABELS[key]} dropped at position ${index + 1} of ${displayedColumns.length}.`,
2705
+ );
2706
+ return;
2707
+ }
2708
+ setReorderSession({
2709
+ key,
2710
+ mode: 'keyboard',
2711
+ originalKeys: [...displayedColumns],
2712
+ fromIndex: index,
2713
+ toIndex: index,
2714
+ });
2715
+ announce(
2716
+ `${COLUMN_LABELS[key]} picked up, position ${index + 1} of ${displayedColumns.length}. Use the arrow keys to move it, Space or Enter to drop, or Escape to cancel.`,
2717
+ );
2718
+ };
2719
+
2720
+ const handleGripKeyDown = (
2721
+ event: ReactKeyboardEvent<HTMLButtonElement>,
2722
+ key: string,
2723
+ ) => {
2724
+ const session = reorderSessionRef.current;
2725
+ if (event.key === 'Escape' && session?.key === key) {
2726
+ // Escape is the popover's own close key, so a cancel has to end here or
2727
+ // the panel disappears out from under the row being put back.
2728
+ event.preventDefault();
2729
+ event.stopPropagation();
2730
+ // Only write if something actually moved: updateView steps off the
2731
+ // active saved view, and abandoning a pick-up has changed nothing.
2732
+ if (
2733
+ session.originalKeys.join() !== displayedColumns.join() &&
2734
+ displayedColumns.includes(key)
2735
+ ) {
2736
+ commitColumnOrder(session.originalKeys);
2737
+ }
2738
+ setReorderSession(null);
2739
+ announce(`${COLUMN_LABELS[key]} move cancelled.`);
2740
+ return;
2741
+ }
2742
+ const index = displayedColumns.indexOf(key);
2743
+ const targets: Record<string, number> = {
2744
+ ArrowUp: index - 1,
2745
+ ArrowDown: index + 1,
2746
+ Home: 0,
2747
+ End: displayedColumns.length - 1,
2748
+ };
2749
+ const target = targets[event.key];
2750
+ if (target == null) {
2751
+ return;
2752
+ }
2753
+ // Arrow keys move the row whether or not it has been picked up first: the
2754
+ // pick-up is what a screen reader is offered, not a gate on the shortcut.
2755
+ event.preventDefault();
2756
+ if (moveColumn(key, target)) {
2757
+ announce(
2758
+ `${COLUMN_LABELS[key]}, position ${Math.max(0, Math.min(displayedColumns.length - 1, target)) + 1} of ${displayedColumns.length}.`,
2759
+ );
2760
+ }
2761
+ };
2762
+
2763
+ const handleGripPointerDown = (
2764
+ event: ReactPointerEvent<HTMLButtonElement>,
2765
+ key: string,
2766
+ ) => {
2767
+ if (event.button !== 0 || columnRowRefs.current.get(key) == null) {
2768
+ return;
2769
+ }
2770
+ suppressGripClickRef.current = true;
2771
+ event.currentTarget.setPointerCapture?.(event.pointerId);
2772
+ const index = displayedColumns.indexOf(key);
2773
+ setReorderSession({
2774
+ key,
2775
+ mode: 'pointer',
2776
+ originalKeys: [...displayedColumns],
2777
+ fromIndex: index,
2778
+ toIndex: index,
2779
+ pointerId: event.pointerId,
2780
+ pointerStartY: event.clientY,
2781
+ hasPointerMoved: false,
2782
+ });
2783
+ };
2784
+
2785
+ const handleGripPointerMove = (
2786
+ event: ReactPointerEvent<HTMLButtonElement>,
2787
+ ) => {
2788
+ const session = reorderSessionRef.current;
2789
+ if (session?.mode !== 'pointer' || session.pointerId !== event.pointerId) {
2790
+ return;
2791
+ }
2792
+ const travel = Math.abs(event.clientY - (session.pointerStartY ?? 0));
2793
+ if (session.hasPointerMoved !== true && travel < REORDER_DRAG_THRESHOLD) {
2794
+ return;
2795
+ }
2796
+ // The dragged row is still in the list at its old height, so the insertion
2797
+ // point is measured against the rows that would remain without it.
2798
+ const remaining = session.originalKeys.filter(k => k !== session.key);
2799
+ let toIndex = remaining.length;
2800
+ for (let i = 0; i < remaining.length; i += 1) {
2801
+ const row = columnRowRefs.current.get(remaining[i]);
2802
+ if (row == null) {
2803
+ continue;
2804
+ }
2805
+ const bounds = row.getBoundingClientRect();
2806
+ if (event.clientY < bounds.top + bounds.height / 2) {
2807
+ toIndex = i;
2808
+ break;
2809
+ }
2810
+ }
2811
+ if (session.hasPointerMoved === true && session.toIndex === toIndex) {
2812
+ return;
2813
+ }
2814
+ setReorderSession({...session, hasPointerMoved: true, toIndex});
2815
+ };
2816
+
2817
+ const handleGripPointerEnd = (
2818
+ event: ReactPointerEvent<HTMLButtonElement>,
2819
+ cancelled: boolean,
2820
+ ) => {
2821
+ const session = reorderSessionRef.current;
2822
+ if (session?.mode !== 'pointer' || session.pointerId !== event.pointerId) {
2823
+ return;
2824
+ }
2825
+ if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
2826
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
2827
+ }
2828
+ setReorderSession(null);
2829
+ // A press that never travelled is a click, not a drag — let it through so
2830
+ // the grip still picks up for the keyboard.
2831
+ if (cancelled || session.hasPointerMoved !== true) {
2832
+ suppressGripClickRef.current = false;
2833
+ return;
2834
+ }
2835
+ // The click lands after this handler, so the flag has to outlive the turn.
2836
+ setTimeout(() => {
2837
+ suppressGripClickRef.current = false;
2838
+ }, 0);
2839
+ if (session.toIndex === session.fromIndex) {
2840
+ return;
2841
+ }
2842
+ const nextKeys = [...session.originalKeys];
2843
+ nextKeys.splice(session.fromIndex, 1);
2844
+ nextKeys.splice(session.toIndex, 0, session.key);
2845
+ commitColumnOrder(nextKeys);
2846
+ announce(
2847
+ `${COLUMN_LABELS[session.key]} dropped at position ${session.toIndex + 1} of ${session.originalKeys.length}.`,
2848
+ );
2849
+ };
2850
+
2851
+ /**
2852
+ * Which row the insertion rule hangs off, and on which side. Suppressed while
2853
+ * the drop would land where the row already is, so a drag that has gone
2854
+ * nowhere does not draw a line promising a move.
2855
+ */
2856
+ const columnDropPlacement = useMemo(() => {
2857
+ if (
2858
+ reorderSession?.mode !== 'pointer' ||
2859
+ reorderSession.hasPointerMoved !== true ||
2860
+ reorderSession.toIndex === reorderSession.fromIndex
2861
+ ) {
2862
+ return null;
2863
+ }
2864
+ const remaining = reorderSession.originalKeys.filter(
2865
+ k => k !== reorderSession.key,
2866
+ );
2867
+ const before = remaining[reorderSession.toIndex];
2868
+ if (before != null) {
2869
+ return {key: before, position: 'before' as const};
2870
+ }
2871
+ const after = remaining[remaining.length - 1];
2872
+ return after == null ? null : {key: after, position: 'after' as const};
2873
+ }, [reorderSession]);
2874
+
2875
+ // ---------------------------------------------------------------------------
2876
+ // Filter bar pieces
2877
+ // ---------------------------------------------------------------------------
2878
+
2879
+ const renderFilterControl = (field: FilterField) => {
2880
+ const active = filters.find(f => f.field === field.key);
2881
+ const value = active
2882
+ ? String((active.value as {value?: unknown}).value ?? '')
2883
+ : '';
2884
+
2885
+ return (
2886
+ <Selector
2887
+ key={field.key}
2888
+ label={`${field.label} filter`}
2889
+ isLabelHidden
2890
+ // Unset the trigger reads as the bare field name; set, renderValue
2891
+ // expands it to the whole clause, so the closed trigger stays the chip.
2892
+ placeholder={field.label}
2893
+ size="sm"
2894
+ hasClear
2895
+ options={[...field.options]}
2896
+ value={value === '' ? null : value}
2897
+ renderValue={option =>
2898
+ `${field.label} ${field.operatorLabel} ${option.label ?? option.value}`
2899
+ }
2900
+ xstyle={active ? styles.filterFill : undefined}
2901
+ onChange={next =>
2902
+ setFieldFilter(
2903
+ field.key,
2904
+ next == null || next === ''
2905
+ ? null
2906
+ : {
2907
+ field: field.key,
2908
+ operator: field.operator,
2909
+ value:
2910
+ field.valueType === 'integer'
2911
+ ? {type: 'integer', value: Number(next)}
2912
+ : {type: 'enum', value: next},
2913
+ },
2914
+ )
2915
+ }
2916
+ />
2917
+ );
2918
+ };
2919
+
2920
+ /**
2921
+ * One flat array rather than inline JSX: OverflowList hands its renderer the
2922
+ * *indices* of the clauses it had to hide, so the row and the overflow menu
2923
+ * have to be reading from the same list for those indices to mean anything.
2924
+ */
2925
+ const filterControls = [
2926
+ ...PRESET_FILTERS.map(preset => {
2927
+ const isPressed = filters.some(
2928
+ f => JSON.stringify(f) === JSON.stringify(preset.filter),
2929
+ );
2930
+ return (
2931
+ <ToggleButton
2932
+ key={preset.key}
2933
+ label={preset.label}
2934
+ size="sm"
2935
+ isPressed={isPressed}
2936
+ // ToggleButton is a ghost Button underneath, so unpressed it has no
2937
+ // chrome at all; the border is what puts it in the same family as
2938
+ // the unset selectors. Pressed, its own overlay supplies the fill.
2939
+ xstyle={[
2940
+ styles.filterChrome,
2941
+ isPressed ? styles.filterLabelValue : styles.filterLabelEmpty,
2942
+ !isPressed && styles.filterSurface,
2943
+ ]}
2944
+ onPressedChange={() => togglePreset(preset.filter)}
2945
+ />
2946
+ );
2947
+ }),
2948
+ ...MULTI_FILTER_FIELDS.map(field => (
2949
+ <MultiSelector
2950
+ key={field.key}
2951
+ label={`${field.label} filter`}
2952
+ isLabelHidden
2953
+ placeholder={field.label}
2954
+ size="sm"
2955
+ hasClear
2956
+ triggerDisplay="labels"
2957
+ // labels mode spells out three before it counts, which is right for a
2958
+ // selector with a row to itself and wrong here: three filters each
2959
+ // three labels wide push the rest of the bar into the overflow menu.
2960
+ // One name and a count keeps every filter on the row and still says
2961
+ // which filter it is.
2962
+ formatValue={items =>
2963
+ items.length > 1
2964
+ ? `${items[0].label}, +${items.length - 1}`
2965
+ : items[0].label
2966
+ }
2967
+ options={[...field.options]}
2968
+ value={multiValues[field.key]}
2969
+ xstyle={
2970
+ multiValues[field.key].length > 0 ? styles.filterFill : undefined
2971
+ }
2972
+ onChange={next => setMultiValues(field.key, next)}
2973
+ />
2974
+ )),
2975
+ ...FILTER_FIELDS.map(renderFilterControl),
2976
+ // Quote is a range, not a one-of-many pick, so it takes the shell built
2977
+ // for custom selection surfaces: ComplexSelector owns the same field,
2978
+ // trigger and popover anatomy the selectors beside it use, and the slider
2979
+ // owns only its own content. `value` is the draft rather than the
2980
+ // committed range — dragging has to move the track, and the filter array
2981
+ // is not written until the thumb is released.
2982
+ <ComplexSelector<[number, number]>
2983
+ key="quote"
2984
+ label="Quote filter"
2985
+ isLabelHidden
2986
+ placeholder="Quote"
2987
+ triggerLabel={quoteLabel}
2988
+ size="sm"
2989
+ value={quoteDraft}
2990
+ onChange={commitQuote}
2991
+ contentXstyle={styles.quotePopover}
2992
+ xstyle={hasQuoteFilter ? styles.filterFill : undefined}>
2993
+ {(range, onChange, close) => (
2994
+ <VStack gap={4}>
2995
+ <VStack gap={0}>
2996
+ <Text type="label">Quote range</Text>
2997
+ <Text type="large" hasTabularNumbers>
2998
+ {formatMoney(range[0])} – {formatMoney(range[1])}
2999
+ </Text>
3000
+ </VStack>
3001
+
3002
+ <VStack gap={1}>
3003
+ <Slider
3004
+ label="Quote range"
3005
+ isLabelHidden
3006
+ value={range}
3007
+ min={QUOTE_MIN}
3008
+ max={QUOTE_MAX}
3009
+ step={QUOTE_STEP}
3010
+ valueDisplay="none"
3011
+ formatValue={formatMoney}
3012
+ minStepsBetweenThumbs={1}
3013
+ width="100%"
3014
+ onChange={setQuoteDraft}
3015
+ onChangeEnd={onChange}
3016
+ />
3017
+ {/* The bounds are their own row rather than Slider `marks`:
3018
+ mark labels are absolutely positioned and contribute no
3019
+ height, so they hang into whatever follows the track. */}
3020
+ <HStack hAlign="between" vAlign="center">
3021
+ <Text type="supporting" color="secondary">
3022
+ {formatMoney(QUOTE_MIN)}
3023
+ </Text>
3024
+ <Text type="supporting" color="secondary">
3025
+ {formatMoney(QUOTE_MAX)}
3026
+ </Text>
3027
+ </HStack>
3028
+ </VStack>
3029
+
3030
+ <Divider />
3031
+
3032
+ {/* ComplexSelector has no `hasClear`, so unlike the selectors
3033
+ beside it the clear lives in the surface rather than the
3034
+ trigger. */}
3035
+ <HStack gap={2} hAlign="between" vAlign="center">
3036
+ <Button
3037
+ label="Clear"
3038
+ variant="ghost"
3039
+ size="sm"
3040
+ isDisabled={!hasQuoteFilter}
3041
+ onClick={() => onChange([QUOTE_MIN, QUOTE_MAX])}
3042
+ />
3043
+ <Button
3044
+ label="Done"
3045
+ variant="secondary"
3046
+ size="sm"
3047
+ onClick={close}
3048
+ />
3049
+ </HStack>
3050
+ </VStack>
3051
+ )}
3052
+ </ComplexSelector>,
3053
+ ];
3054
+
3055
+ const filterBar = (
3056
+ <HStack
3057
+ gap={2}
3058
+ vAlign="center"
3059
+ wrap="wrap"
3060
+ minHeight={32}
3061
+ xstyle={styles.filterRow}>
3062
+ {/* Search leads the row: it is the broadest filter, and anchoring it at
3063
+ the start keeps a fixed-width control on the first line. It sits
3064
+ outside the overflow list so it is never the clause that collapses —
3065
+ a hidden search box reads as a missing feature, not a folded one. */}
3066
+ <TextInput
3067
+ label="Search jobs"
3068
+ isLabelHidden
3069
+ placeholder="Job name"
3070
+ size="sm"
3071
+ value={query}
3072
+ onChange={setQuery}
3073
+ startIcon={Search}
3074
+ xstyle={styles.searchInput}
3075
+ />
3076
+
3077
+ {filterControls}
3078
+
3079
+ {/* The row's readout, kept as one group directly after the last clause
3080
+ rather than pushed to the far edge. Grouping also stops the separator
3081
+ strandng itself on a line of its own when the row wraps.
3082
+
3083
+ NOTE: this is why there is no overflow list here. OverflowList reads
3084
+ its own offsetWidth to decide what fits, so it only reports an
3085
+ overflow while it owns the row's leftover width — and owning that
3086
+ width is exactly what pushes this group to the far right. Sized to
3087
+ its content instead, it shrinks as it collapses, so its measurement
3088
+ shrinks with it and it never re-expands: once collapsed at 600px it
3089
+ stayed collapsed all the way back out to 1680px. Wrapping is the
3090
+ behaviour that keeps this group where it belongs. */}
3091
+ <HStack gap={2} vAlign="center" xstyle={styles.filterMeta}>
3092
+ {powerSearchToggle}
3093
+
3094
+ <Text type="supporting" color="secondary">
3095
+ {results.length} {results.length === 1 ? 'result' : 'results'}
3096
+ </Text>
3097
+
3098
+ {hasFilters && (
3099
+ <>
3100
+ <Text type="supporting" color="secondary">
3101
+
3102
+ </Text>
3103
+ {/* Href-less, so Link renders a <button>: this resets the filter
3104
+ array, it does not navigate. */}
3105
+ <Link type="supporting" onClick={clearAll}>
3106
+ Clear all
3107
+ </Link>
3108
+ </>
3109
+ )}
3110
+ </HStack>
3111
+ </HStack>
3112
+ );
3113
+
3114
+ const savedViewsBar = (
3115
+ <HStack
3116
+ gap={2}
3117
+ vAlign="center"
3118
+ wrap="wrap"
3119
+ minHeight={32}
3120
+ xstyle={styles.bar}>
3121
+ <Text type="label">Saved views:</Text>
3122
+ {/* Exactly one of these is engaged at a time, so All is a member of the
3123
+ same group rather than a differently-shaped reset button. It carries
3124
+ no icon because it stands for the absence of a saved view. */}
3125
+ <ToggleButton
3126
+ label="All"
3127
+ size="sm"
3128
+ isPressed={activeSavedViewId == null}
3129
+ xstyle={[
3130
+ styles.filterChrome,
3131
+ activeSavedViewId == null
3132
+ ? styles.filterLabelValue
3133
+ : styles.filterLabelEmpty,
3134
+ activeSavedViewId != null && styles.filterSurface,
3135
+ ]}
3136
+ onPressedChange={() => applySavedView(null)}
3137
+ />
3138
+ {savedViews.map(saved => {
3139
+ const isPressed = activeSavedViewId === saved.id;
3140
+ return (
3141
+ <ToggleButton
3142
+ key={saved.id}
3143
+ label={saved.name}
3144
+ size="sm"
3145
+ isPressed={isPressed}
3146
+ icon={<Icon icon={Bookmark} size="sm" />}
3147
+ // Same chrome the filter-bar presets wear: a ghost Button has none
3148
+ // unpressed, and the border is what keeps these in the same family
3149
+ // as the unset selectors in the row above.
3150
+ xstyle={[
3151
+ styles.filterChrome,
3152
+ isPressed ? styles.filterLabelValue : styles.filterLabelEmpty,
3153
+ !isPressed && styles.filterSurface,
3154
+ ]}
3155
+ // Unpressing lands on the state the All chip already models:
3156
+ // applySavedView(null) drops the active id and its filters
3157
+ // together, so the two controls can never disagree about what is
3158
+ // applied.
3159
+ onPressedChange={next => applySavedView(next ? saved : null)}
3160
+ />
3161
+ );
3162
+ })}
3163
+ {activeSavedViewId != null && (
3164
+ <IconButton
3165
+ label="Edit saved view"
3166
+ tooltip="Edit saved view"
3167
+ variant="ghost"
3168
+ size="sm"
3169
+ icon={<Icon icon={Pencil} size="sm" />}
3170
+ onClick={() => {
3171
+ const found = savedViews.find(s => s.id === activeSavedViewId);
3172
+ if (found) {
3173
+ setEditing({...found});
3174
+ }
3175
+ }}
3176
+ />
3177
+ )}
3178
+ </HStack>
3179
+ );
3180
+
3181
+ const bulkBar = (
3182
+ <Section
3183
+ variant="muted"
3184
+ paddingInline={3}
3185
+ paddingBlock={1.5}
3186
+ xstyle={[styles.bulkBand, styles.bulkBandEnter]}>
3187
+ <HStack
3188
+ gap={3}
3189
+ vAlign="center"
3190
+ wrap="wrap"
3191
+ minHeight={32}
3192
+ xstyle={styles.bar}>
3193
+ <StackItem size="fill">
3194
+ <HStack gap={1} vAlign="center">
3195
+ {BULK_EDIT_ACTIONS.map(action => (
3196
+ <Button
3197
+ key={action.key}
3198
+ label={action.label}
3199
+ variant="ghost"
3200
+ size="sm"
3201
+ icon={action.icon}
3202
+ onClick={() => action.onClick(selectedJobs)}
3203
+ />
3204
+ ))}
3205
+ </HStack>
3206
+ </StackItem>
3207
+
3208
+ {/* Grouped so the separator can't strand itself on a line of its own
3209
+ once the actions grow wide enough to break the bar. */}
3210
+ <HStack gap={3} vAlign="center">
3211
+ <Text type="body">
3212
+ {selectedCount} {selectedCount === 1 ? 'job' : 'jobs'} selected
3213
+ </Text>
3214
+ <Text type="supporting" color="secondary">
3215
+
3216
+ </Text>
3217
+ <Button
3218
+ label="Unselect All"
3219
+ variant="ghost"
3220
+ size="sm"
3221
+ onClick={() => setSelectedKeys(new Set())}
3222
+ />
3223
+ </HStack>
3224
+ </HStack>
3225
+ </Section>
3226
+ );
3227
+
3228
+ // ---------------------------------------------------------------------------
3229
+ // View options panel
3230
+ // ---------------------------------------------------------------------------
3231
+
3232
+ const viewPanelBody = () => {
3233
+ switch (viewSection) {
3234
+ case 'columns':
3235
+ return (
3236
+ <VStack gap={0} minHeight={0} xstyle={styles.transferRoot}>
3237
+ {/* No column preset here: a saved view stores the whole view
3238
+ configuration, columns included, so a second preset picker
3239
+ scoped to columns alone would be a rival source of truth.
3240
+ No search either: both panels together hold the full column
3241
+ set, which is short enough to read at a glance. */}
3242
+ <VisuallyHidden id={`${columnPanelId}-reorder-hint`}>
3243
+ Press Space or Enter to pick up a column. Use the arrow keys to
3244
+ move it, Space or Enter to drop, or Escape to cancel.
3245
+ </VisuallyHidden>
3246
+
3247
+ <HStack gap={0} minHeight={0} xstyle={styles.transferPanels}>
3248
+ <VStack
3249
+ gap={0}
3250
+ role="group"
3251
+ aria-labelledby={`${columnPanelId}-displayed`}
3252
+ xstyle={styles.transferPanel}>
3253
+ <HStack
3254
+ gap={2}
3255
+ vAlign="center"
3256
+ hAlign="between"
3257
+ paddingBlock={2}
3258
+ xstyle={[
3259
+ styles.transferPanelHeader,
3260
+ styles.transferPadStart,
3261
+ ]}>
3262
+ <Text
3263
+ id={`${columnPanelId}-displayed`}
3264
+ type="label"
3265
+ color="secondary">
3266
+ Displayed columns
3267
+ </Text>
3268
+ <Button
3269
+ label="Restore"
3270
+ variant="ghost"
3271
+ size="sm"
3272
+ xstyle={styles.transferHeaderAction}
3273
+ onClick={() =>
3274
+ updateView(v => ({
3275
+ ...v,
3276
+ columnKeys: DEFAULT_COLUMN_KEYS,
3277
+ }))
3278
+ }
3279
+ />
3280
+ </HStack>
3281
+ <VStack gap={0} xstyle={styles.transferPanelBody}>
3282
+ {displayedColumns.length === 0 ? (
3283
+ <VStack
3284
+ gap={0}
3285
+ vAlign="center"
3286
+ hAlign="center"
3287
+ minHeight="100%"
3288
+ paddingBlock={4}
3289
+ xstyle={[styles.transferEmpty, styles.transferPadStart]}>
3290
+ <Text type="supporting" color="secondary">
3291
+ No columns are displayed.
3292
+ </Text>
3293
+ </VStack>
3294
+ ) : (
3295
+ <List
3296
+ density="compact"
3297
+ header={
3298
+ <VisuallyHidden>Displayed columns</VisuallyHidden>
3299
+ }>
3300
+ {displayedColumns.map(key => {
3301
+ const isLocked = key === LOCKED_COLUMN_KEY;
3302
+ const isPicked =
3303
+ reorderSession?.key === key &&
3304
+ reorderSession.mode === 'keyboard';
3305
+ const isDragging =
3306
+ reorderSession?.key === key &&
3307
+ reorderSession.mode === 'pointer' &&
3308
+ reorderSession.hasPointerMoved === true;
3309
+ const drop =
3310
+ columnDropPlacement?.key === key
3311
+ ? columnDropPlacement.position
3312
+ : null;
3313
+ return (
3314
+ <Item
3315
+ key={key}
3316
+ as="li"
3317
+ density="compact"
3318
+ ref={node => {
3319
+ if (node == null) {
3320
+ columnRowRefs.current.delete(key);
3321
+ } else {
3322
+ columnRowRefs.current.set(key, node);
3323
+ }
3324
+ }}
3325
+ label={COLUMN_LABELS[key]}
3326
+ startContent={
3327
+ <IconButton
3328
+ label={`Reorder ${COLUMN_LABELS[key]}`}
3329
+ aria-describedby={`${columnPanelId}-reorder-hint`}
3330
+ aria-pressed={isPicked}
3331
+ variant="ghost"
3332
+ size="sm"
3333
+ icon={<Icon icon={GripVertical} size="sm" />}
3334
+ xstyle={[
3335
+ styles.transferGrip,
3336
+ reorderStyles.handle,
3337
+ (isPicked || isDragging) &&
3338
+ reorderStyles.handleActive,
3339
+ ]}
3340
+ onClick={() => handleGripClick(key)}
3341
+ onKeyDown={event =>
3342
+ handleGripKeyDown(event, key)
3343
+ }
3344
+ onPointerDown={event =>
3345
+ handleGripPointerDown(event, key)
3346
+ }
3347
+ onPointerMove={handleGripPointerMove}
3348
+ onPointerUp={event =>
3349
+ handleGripPointerEnd(event, false)
3350
+ }
3351
+ onPointerCancel={event =>
3352
+ handleGripPointerEnd(event, true)
3353
+ }
3354
+ onLostPointerCapture={event =>
3355
+ handleGripPointerEnd(event, true)
3356
+ }
3357
+ />
3358
+ }
3359
+ endContent={
3360
+ <IconButton
3361
+ label={`Remove ${COLUMN_LABELS[key]}`}
3362
+ variant="ghost"
3363
+ size="sm"
3364
+ isDisabled={isLocked}
3365
+ // A control that refuses without saying why is
3366
+ // a dead end; the tooltip is the explanation.
3367
+ tooltip={
3368
+ isLocked ? LOCKED_COLUMN_MESSAGE : undefined
3369
+ }
3370
+ icon={<Icon icon={X} size="sm" />}
3371
+ xstyle={styles.transferEndAction}
3372
+ onClick={() =>
3373
+ updateView(v => ({
3374
+ ...v,
3375
+ columnKeys: v.columnKeys.filter(
3376
+ k => k !== key,
3377
+ ),
3378
+ }))
3379
+ }
3380
+ />
3381
+ }
3382
+ xstyle={[
3383
+ styles.transferItem,
3384
+ styles.transferPadStart,
3385
+ isPicked && styles.transferItemPicked,
3386
+ isDragging && reorderStyles.source,
3387
+ drop === 'before' && reorderStyles.dropBefore,
3388
+ drop === 'after' && reorderStyles.dropAfter,
3389
+ ]}
3390
+ />
3391
+ );
3392
+ })}
3393
+ </List>
3394
+ )}
3395
+ </VStack>
3396
+ </VStack>
3397
+
3398
+ <VStack
3399
+ gap={0}
3400
+ role="group"
3401
+ aria-labelledby={`${columnPanelId}-available`}
3402
+ xstyle={[styles.transferPanel, styles.transferPanelDivider]}>
3403
+ <HStack
3404
+ gap={2}
3405
+ vAlign="center"
3406
+ hAlign="between"
3407
+ paddingBlock={2}
3408
+ xstyle={[styles.transferPanelHeader, styles.transferPadEnd]}>
3409
+ <Text
3410
+ id={`${columnPanelId}-available`}
3411
+ type="label"
3412
+ color="secondary">
3413
+ Available columns
3414
+ </Text>
3415
+ <Button
3416
+ label="Select all"
3417
+ variant="ghost"
3418
+ size="sm"
3419
+ xstyle={styles.transferHeaderAction}
3420
+ isDisabled={
3421
+ view.columnKeys.length === ALL_COLUMN_KEYS.length
3422
+ }
3423
+ onClick={() =>
3424
+ updateView(v => ({
3425
+ ...v,
3426
+ // Appended, not re-sorted: a bulk add is still an add,
3427
+ // and it has no business restating the reader's order.
3428
+ columnKeys: [
3429
+ ...v.columnKeys,
3430
+ ...ALL_COLUMN_KEYS.filter(
3431
+ k => !v.columnKeys.includes(k),
3432
+ ),
3433
+ ],
3434
+ }))
3435
+ }
3436
+ />
3437
+ </HStack>
3438
+ <VStack gap={0} xstyle={styles.transferPanelBody}>
3439
+ {availableColumns.length === 0 ? (
3440
+ <VStack
3441
+ gap={0}
3442
+ vAlign="center"
3443
+ hAlign="center"
3444
+ minHeight="100%"
3445
+ paddingBlock={4}
3446
+ xstyle={[styles.transferEmpty, styles.transferPadEnd]}>
3447
+ <Text type="supporting" color="secondary">
3448
+ All columns are displayed.
3449
+ </Text>
3450
+ </VStack>
3451
+ ) : (
3452
+ <List
3453
+ density="compact"
3454
+ header={
3455
+ <VisuallyHidden>Available columns</VisuallyHidden>
3456
+ }>
3457
+ {availableColumns.map(key => (
3458
+ <Item
3459
+ key={key}
3460
+ as="li"
3461
+ density="compact"
3462
+ label={COLUMN_LABELS[key]}
3463
+ endContent={
3464
+ <IconButton
3465
+ label={`Add ${COLUMN_LABELS[key]}`}
3466
+ variant="ghost"
3467
+ size="sm"
3468
+ icon={<Icon icon={Plus} size="sm" />}
3469
+ xstyle={styles.transferEndAction}
3470
+ onClick={() =>
3471
+ updateView(v => ({
3472
+ ...v,
3473
+ // Appended rather than slotted back into
3474
+ // canonical order: the reader's own ordering
3475
+ // is the thing being edited, and re-sorting
3476
+ // here would silently discard it every time a
3477
+ // column came back.
3478
+ columnKeys: [...v.columnKeys, key],
3479
+ }))
3480
+ }
3481
+ />
3482
+ }
3483
+ xstyle={[styles.transferItem, styles.transferPadEnd]}
3484
+ />
3485
+ ))}
3486
+ </List>
3487
+ )}
3488
+ </VStack>
3489
+ </VStack>
3490
+ </HStack>
3491
+ </VStack>
3492
+ );
3493
+
3494
+ case 'density':
3495
+ return (
3496
+ <VStack gap={0} paddingInline={4} paddingBlockEnd={4}>
3497
+ <RadioList
3498
+ label="Density"
3499
+ isLabelHidden
3500
+ value={view.density}
3501
+ onChange={value =>
3502
+ updateView(v => ({...v, density: value as Density}))
3503
+ }>
3504
+ {DENSITY_OPTIONS.map(option => (
3505
+ <RadioListItem
3506
+ key={option.value}
3507
+ value={option.value}
3508
+ label={option.label}
3509
+ />
3510
+ ))}
3511
+ </RadioList>
3512
+ </VStack>
3513
+ );
3514
+
3515
+ case 'sticky':
3516
+ return (
3517
+ <VStack gap={4} paddingInline={4} paddingBlockEnd={4}>
3518
+ <RadioList
3519
+ label="First columns"
3520
+ value={view.stickyStart}
3521
+ onChange={value =>
3522
+ updateView(v => ({...v, stickyStart: value as StickyEdge}))
3523
+ }>
3524
+ {STICKY_START_OPTIONS.map(option => (
3525
+ <RadioListItem
3526
+ key={option.value}
3527
+ value={option.value}
3528
+ label={option.label}
3529
+ />
3530
+ ))}
3531
+ </RadioList>
3532
+ <RadioList
3533
+ label="Last columns"
3534
+ value={view.stickyEnd}
3535
+ onChange={value =>
3536
+ updateView(v => ({...v, stickyEnd: value as StickyEdge}))
3537
+ }>
3538
+ {STICKY_END_OPTIONS.map(option => (
3539
+ <RadioListItem
3540
+ key={option.value}
3541
+ value={option.value}
3542
+ label={option.label}
3543
+ />
3544
+ ))}
3545
+ </RadioList>
3546
+ </VStack>
3547
+ );
3548
+
3549
+ case 'grouping':
3550
+ return (
3551
+ <VStack gap={0} paddingInline={4} paddingBlockEnd={4}>
3552
+ <RadioList
3553
+ label="Grouping"
3554
+ isLabelHidden
3555
+ value={view.grouping}
3556
+ onChange={value =>
3557
+ updateView(v => ({...v, grouping: value as GroupField}))
3558
+ }>
3559
+ {GROUPING_OPTIONS.map(option => (
3560
+ <RadioListItem
3561
+ key={option.value}
3562
+ value={option.value}
3563
+ label={option.label}
3564
+ />
3565
+ ))}
3566
+ </RadioList>
3567
+ </VStack>
3568
+ );
3569
+ }
3570
+ };
3571
+
3572
+ const activeSection = VIEW_SECTIONS.find(s => s.key === viewSection);
3573
+
3574
+ /**
3575
+ * The popover container is flush (see styles.viewPopoverSurface), so the
3576
+ * rail's divider runs the full height of the body. Nothing sits against a
3577
+ * popover edge: the rail and the pane each carry their own gutter.
3578
+ */
3579
+ const viewOptionsPopover = (
3580
+ <VStack gap={0}>
3581
+ <HStack gap={0} xstyle={styles.viewPopover}>
3582
+ <Section
3583
+ variant="transparent"
3584
+ padding={1}
3585
+ dividers={['end']}
3586
+ xstyle={styles.viewRail}>
3587
+ <VStack gap={1}>
3588
+ {VIEW_SECTIONS.map(section => (
3589
+ <Button
3590
+ key={section.key}
3591
+ label={section.label}
3592
+ variant={viewSection === section.key ? 'secondary' : 'ghost'}
3593
+ icon={section.icon}
3594
+ xstyle={styles.railItem}
3595
+ onClick={() => setViewSection(section.key)}
3596
+ />
3597
+ ))}
3598
+ </VStack>
3599
+ </Section>
3600
+
3601
+ <StackItem size="fill">
3602
+ {/* A stack rather than a Section: the pane carries no gutter of its
3603
+ own, so a child can run a rule the full width of it rather than
3604
+ stopping at a gutter it does not own, and everything that is not
3605
+ a divider re-states the 16px itself. That leaves Section with
3606
+ nothing to contribute here but an inner display:block wrapper,
3607
+ which is exactly what would stop the panels below from taking
3608
+ the pane's full height. */}
3609
+ <VStack gap={0} minHeight={0} xstyle={styles.viewPanel}>
3610
+ {/* The pane's gutter, restated here because the container carries
3611
+ none — that is what lets a rule below reach both edges. */}
3612
+ <VStack gap={0} padding={4} paddingBlockEnd={3}>
3613
+ <Heading level={3}>{activeSection?.title}</Heading>
3614
+ </VStack>
3615
+
3616
+ {viewPanelBody()}
3617
+ </VStack>
3618
+ </StackItem>
3619
+ </HStack>
3620
+ </VStack>
3621
+ );
3622
+
3623
+ // ---------------------------------------------------------------------------
3624
+ // Detail panel
3625
+ // ---------------------------------------------------------------------------
3626
+
3627
+ const detailPanel =
3628
+ activeJob == null ? undefined : (
3629
+ <>
3630
+ {/* The panel owns the separator, so the handle stays divider-less and
3631
+ hides its pill at rest rather than floating a stray stub. */}
3632
+ <ResizeHandle
3633
+ resizable={detailWidth.props}
3634
+ isReversed
3635
+ isAlwaysVisible={false}
3636
+ label="Resize job details"
3637
+ />
3638
+ <LayoutPanel
3639
+ resizable={detailWidth.props}
3640
+ hasDivider
3641
+ padding={0}
3642
+ label="Job details">
3643
+ {/* Panel padding is 0 so the dividers reach both edges; each section
3644
+ re-adds the 16px gutter to keep its content on the same line. */}
3645
+ <VStack gap={0} xstyle={styles.detailPanel}>
3646
+ <Section variant="transparent" padding={4}>
3647
+ <VStack gap={4}>
3648
+ <HStack gap={2}>
3649
+ <StackItem size="fill">
3650
+ <VStack gap={2}>
3651
+ <HStack gap={2} vAlign="center">
3652
+ <Badge
3653
+ variant={STATUS_META[activeJob.status].badge}
3654
+ label={STATUS_META[activeJob.status].label}
3655
+ />
3656
+ <StatusDot
3657
+ variant={PRIORITY_META[activeJob.priority].dot}
3658
+ label={`${PRIORITY_META[activeJob.priority].label} priority`}
3659
+ isPulsing={activeJob.priority === 'urgent'}
3660
+ />
3661
+ <Text type="supporting" color="secondary">
3662
+ {PRIORITY_META[activeJob.priority].label} ·{' '}
3663
+ {activeJob.id}
3664
+ </Text>
3665
+ </HStack>
3666
+ <Heading level={2}>{activeJob.summary}</Heading>
3667
+ </VStack>
3668
+ </StackItem>
3669
+ <IconButton
3670
+ label="Close details"
3671
+ variant="ghost"
3672
+ size="sm"
3673
+ icon={<Icon icon={X} size="sm" />}
3674
+ onClick={() => setActiveJobId(null)}
3675
+ />
3676
+ </HStack>
3677
+
3678
+ {/* Both take the full width, so flex shrink splits the row
3679
+ evenly however wide the panel is dragged. */}
3680
+ <HStack gap={2}>
3681
+ <Button
3682
+ label={NEXT_ACTION[activeJob.status]}
3683
+ size="sm"
3684
+ width="100%"
3685
+ />
3686
+ <Button
3687
+ label="Edit job"
3688
+ variant="secondary"
3689
+ size="sm"
3690
+ width="100%"
3691
+ />
3692
+ </HStack>
3693
+ </VStack>
3694
+ </Section>
3695
+
3696
+ <Divider />
3697
+
3698
+ <Section variant="transparent" padding={4}>
3699
+ <MetadataList
3700
+ columns="single"
3701
+ label={{position: 'start', width: 104}}>
3702
+ <MetadataListItem label="Customer">
3703
+ <Text type="body">{activeJob.customer}</Text>
3704
+ </MetadataListItem>
3705
+ <MetadataListItem label="Site address">
3706
+ <Text type="body">{activeJob.address}</Text>
3707
+ </MetadataListItem>
3708
+ <MetadataListItem label="Technician">
3709
+ <Text type="body">{activeJob.technician}</Text>
3710
+ </MetadataListItem>
3711
+ <MetadataListItem label="Scheduled">
3712
+ <Text type="body">
3713
+ {formatDate(activeJob.scheduledAt, true)},{' '}
3714
+ {formatTime(activeJob.scheduledAt)}
3715
+ </Text>
3716
+ </MetadataListItem>
3717
+ <MetadataListItem label="Equipment">
3718
+ <Text type="body">{activeJob.equipment}</Text>
3719
+ </MetadataListItem>
3720
+ <MetadataListItem label="Quote">
3721
+ <Text type="body">{formatMoney(activeJob.quoted)}</Text>
3722
+ </MetadataListItem>
3723
+ </MetadataList>
3724
+ </Section>
3725
+
3726
+ <Divider />
3727
+
3728
+ <Section variant="transparent" padding={4}>
3729
+ <VStack gap={2}>
3730
+ <Heading level={3}>Site history</Heading>
3731
+ {siteHistory.length === 0 ? (
3732
+ <Text type="supporting" color="secondary">
3733
+ No other visits on file for this site.
3734
+ </Text>
3735
+ ) : (
3736
+ <VStack gap={0}>
3737
+ {siteHistory.map(related => (
3738
+ <Item
3739
+ key={related.id}
3740
+ align="center"
3741
+ label={related.summary}
3742
+ labelLines={2}
3743
+ // The date leads: this is a list of visits to one site,
3744
+ // so when it happened is what tells them apart. The
3745
+ // badge takes the end slot the date used to hold —
3746
+ // a badge is too wide to lead a row this narrow.
3747
+ description={`${formatDate(related.scheduledAt, true)} · ${related.technician}`}
3748
+ endContent={
3749
+ <Badge
3750
+ variant={STATUS_META[related.status].badge}
3751
+ label={STATUS_META[related.status].label}
3752
+ />
3753
+ }
3754
+ onClick={() => setActiveJobId(related.id)}
3755
+ />
3756
+ ))}
3757
+ </VStack>
3758
+ )}
3759
+ </VStack>
3760
+ </Section>
3761
+ </VStack>
3762
+ </LayoutPanel>
3763
+ </>
3764
+ );
3765
+
3766
+ // ---------------------------------------------------------------------------
3767
+ // Render
3768
+ // ---------------------------------------------------------------------------
3769
+
3770
+ return (
3771
+ <>
3772
+ <Layout
3773
+ height="fill"
3774
+ padding={0}
3775
+ xstyle={styles.pageShell}
3776
+ end={detailPanel}
3777
+ header={
3778
+ <LayoutHeader hasDivider label="Job filters and table actions">
3779
+ <VStack gap={0} xstyle={styles.headerWrap}>
3780
+ {isLoading && (
3781
+ <ProgressBar
3782
+ label="Loading jobs"
3783
+ isLabelHidden
3784
+ isIndeterminate
3785
+ xstyle={styles.progress}
3786
+ />
3787
+ )}
3788
+
3789
+ <Section
3790
+ variant="transparent"
3791
+ padding={4}
3792
+ xstyle={styles.toolbarContainer}>
3793
+ <VStack gap={4}>
3794
+ {/* No page-title component publishes an end slot — Heading,
3795
+ Section and LayoutHeader all take content only — so the
3796
+ title and its action are a row, the same shape the other
3797
+ table templates use. */}
3798
+ <HStack gap={3} vAlign="center">
3799
+ <StackItem size="fill">
3800
+ <Heading level={1}>Service jobs</Heading>
3801
+ </StackItem>
3802
+ <Button
3803
+ label="New job"
3804
+ variant="primary"
3805
+ onClick={() => alert('New job')}
3806
+ />
3807
+ </HStack>
3808
+
3809
+ {selectedCount > 0 ? (
3810
+ bulkBar
3811
+ ) : (
3812
+ <HStack gap={3} vAlign="center" wrap="wrap">
3813
+ <StackItem size="fill" xstyle={styles.toolbarPrimary}>
3814
+ {isPowerSearch ? (
3815
+ <PowerSearch
3816
+ config={config}
3817
+ filters={filters}
3818
+ onChange={next => setFilters([...next])}
3819
+ placeholder='Try "quote > 1000" or "status is Overdue"'
3820
+ resultCount={results.length}
3821
+ />
3822
+ ) : isSavedViewsBarOpen ? (
3823
+ savedViewsBar
3824
+ ) : (
3825
+ filterBar
3826
+ )}
3827
+ </StackItem>
3828
+
3829
+ {/* One flex item rather than loose siblings: at narrow
3830
+ widths the whole cluster has to drop below the filter
3831
+ row together, and loose siblings would break up
3832
+ mid-cluster instead. */}
3833
+ <HStack
3834
+ gap={3}
3835
+ vAlign="center"
3836
+ xstyle={styles.toolbarEnd}>
3837
+ {/* Saved views mode is about picking a view, so the
3838
+ controls that edit one step aside. Only the toggle
3839
+ that got you here stays, which is also the way back. */}
3840
+ {!isSavedViewsBarOpen && (
3841
+ <>
3842
+ {/* Only while power search is up: the query bar has
3843
+ no filter row to carry the toggle, so this is the
3844
+ return trip. In token mode it sits by the result
3845
+ count instead, next to the controls it swaps.
3846
+
3847
+ Leads the cluster so it stays the leftmost control
3848
+ in both modes, rather than crossing to the far side
3849
+ of View options when the bar switches. */}
3850
+ {isPowerSearch && powerSearchToggle}
3851
+
3852
+ <Popover
3853
+ placement="below"
3854
+ alignment="end"
3855
+ width={660}
3856
+ label="View options"
3857
+ isOpen={isViewOpen}
3858
+ onOpenChange={setIsViewOpen}
3859
+ xstyle={styles.viewPopoverSurface}
3860
+ content={viewOptionsPopover}>
3861
+ {/* Flat rather than outlined: ghost is the
3862
+ borderless variant Button and Selector share, so
3863
+ this trigger reads as the same family of control
3864
+ as a ghost Selector — chevron and all — without
3865
+ the local filterChrome the bordered toggles in
3866
+ the row need.
3867
+
3868
+ Deliberately not a ComplexSelector, unlike the
3869
+ Quote control: this panel drives a whole
3870
+ ViewState and has no single value to hand a
3871
+ value-shaped component. */}
3872
+ <Button
3873
+ label="View options"
3874
+ variant="ghost"
3875
+ size="sm"
3876
+ endContent={
3877
+ <Icon icon={ChevronDown} size="sm" />
3878
+ }
3879
+ />
3880
+ </Popover>
3881
+
3882
+ {/* `tooltip` is stated rather than left to
3883
+ isIconOnly: the automatic tooltip the prop
3884
+ documents does not reach Button, so an icon-only
3885
+ button shows nothing on hover without it. Same for
3886
+ the Saved views toggle below. */}
3887
+ <IconButton
3888
+ label="Create saved view"
3889
+ tooltip="Create saved view"
3890
+ variant="ghost"
3891
+ size="sm"
3892
+ icon={<Icon icon={BookmarkPlus} size="sm" />}
3893
+ onClick={() => setCreatingName('')}
3894
+ />
3895
+ </>
3896
+ )}
3897
+
3898
+ <ToggleButton
3899
+ label="Saved views"
3900
+ tooltip="Saved views"
3901
+ isIconOnly
3902
+ size="sm"
3903
+ isPressed={isSavedViewsBarOpen}
3904
+ icon={<Icon icon={Bookmark} size="sm" />}
3905
+ pressedIcon={<Icon icon={BookmarkFilled} size="sm" />}
3906
+ onPressedChange={next => {
3907
+ setIsSavedViewsBarOpen(next);
3908
+ // Power search wins the render below, so opening the
3909
+ // bar from that mode would toggle a control that
3910
+ // changes nothing on screen. Asking for saved views
3911
+ // is a decision to leave the query bar.
3912
+ if (next) {
3913
+ setIsPowerSearch(false);
3914
+ }
3915
+ }}
3916
+ />
3917
+ </HStack>
3918
+ </HStack>
3919
+ )}
3920
+ </VStack>
3921
+ </Section>
3922
+ </VStack>
3923
+ </LayoutHeader>
3924
+ }
3925
+ content={
3926
+ /* Default padding (16px) is deliberate: Table bleeds to the edges
3927
+ and re-applies it to the first and last cells. */
3928
+ <LayoutContent padding={4} label="Service jobs">
3929
+ {results.length === 0 ? (
3930
+ <EmptyState
3931
+ icon={<Icon icon={Search} size="lg" />}
3932
+ title="No jobs match these filters"
3933
+ description="Try clearing a filter, or switch to power search to build a broader query."
3934
+ actions={
3935
+ <>
3936
+ <Button label="Clear all" onClick={clearAll} />
3937
+ <Button
3938
+ label="Power search"
3939
+ variant="secondary"
3940
+ onClick={() => setIsPowerSearch(true)}
3941
+ />
3942
+ </>
3943
+ }
3944
+ />
3945
+ ) : (
3946
+ <Table<ServiceJob>
3947
+ // Grouping flattens the batch into headers and rows; off, the
3948
+ // batch goes in untouched.
3949
+ data={isGrouped ? groupedRows : rows}
3950
+ columns={columns}
3951
+ idKey={isGrouped ? groupRowKey : 'id'}
3952
+ density={view.density}
3953
+ dividers="rows"
3954
+ hasHover
3955
+ textOverflow="wrap"
3956
+ // Rows are two lines in the Job column at every density but
3957
+ // compact; top alignment keeps the checkbox and every other
3958
+ // cell on the first content line.
3959
+ verticalAlign="top"
3960
+ plugins={plugins}
3961
+ rowCount={results.length}
3962
+ />
3963
+ )}
3964
+
3965
+ {results.length > 0 &&
3966
+ (hasNext ? (
3967
+ // Tall enough that the observer trips before the reader
3968
+ // reaches the true end of the list.
3969
+ <VStack ref={sentinelRef} gap={0} minHeight={56}>
3970
+ {isLoadingNext && (
3971
+ <LoadingRows columns={columns} density={view.density} />
3972
+ )}
3973
+ </VStack>
3974
+ ) : (
3975
+ results.length > PAGE_SIZE && (
3976
+ <VStack
3977
+ gap={0}
3978
+ minHeight={56}
3979
+ vAlign="center"
3980
+ hAlign="center">
3981
+ <Text type="supporting" color="secondary">
3982
+ All {results.length} jobs loaded
3983
+ </Text>
3984
+ </VStack>
3985
+ )
3986
+ ))}
3987
+ </LayoutContent>
3988
+ }
3989
+ />
3990
+
3991
+ {/* Create saved view.
3992
+
3993
+ Dialog → DialogHeader + Section: two regions, 16px on each. The
3994
+ Dialog keeps its --spacing-4 default rather than restating
3995
+ padding={4}; either way it puts that gutter on its inner wrapper and
3996
+ republishes it, and the header would otherwise pad on top of it. The
3997
+ Section escapes the wrapper on its own; dialogHeaderBleed does the
3998
+ same for the header so both land on one 16px gutter. */}
3999
+ <Dialog
4000
+ isOpen={creatingName != null}
4001
+ onOpenChange={open => setCreatingName(open ? '' : null)}
4002
+ purpose="form"
4003
+ width={400}>
4004
+ <DialogHeader
4005
+ title="Create new saved view"
4006
+ subtitle="Captures the filters and the table configuration as they are now."
4007
+ onOpenChange={open => setCreatingName(open ? '' : null)}
4008
+ xstyle={styles.dialogHeaderBleed}
4009
+ />
4010
+ <Section variant="transparent" padding={4}>
4011
+ <VStack gap={4}>
4012
+ <TextInput
4013
+ label="Name"
4014
+ value={creatingName ?? ''}
4015
+ onChange={setCreatingName}
4016
+ hasAutoFocus
4017
+ />
4018
+ <VStack gap={1}>
4019
+ <Text type="label">This view saves</Text>
4020
+ <ViewSummaryList view={view} filters={filters} />
4021
+ </VStack>
4022
+ <HStack hAlign="end">
4023
+ <Button
4024
+ label="Create"
4025
+ variant="primary"
4026
+ isDisabled={(creatingName ?? '').trim() === ''}
4027
+ onClick={() => createSavedView(creatingName ?? '')}
4028
+ />
4029
+ </HStack>
4030
+ </VStack>
4031
+ </Section>
4032
+ </Dialog>
4033
+
4034
+ {/* Edit saved view */}
4035
+ <Dialog
4036
+ isOpen={editing != null}
4037
+ onOpenChange={open => !open && setEditing(null)}
4038
+ purpose="form"
4039
+ width={400}>
4040
+ <DialogHeader
4041
+ title="Edit saved view"
4042
+ subtitle="Renaming only. The configuration is what was captured when the view was saved."
4043
+ onOpenChange={open => !open && setEditing(null)}
4044
+ xstyle={styles.dialogHeaderBleed}
4045
+ />
4046
+ <Section variant="transparent" padding={4}>
4047
+ <VStack gap={4}>
4048
+ <TextInput
4049
+ label="Name"
4050
+ value={editing?.name ?? ''}
4051
+ onChange={value =>
4052
+ setEditing(current =>
4053
+ current ? {...current, name: value} : current,
4054
+ )
4055
+ }
4056
+ />
4057
+ {/* The configuration is captured, not editable here: it is changed
4058
+ by reconfiguring the table and saving a new view. */}
4059
+ <VStack gap={1}>
4060
+ <Text type="label">This view saves</Text>
4061
+ {editing == null ? (
4062
+ <Text type="supporting" color="secondary">
4063
+ (Empty)
4064
+ </Text>
4065
+ ) : (
4066
+ <ViewSummaryList
4067
+ view={editing.view}
4068
+ filters={editing.filters}
4069
+ />
4070
+ )}
4071
+ </VStack>
4072
+ <HStack gap={2} vAlign="center">
4073
+ <Button
4074
+ label="Delete"
4075
+ variant="destructive"
4076
+ onClick={() => editing && deleteSavedView(editing.id)}
4077
+ />
4078
+ <StackItem size="fill">
4079
+ <HStack hAlign="end">
4080
+ <Button
4081
+ label="Save"
4082
+ variant="primary"
4083
+ onClick={() => editing && saveEditedView(editing)}
4084
+ />
4085
+ </HStack>
4086
+ </StackItem>
4087
+ </HStack>
4088
+ </VStack>
4089
+ </Section>
4090
+ </Dialog>
4091
+ </>
4092
+ );
4093
+ }