@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.
- package/CHANGELOG.md +64 -0
- package/README.md +53 -51
- package/api/docs/docs.doc.mjs +2 -2
- package/api/index.d.mts +1 -1
- package/api/index.mjs +1 -1
- package/api/search/search.mjs +50 -4
- package/api/search/search.test.mjs +71 -0
- package/api/template/data-token-fallbacks.test.mjs +85 -0
- package/api/template/template.doc.mjs +2 -2
- package/api/theme/build/build.mjs +43 -44
- package/api/theme/targets/targets.d.mts +18 -0
- package/api/theme/targets/targets.mjs +87 -0
- package/api/theme/targets/targets.test.mjs +66 -0
- package/api/theme/theme.d.mts +1 -0
- package/api/theme/theme.mjs +3 -1
- package/api/theme/theme.type.d.mts +23 -0
- package/api/theme/theme.type.mjs +21 -1
- package/api/theme/themeTargets.doc.d.mts +11 -0
- package/api/theme/themeTargets.doc.mjs +58 -0
- package/api/theme/themeTemplate.doc.mjs +3 -3
- package/assets/codemods/__tests__/registry.test.mjs +1 -0
- package/assets/codemods/registry.mjs +1 -0
- package/assets/codemods/transforms/v0.5.0/__tests__/next-codemods.test.mjs +127 -0
- package/assets/codemods/transforms/v0.5.0/banner-collapsible-content.mjs +171 -0
- package/assets/codemods/transforms/v0.5.0/index.mjs +20 -0
- package/assets/docs/README.md +50 -0
- package/assets/docs/cli-integrations.doc.mjs +4 -4
- package/assets/docs/theme.doc.dense.mjs +1 -1
- package/assets/docs/theme.doc.mjs +1 -1
- package/assets/docs/typography.doc.mjs +2 -2
- package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.doc.mjs +1 -1
- package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.tsx +1 -1
- package/assets/templates/blocks/components/DateInput/DateInputClearable.tsx +5 -1
- package/assets/templates/blocks/components/DateInput/DateInputDateRange.tsx +6 -1
- package/assets/templates/blocks/components/DateInput/DateInputFormats.tsx +5 -1
- package/assets/templates/blocks/components/DateInput/DateInputShowcase.tsx +4 -1
- package/assets/templates/blocks/components/DateInput/DateInputWithDescription.tsx +5 -1
- package/assets/templates/blocks/components/DateInput/DateInputWithValidation.tsx +5 -1
- package/assets/templates/blocks/components/Dialog/DialogAdaptivePresentation.doc.mjs +25 -0
- package/assets/templates/blocks/components/Dialog/DialogAdaptivePresentation.tsx +167 -0
- package/assets/templates/blocks/components/Dialog/DialogScrollingContent.tsx +1 -1
- package/assets/templates/blocks/components/HoverCard/HoverCardHookUsage.tsx +4 -1
- package/assets/templates/blocks/components/Step/StepContent.doc.mjs +14 -0
- package/assets/templates/blocks/components/Step/StepContent.tsx +32 -0
- package/assets/templates/blocks/components/Step/StepIndicator.doc.mjs +14 -0
- package/assets/templates/blocks/components/Step/StepIndicator.tsx +60 -0
- package/assets/templates/blocks/components/Step/StepShowcase.doc.mjs +15 -0
- package/assets/templates/blocks/components/Step/StepShowcase.tsx +26 -0
- package/assets/templates/blocks/components/Step/StepStates.doc.mjs +14 -0
- package/assets/templates/blocks/components/Step/StepStates.tsx +46 -0
- package/assets/templates/blocks/components/Stepper/StepperCustomContent.doc.mjs +22 -0
- package/assets/templates/blocks/components/Stepper/StepperCustomContent.tsx +126 -0
- package/assets/templates/blocks/components/Stepper/StepperIndicatorModes.doc.mjs +1 -1
- package/assets/templates/blocks/components/Stepper/StepperIndicatorModes.tsx +17 -5
- package/assets/templates/blocks/components/Stepper/StepperOnTrackHorizontal.doc.mjs +14 -0
- package/assets/templates/blocks/components/Stepper/StepperOnTrackHorizontal.tsx +25 -0
- package/assets/templates/blocks/components/Stepper/StepperOnTrackVertical.doc.mjs +2 -2
- package/assets/templates/blocks/components/Stepper/StepperOnTrackVertical.tsx +1 -1
- package/assets/templates/blocks/components/Stepper/StepperShowcase.doc.mjs +1 -1
- package/assets/templates/blocks/components/Stepper/StepperShowcase.tsx +6 -7
- package/assets/templates/blocks/components/Stepper/StepperStatus.tsx +1 -1
- package/assets/templates/pages/dashboard-cohort-funnel/page.tsx +1 -1
- package/assets/templates/pages/dashboard-data/page.tsx +1 -1
- package/assets/templates/pages/dashboard-portfolio/page.tsx +5 -5
- package/assets/templates/pages/dashboard-service-monitoring/page.tsx +1 -1
- package/assets/templates/pages/mixed-gallery/page.tsx +12 -3
- package/assets/templates/pages/settings-dialog/page.tsx +2543 -757
- package/assets/templates/pages/settings-dialog/template.doc.mjs +1 -2
- package/assets/templates/pages/table-filter/page.tsx +4093 -0
- package/assets/templates/pages/table-filter/template.doc.mjs +12 -0
- package/assets/templates/pages/table-grouped/page.tsx +151 -144
- package/assets/templates/pages/theme-showcase/page.tsx +36 -17
- package/assets/templates/themes/chocolate/chocolateTheme.ts +3 -1
- package/assets/templates/themes/matcha/matchaTheme.ts +3 -1
- package/assets/templates/themes/neutral/neutralTheme.ts +16 -9
- package/assets/templates/themes/stone/stoneTheme.ts +3 -1
- package/clients/cli/commands/build-theme.color-scheme.test.mjs +7 -6
- package/clients/cli/commands/build-theme.data-tokens.test.mjs +144 -0
- package/clients/cli/commands/build-theme.mjs +85 -0
- package/clients/cli/commands/build-theme.variants.test.mjs +3 -0
- package/clients/cli/commands/dialog-adaptive-template.test.mjs +24 -0
- package/clients/cli/commands/theme-targets.behavior.test.mjs +64 -0
- package/clients/cli/commands/theme-targets.doc.mjs +38 -0
- package/clients/cli/commands/theme-template.doc.mjs +2 -2
- package/clients/cli/commands/theme.doc.mjs +4 -2
- package/clients/cli/index.mjs +1 -0
- package/clients/cli/lib/component-format.mjs +2 -2
- package/clients/cli/lib/manifest.mjs +2 -0
- package/foundation/discovery/component-discovery.mjs +10 -9
- package/foundation/discovery/hook-discovery.mjs +2 -1
- package/foundation/discovery/hook-discovery.test.mjs +156 -37
- package/foundation/discovery/theming-targets.d.mts +86 -0
- package/foundation/discovery/theming-targets.mjs +202 -0
- package/foundation/discovery/theming-targets.test.mjs +245 -0
- package/foundation/fs/paths.d.mts +16 -0
- package/foundation/fs/paths.mjs +36 -0
- package/foundation/fs/paths.test.mjs +29 -1
- package/foundation/response/response-types.doc.mjs +7 -2
- package/package.json +9 -9
- package/assets/templates/blocks/components/Stepper/StepperHorizontal.doc.mjs +0 -14
- package/assets/templates/blocks/components/Stepper/StepperHorizontal.tsx +0 -24
- package/assets/templates/blocks/components/Stepper/StepperMultiStepForm.doc.mjs +0 -14
- package/assets/templates/blocks/components/Stepper/StepperMultiStepForm.tsx +0 -92
- package/assets/templates/blocks/components/Stepper/StepperVerticalOnboarding.doc.mjs +0 -14
- 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
|
+
}
|