@12-apps/ui 1.6.3 → 1.7.0
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/package.json +4 -4
- package/src/components/data-display/DataViews/DataViewsTableBase.tsx +1 -1
- package/src/components/data-display/DataViews/RESPONSIVE-BEHAVIOUR.md +183 -0
- package/src/components/data-display/DataViews/data-views-bar-controls.tsx +119 -0
- package/src/components/data-display/DataViews/data-views-display-panel.tsx +48 -22
- package/src/components/data-display/DataViews/data-views-display-tabs.tsx +64 -31
- package/src/components/data-display/DataViews/data-views-export.tsx +2 -1
- package/src/components/data-display/DataViews/data-views-glyphs.tsx +16 -0
- package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +5 -1
- package/src/components/data-display/DataViews/data-views-grid-parts.tsx +20 -14
- package/src/components/data-display/DataViews/data-views-inline-bar.tsx +134 -155
- package/src/components/data-display/DataViews/data-views-layout-context.tsx +21 -0
- package/src/components/data-display/DataViews/data-views-more-fields.tsx +291 -0
- package/src/components/data-display/DataViews/data-views-more-filters.tsx +151 -199
- package/src/components/data-display/DataViews/data-views-overflow-costs.ts +100 -0
- package/src/components/data-display/DataViews/data-views-overflow.ts +89 -73
- package/src/components/data-display/DataViews/data-views-preset-chips.tsx +100 -0
- package/src/components/data-display/DataViews/data-views-range-pill.tsx +27 -11
- package/src/components/data-display/DataViews/data-views-range-presets.ts +121 -0
- package/src/components/data-display/DataViews/data-views-range-types.ts +94 -0
- package/src/components/data-display/DataViews/data-views-scope-tabs.tsx +57 -0
- package/src/components/data-display/DataViews/data-views-scopes.tsx +2 -2
- package/src/components/data-display/DataViews/data-views-search.tsx +19 -1
- package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +4 -0
- package/src/components/data-display/DataViews/data-views-toolbar.tsx +31 -21
- package/src/components/data-display/DataViews/data-views-types.ts +14 -47
- package/src/components/data-display/DataViews/index.ts +1 -0
- package/src/components/data-display/DataViews/pedidos.server.ts +118 -0
- package/src/components/data-display/DataViews/use-data-views-state.ts +1 -1
- package/src/components/layout/ContentToolbar/ContentToolbar.tsx +16 -1
- package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +9 -1
- package/src/components/layout/ContentToolbar/MultiSelectTriggers.tsx +39 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
|
|
5
|
+
import { estimateWidth, GAP, isRangeSet, pillText, RESERVED } from "./data-views-overflow-costs";
|
|
5
6
|
import type { FilterFieldConfig, RangeFieldConfig, RangeValue } from "./data-views-types";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -12,10 +13,14 @@ import type { FilterFieldConfig, RangeFieldConfig, RangeValue } from "./data-vie
|
|
|
12
13
|
* 1. Controls shed ONE AT A TIME into an overflow, never all at once. With four
|
|
13
14
|
* filters and room for three, hiding all four throws away the room that was
|
|
14
15
|
* there.
|
|
15
|
-
* 2. An APPLIED filter
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* 2. An APPLIED filter is ranked first, not exempt. Applied controls take the
|
|
17
|
+
* visible slots ahead of idle ones, and go into "Mais" like anything else
|
|
18
|
+
* when even those slots run out. Exempting them outright — the earlier rule
|
|
19
|
+
* — meant four applied filters on a phone claimed more width than the row
|
|
20
|
+
* had, and the bar painted past its own edge: the pills were reachable only
|
|
21
|
+
* by scrolling the toolbar sideways. Hiding one is safe because "Mais" says
|
|
22
|
+
* how many applied filters are behind it; scrolling one off-screen says
|
|
23
|
+
* nothing at all.
|
|
19
24
|
*
|
|
20
25
|
* It MEASURES rather than picking a breakpoint, which is what
|
|
21
26
|
* `FILTERS-CONTRACT.md` asks for: pages declare different numbers of filters
|
|
@@ -46,53 +51,39 @@ export interface OverflowSplit<T extends Record<string, unknown>> {
|
|
|
46
51
|
* Cheap: both keep their icon, their tooltip and their position.
|
|
47
52
|
*/
|
|
48
53
|
compactControls: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Step 5, and the LAST resort — the "N de N" counter is dropped.
|
|
56
|
+
*
|
|
57
|
+
* Only once every control has already collapsed and the row STILL does not
|
|
58
|
+
* fit, which on a six-filter table is below about 400px. The counter goes
|
|
59
|
+
* last because it is the only thing left that reports rather than does: an
|
|
60
|
+
* operator can act without knowing the total, but not without the search.
|
|
61
|
+
*/
|
|
62
|
+
counterHidden: boolean;
|
|
49
63
|
/**
|
|
50
64
|
* Step 4 — the search collapses to a magnifier that expands on click.
|
|
51
65
|
* The most expensive step, so it is taken last: the search is the one
|
|
52
66
|
* control on the bar that cannot be guessed from an icon's position.
|
|
53
67
|
*/
|
|
54
68
|
searchCollapsed: boolean;
|
|
55
|
-
/** Attach to the toolbar row being measured. */
|
|
56
|
-
barRef: React.RefObject<HTMLDivElement | null>;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Rough rendered width of a control, in px.
|
|
61
|
-
*
|
|
62
|
-
* An ESTIMATE from the label text rather than a real measurement, deliberately:
|
|
63
|
-
* measuring the true width needs every control mounted first, and mounting them
|
|
64
|
-
* to decide whether to mount them is the layout thrash this hook exists to
|
|
65
|
-
* avoid. The estimate only has to rank controls and find the cut point — being
|
|
66
|
-
* a few px out moves the cut by at most one control, and the overflow catches it.
|
|
67
|
-
*/
|
|
68
|
-
function estimateWidth(text: string, extra: number): number {
|
|
69
69
|
/**
|
|
70
|
-
*
|
|
71
|
-
* horizontal padding, the border, and the dropdown chevron. The chrome used
|
|
72
|
-
* to be 52, which under-priced every control by roughly a chevron's width —
|
|
73
|
-
* six of them then "fitted" a row they overflowed, and because they are flex
|
|
74
|
-
* children the row paid for it by squeezing labels to "D…" and "V…" rather
|
|
75
|
-
* than by shedding a control into the overflow.
|
|
70
|
+
* Step 6, below even the counter — "Limpar" leaves the bar.
|
|
76
71
|
*
|
|
77
|
-
*
|
|
78
|
-
* "
|
|
72
|
+
* The one control here with a second home: the overflow panel's footer
|
|
73
|
+
* carries "Limpar todos os filtros". So it goes before the search icon or
|
|
74
|
+
* "Mais", neither of which has anywhere else to be.
|
|
79
75
|
*/
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (values.length === 1) {
|
|
92
|
-
const option = field.pill?.options.find((entry) => entry.value === values[0]);
|
|
93
|
-
return `${field.label}: ${option?.label ?? values[0]}`;
|
|
94
|
-
}
|
|
95
|
-
return `${field.label}: ${values.length}`;
|
|
76
|
+
clearAllHidden: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Expanding the magnifier must take the whole cluster, filters and all.
|
|
79
|
+
*
|
|
80
|
+
* Only on the rungs where what is left over would be too narrow to read —
|
|
81
|
+
* on a large phone the box simply shrinks and the filters stay, which is why
|
|
82
|
+
* this is its own answer rather than `searchCollapsed` reused.
|
|
83
|
+
*/
|
|
84
|
+
searchTakeover: boolean;
|
|
85
|
+
/** Attach to the toolbar row being measured. */
|
|
86
|
+
barRef: React.RefObject<HTMLDivElement | null>;
|
|
96
87
|
}
|
|
97
88
|
|
|
98
89
|
/** Every declared control, pills first, in the order the bar renders them. */
|
|
@@ -106,24 +97,6 @@ export function toOverflowFields<T extends Record<string, unknown>>(
|
|
|
106
97
|
];
|
|
107
98
|
}
|
|
108
99
|
|
|
109
|
-
/** Fixed toolbar furniture the filters have to fit around, in px. */
|
|
110
|
-
const RESERVED = {
|
|
111
|
-
/** The search box never shrinks below this. */
|
|
112
|
-
search: 200,
|
|
113
|
-
/** The "N de N" counter. */
|
|
114
|
-
counter: 96,
|
|
115
|
-
/** Exibir + Exportar WITH their text labels. */
|
|
116
|
-
right: 216,
|
|
117
|
-
/** The same two controls as icons only — step 2 of the ladder. */
|
|
118
|
-
rightCompact: 96,
|
|
119
|
-
/** The "Mais" button, only charged when there IS an overflow. */
|
|
120
|
-
overflowButton: 104,
|
|
121
|
-
/** Gaps + the row's own padding. */
|
|
122
|
-
chrome: 64,
|
|
123
|
-
} as const;
|
|
124
|
-
|
|
125
|
-
/** The gap between two controls. */
|
|
126
|
-
const GAP = 8;
|
|
127
100
|
|
|
128
101
|
/** Observe the bar's width. Returns 0 until the first measurement lands. */
|
|
129
102
|
function useBarWidth(): { barRef: React.RefObject<HTMLDivElement | null>; width: number } {
|
|
@@ -189,23 +162,32 @@ function splitFilters<T extends Record<string, unknown>>(
|
|
|
189
162
|
const active = all.filter((field) => isActiveField(field, pills, ranges));
|
|
190
163
|
const idle = all.filter((field) => !isActiveField(field, pills, ranges));
|
|
191
164
|
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
// alone still loses the one filter the operator just set whenever it no
|
|
195
|
-
// longer fits on its own. The bar wraps; a filter you cannot see does not
|
|
196
|
-
// announce itself at all.
|
|
165
|
+
// "Limpar" rides the end of the cluster whenever anything is applied.
|
|
166
|
+
const clearCost = active.length > 0 ? RESERVED.clearAll + GAP : 0;
|
|
197
167
|
let available =
|
|
198
|
-
width - RESERVED.search - RESERVED.counter - RESERVED.right - RESERVED.chrome -
|
|
199
|
-
if (
|
|
200
|
-
return { inline: all, overflow: none, used: cost(all) };
|
|
168
|
+
width - RESERVED.search - RESERVED.counter - RESERVED.right - RESERVED.chrome - clearCost;
|
|
169
|
+
if (cost(all) <= available) {
|
|
170
|
+
return { inline: all, overflow: none, used: cost(all) + clearCost };
|
|
201
171
|
}
|
|
202
172
|
|
|
203
173
|
// There IS an overflow, so its button now costs room too.
|
|
204
174
|
available -= RESERVED.overflowButton;
|
|
205
|
-
|
|
206
|
-
|
|
175
|
+
// APPLIED FIRST, BUT NOT EXEMPT.
|
|
176
|
+
//
|
|
177
|
+
// Applied controls take the visible slots ahead of idle ones — that part was
|
|
178
|
+
// always right. Exempting them from the overflow entirely was not: applying
|
|
179
|
+
// a value LENGTHENS a control ("Pagamento (1)"), so on a phone four applied
|
|
180
|
+
// filters claimed more width than the row had and the bar simply painted
|
|
181
|
+
// past its own edge, leaving pills reachable only by scrolling sideways.
|
|
182
|
+
//
|
|
183
|
+
// An applied filter hidden in "Mais" is not lost the way it would be if it
|
|
184
|
+
// vanished silently: the trigger goes to the applied tone and says how many
|
|
185
|
+
// are in there (see `MoreTrigger`), which is the same signal the pill itself
|
|
186
|
+
// was carrying. A control you scroll off-screen carries no signal at all.
|
|
187
|
+
const keep = new Set<string>();
|
|
188
|
+
for (const field of [...active, ...idle]) {
|
|
207
189
|
const next = widthOf(field) + GAP;
|
|
208
|
-
if (next > available)
|
|
190
|
+
if (next > available) continue;
|
|
209
191
|
available -= next;
|
|
210
192
|
keep.add(field.id);
|
|
211
193
|
}
|
|
@@ -216,7 +198,7 @@ function splitFilters<T extends Record<string, unknown>>(
|
|
|
216
198
|
return {
|
|
217
199
|
inline,
|
|
218
200
|
overflow: all.filter((field) => !keep.has(field.id)),
|
|
219
|
-
used: cost(inline) + RESERVED.overflowButton,
|
|
201
|
+
used: cost(inline) + RESERVED.overflowButton + clearCost,
|
|
220
202
|
};
|
|
221
203
|
}
|
|
222
204
|
|
|
@@ -249,7 +231,10 @@ function computeSplit<T extends Record<string, unknown>>(
|
|
|
249
231
|
inline: all,
|
|
250
232
|
overflow: [],
|
|
251
233
|
compactControls: false,
|
|
234
|
+
counterHidden: false,
|
|
252
235
|
searchCollapsed: false,
|
|
236
|
+
clearAllHidden: false,
|
|
237
|
+
searchTakeover: false,
|
|
253
238
|
};
|
|
254
239
|
}
|
|
255
240
|
const { inline, overflow, used } = splitFilters(all, pills, ranges, width);
|
|
@@ -260,8 +245,39 @@ function computeSplit<T extends Record<string, unknown>>(
|
|
|
260
245
|
const rightCost = compactControls ? RESERVED.rightCompact : RESERVED.right;
|
|
261
246
|
// …and with them off?
|
|
262
247
|
const searchCollapsed = base - RESERVED.counter - rightCost < RESERVED.search;
|
|
248
|
+
// Everything has collapsed and the row STILL overflows. The controls cannot
|
|
249
|
+
// shrink further — they are `flexShrink: 0` precisely so an over-packed row
|
|
250
|
+
// sheds rather than squeezes — so without this last rung they simply paint
|
|
251
|
+
// outside the toolbar, which is what a narrow phone was doing.
|
|
252
|
+
const counterHidden =
|
|
253
|
+
searchCollapsed && base - RESERVED.counter - rightCost < RESERVED.searchIcon;
|
|
254
|
+
// Step 6 — below even that, "Limpar" leaves the bar. It is the ONLY control
|
|
255
|
+
// here with a second home: the overflow panel's footer carries "Limpar todos
|
|
256
|
+
// os filtros", so nothing is lost, which is exactly why it goes before the
|
|
257
|
+
// search icon or "Mais" (neither of which has anywhere else to be). Guarded
|
|
258
|
+
// on there BEING an overflow, because with no panel the footer does not
|
|
259
|
+
// exist and dropping this would strand the operator.
|
|
260
|
+
const clearAllHidden = counterHidden && overflow.length > 0;
|
|
261
|
+
// What the keyword box would get if the operator expanded the magnifier and
|
|
262
|
+
// everything else stayed put. `searchCollapsed` is a different question — it
|
|
263
|
+
// asks whether the box fits at its PREFERRED 200px, and is deliberately
|
|
264
|
+
// pessimistic — so reusing it to decide the takeover evicted the filters on
|
|
265
|
+
// screens where the box had plenty of room to simply shrink.
|
|
266
|
+
const searchBoxRoom = base - (counterHidden ? 0 : RESERVED.counter) - rightCost;
|
|
267
|
+
// Below this a box is too narrow to read what you typed into it, and shrinking
|
|
268
|
+
// further buys nothing; that is the only point at which taking the whole
|
|
269
|
+
// cluster is worth losing the filters.
|
|
270
|
+
const searchTakeover = searchCollapsed && searchBoxRoom < RESERVED.usableSearch;
|
|
263
271
|
|
|
264
|
-
return {
|
|
272
|
+
return {
|
|
273
|
+
inline,
|
|
274
|
+
overflow,
|
|
275
|
+
compactControls,
|
|
276
|
+
counterHidden,
|
|
277
|
+
searchCollapsed,
|
|
278
|
+
clearAllHidden,
|
|
279
|
+
searchTakeover,
|
|
280
|
+
};
|
|
265
281
|
}
|
|
266
282
|
|
|
267
283
|
/**
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* THE ONE-CLICK WINDOWS ABOVE A RANGE'S `De`/`Até` INPUTS.
|
|
5
|
+
*
|
|
6
|
+
* "Filter by last month" is two date pickers, four gestures and a mental
|
|
7
|
+
* calculation of which day the month ended on. It is also the overwhelmingly
|
|
8
|
+
* common case. So the common windows get a chip each, and the inputs stay for
|
|
9
|
+
* the windows nobody could enumerate.
|
|
10
|
+
*
|
|
11
|
+
* Clicking an active preset CLEARS it. A chip that reads as pressed and does
|
|
12
|
+
* nothing when pressed again is a dead control, and the alternative — hunting
|
|
13
|
+
* for the pill's ✕ — is the gesture this row exists to remove.
|
|
14
|
+
*/
|
|
15
|
+
import { Box } from "@mui/material";
|
|
16
|
+
|
|
17
|
+
import { isPresetActive, resolvePreset } from "./data-views-range-presets";
|
|
18
|
+
import type { RangePreset, RangeValue } from "./data-views-types";
|
|
19
|
+
|
|
20
|
+
/** One preset, styled as the same pill-shaped chip the filter bar uses. */
|
|
21
|
+
function PresetChip({
|
|
22
|
+
preset,
|
|
23
|
+
active,
|
|
24
|
+
onSelect,
|
|
25
|
+
testId,
|
|
26
|
+
}: {
|
|
27
|
+
preset: RangePreset;
|
|
28
|
+
active: boolean;
|
|
29
|
+
onSelect: () => void;
|
|
30
|
+
testId: string;
|
|
31
|
+
}): React.JSX.Element {
|
|
32
|
+
return (
|
|
33
|
+
<Box
|
|
34
|
+
component="button"
|
|
35
|
+
type="button"
|
|
36
|
+
onClick={onSelect}
|
|
37
|
+
aria-pressed={active}
|
|
38
|
+
data-testid={testId}
|
|
39
|
+
sx={{
|
|
40
|
+
px: 1.25,
|
|
41
|
+
py: 0.5,
|
|
42
|
+
border: 1,
|
|
43
|
+
borderStyle: "solid",
|
|
44
|
+
borderColor: active ? "primary.main" : "divider",
|
|
45
|
+
borderRadius: 999,
|
|
46
|
+
bgcolor: active ? "action.selected" : "background.paper",
|
|
47
|
+
color: active ? "primary.main" : "text.secondary",
|
|
48
|
+
cursor: "pointer",
|
|
49
|
+
font: "inherit",
|
|
50
|
+
fontSize: "0.75rem",
|
|
51
|
+
fontWeight: 600,
|
|
52
|
+
whiteSpace: "nowrap",
|
|
53
|
+
"&:hover": { borderColor: active ? "primary.main" : "text.primary" },
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
{preset.label}
|
|
57
|
+
</Box>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The preset row. Renders nothing when a field declares none, so a numeric
|
|
63
|
+
* range without host-configured windows keeps the popover it had.
|
|
64
|
+
*
|
|
65
|
+
* Carries no outer margin — the three surfaces that host it (the pill's
|
|
66
|
+
* popover, the slide-in panel, the "Mais" overflow) each sit it in a different
|
|
67
|
+
* stack, and a baked-in margin would be wrong in two of them.
|
|
68
|
+
*/
|
|
69
|
+
export function RangePresets({
|
|
70
|
+
presets,
|
|
71
|
+
value,
|
|
72
|
+
onChange,
|
|
73
|
+
testId,
|
|
74
|
+
}: {
|
|
75
|
+
presets: RangePreset[];
|
|
76
|
+
value: RangeValue;
|
|
77
|
+
onChange: (range: RangeValue) => void;
|
|
78
|
+
testId: string;
|
|
79
|
+
}): React.JSX.Element | null {
|
|
80
|
+
if (presets.length === 0) return null;
|
|
81
|
+
return (
|
|
82
|
+
<Box
|
|
83
|
+
data-testid={`${testId}-presets`}
|
|
84
|
+
sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}
|
|
85
|
+
>
|
|
86
|
+
{presets.map((preset) => {
|
|
87
|
+
const active = isPresetActive(preset, value);
|
|
88
|
+
return (
|
|
89
|
+
<PresetChip
|
|
90
|
+
key={preset.id}
|
|
91
|
+
preset={preset}
|
|
92
|
+
active={active}
|
|
93
|
+
onSelect={() => onChange(active ? {} : resolvePreset(preset))}
|
|
94
|
+
testId={`${testId}-preset-${preset.id}`}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
})}
|
|
98
|
+
</Box>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -19,6 +19,8 @@ import React, { useState } from 'react';
|
|
|
19
19
|
import { TableFilter } from '../../layout/TableFilter';
|
|
20
20
|
import { DayBoundInput } from './data-views-day-input';
|
|
21
21
|
import { NumberBoundInput } from './data-views-number-input';
|
|
22
|
+
import { RangePresets } from './data-views-preset-chips';
|
|
23
|
+
import { presetsFor } from './data-views-range-presets';
|
|
22
24
|
import {
|
|
23
25
|
isRangeInverted,
|
|
24
26
|
isRangeSet,
|
|
@@ -90,6 +92,11 @@ export function RangeBounds<T extends Record<string, unknown>>({
|
|
|
90
92
|
const inverted = isRangeInverted(value);
|
|
91
93
|
return (
|
|
92
94
|
<Box data-testid={`${testId}-panel`}>
|
|
95
|
+
{/* Above the inputs, not below: the presets are the answer for most
|
|
96
|
+
merchants, and the two date pickers are the escape hatch. */}
|
|
97
|
+
<Box sx={{ '&:not(:empty)': { mb: 1.5 } }}>
|
|
98
|
+
<RangePresets presets={presetsFor(field)} value={value} onChange={onChange} testId={testId} />
|
|
99
|
+
</Box>
|
|
93
100
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
94
101
|
<BoundField
|
|
95
102
|
field={field}
|
|
@@ -200,13 +207,16 @@ function RangeTrigger<T extends Record<string, unknown>>({
|
|
|
200
207
|
aria-haspopup="dialog"
|
|
201
208
|
aria-expanded={open}
|
|
202
209
|
onClick={(event) => onOpen(event.currentTarget)}
|
|
203
|
-
|
|
210
|
+
// ✕ FIRST, chevron LAST — the same shape as every other pill. It used to
|
|
211
|
+
// replace the chevron at the end, so a range pill and a multi-select pill
|
|
212
|
+
// put the clear in different places, and an applied range lost the
|
|
213
|
+
// affordance that says it opens.
|
|
214
|
+
startIcon={
|
|
204
215
|
active ? (
|
|
205
216
|
<ClearAffordance label={field.label} onClear={() => onChange({})} testId={`${testId}-clear-inline`} />
|
|
206
|
-
) :
|
|
207
|
-
<ChevronDownIcon sx={{ fontSize: 16 }} />
|
|
208
|
-
)
|
|
217
|
+
) : undefined
|
|
209
218
|
}
|
|
219
|
+
endIcon={<ChevronDownIcon sx={{ fontSize: 16 }} />}
|
|
210
220
|
sx={{
|
|
211
221
|
borderRadius: 999,
|
|
212
222
|
height: 34,
|
|
@@ -327,12 +337,18 @@ export function PanelRangeField<T extends Record<string, unknown>>({
|
|
|
327
337
|
);
|
|
328
338
|
}
|
|
329
339
|
return (
|
|
330
|
-
<
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
340
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
|
341
|
+
<TableFilter.RangeField
|
|
342
|
+
label={field.label}
|
|
343
|
+
unit={field.unit}
|
|
344
|
+
value={numericRange(value)}
|
|
345
|
+
onChange={(range) => onChange(range)}
|
|
346
|
+
testId={testId}
|
|
347
|
+
/>
|
|
348
|
+
{/* Below the numeric control here, unlike the pill's popover: this one
|
|
349
|
+
renders its own label, and chips above it would separate the label
|
|
350
|
+
from the field it names. */}
|
|
351
|
+
<RangePresets presets={presetsFor(field)} value={value} onChange={onChange} testId={testId} />
|
|
352
|
+
</Box>
|
|
337
353
|
);
|
|
338
354
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { RangeFieldConfig, RangePreset, RangeValue } from "./data-views-types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* THE CALENDAR WINDOWS BEHIND A DATE FILTER'S ONE-CLICK PRESETS.
|
|
5
|
+
*
|
|
6
|
+
* Pure and component-free, for the same reason `data-views-range-values` is:
|
|
7
|
+
* the pill's popover, the slide-in panel and the "Mais" overflow all offer the
|
|
8
|
+
* same presets, and none of them should have to import a component to know what
|
|
9
|
+
* "Esta semana" resolves to.
|
|
10
|
+
*
|
|
11
|
+
* Every window is computed in the LOCAL calendar and emitted as `AAAA-MM-DD`.
|
|
12
|
+
* Going through `toISOString()` would answer in UTC, which for every Brazilian
|
|
13
|
+
* timezone is a day ahead after 21:00 — "Hoje" would quietly become tomorrow
|
|
14
|
+
* every evening.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** `AAAA-MM-DD` for a date, read in the local calendar (never UTC). */
|
|
18
|
+
function isoDay(date: Date): string {
|
|
19
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
20
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
21
|
+
return `${date.getFullYear()}-${month}-${day}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The same date shifted by whole days, without mutating the original. */
|
|
25
|
+
function shiftDays(date: Date, days: number): Date {
|
|
26
|
+
const next = new Date(date);
|
|
27
|
+
next.setDate(next.getDate() + days);
|
|
28
|
+
return next;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The five windows every date filter wants, resolved when CLICKED.
|
|
33
|
+
*
|
|
34
|
+
* Each is a whole calendar unit rather than a unit-to-date: "Este mês" is the
|
|
35
|
+
* whole month, not the 1st through today. It is what the words say, it matches
|
|
36
|
+
* the label a merchant reads back on the pill, and it changes nothing for a
|
|
37
|
+
* dataset with no future rows — while a page that does have them (scheduled
|
|
38
|
+
* orders) would otherwise hide them under a preset that claims to show the
|
|
39
|
+
* month.
|
|
40
|
+
*
|
|
41
|
+
* The week starts on SUNDAY, following the Brazilian calendar these grids are
|
|
42
|
+
* read against rather than ISO-8601's Monday.
|
|
43
|
+
*/
|
|
44
|
+
export const DAY_RANGE_PRESETS: RangePreset[] = [
|
|
45
|
+
{
|
|
46
|
+
id: "hoje",
|
|
47
|
+
label: "Hoje",
|
|
48
|
+
range: () => {
|
|
49
|
+
const today = isoDay(new Date());
|
|
50
|
+
return { min: today, max: today };
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "ontem",
|
|
55
|
+
label: "Ontem",
|
|
56
|
+
range: () => {
|
|
57
|
+
const yesterday = isoDay(shiftDays(new Date(), -1));
|
|
58
|
+
return { min: yesterday, max: yesterday };
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "semana",
|
|
63
|
+
label: "Esta semana",
|
|
64
|
+
range: () => {
|
|
65
|
+
const now = new Date();
|
|
66
|
+
const sunday = shiftDays(now, -now.getDay());
|
|
67
|
+
return { min: isoDay(sunday), max: isoDay(shiftDays(sunday, 6)) };
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "mes",
|
|
72
|
+
label: "Este mês",
|
|
73
|
+
range: () => {
|
|
74
|
+
const now = new Date();
|
|
75
|
+
const year = now.getFullYear();
|
|
76
|
+
const month = now.getMonth();
|
|
77
|
+
// Day 0 of the NEXT month is the last day of this one — the only spelling
|
|
78
|
+
// that needs no month-length table and no leap-year branch.
|
|
79
|
+
return { min: isoDay(new Date(year, month, 1)), max: isoDay(new Date(year, month + 1, 0)) };
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "ano",
|
|
84
|
+
label: "Este ano",
|
|
85
|
+
range: () => {
|
|
86
|
+
const year = new Date().getFullYear();
|
|
87
|
+
return { min: `${year}-01-01`, max: `${year}-12-31` };
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The presets a field offers: its own, or the calendar defaults for a day field
|
|
94
|
+
* that declared none. An explicit `[]` means none — a host suppressing them is
|
|
95
|
+
* saying something, and must not be overridden by the fallback.
|
|
96
|
+
*/
|
|
97
|
+
export function presetsFor<T extends Record<string, unknown>>(
|
|
98
|
+
field: RangeFieldConfig<T>,
|
|
99
|
+
): RangePreset[] {
|
|
100
|
+
if (field.presets) return field.presets;
|
|
101
|
+
return field.kind === "day" ? DAY_RANGE_PRESETS : [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A preset's window, evaluating it if it is time-dependent. */
|
|
105
|
+
export function resolvePreset(preset: RangePreset): RangeValue {
|
|
106
|
+
return typeof preset.range === "function" ? preset.range() : preset.range;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Is this preset the window currently applied?
|
|
111
|
+
*
|
|
112
|
+
* Bounds compare as STRINGS because a `number` field's stored bound may have
|
|
113
|
+
* arrived from an input as `100` while the preset declares `100` — same window,
|
|
114
|
+
* and `undefined` vs a missing key must read as equal too.
|
|
115
|
+
*/
|
|
116
|
+
export function isPresetActive(preset: RangePreset, value: RangeValue): boolean {
|
|
117
|
+
const resolved = resolvePreset(preset);
|
|
118
|
+
const same = (a: number | string | undefined, b: number | string | undefined): boolean =>
|
|
119
|
+
String(a ?? "") === String(b ?? "");
|
|
120
|
+
return same(resolved.min, value.min) && same(resolved.max, value.max);
|
|
121
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RANGE FILTERS — the value types, apart from the rest of the DataViews model.
|
|
3
|
+
*
|
|
4
|
+
* Split out because `data-views-types` had grown past the file-size gate, and
|
|
5
|
+
* this is the seam that actually exists: a range is a self-contained little
|
|
6
|
+
* language (a window, its two kinds, its one-click presets) that the pill, the
|
|
7
|
+
* overflow panel, the slide-in panel and the fake backend all speak.
|
|
8
|
+
*
|
|
9
|
+
* Every one of them still imports from `data-views-types`, which re-exports
|
|
10
|
+
* these — there is one public name for the model, and moving a type between
|
|
11
|
+
* files is not a reason to touch a dozen import lines.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A selected range for a range filter (either bound optional). Numbers for a
|
|
16
|
+
* `number` field; `AAAA-MM-DD` day strings for a `day` one — which compare
|
|
17
|
+
* correctly with `<`/`>` precisely because ISO days sort lexicographically.
|
|
18
|
+
*/
|
|
19
|
+
export interface RangeValue {
|
|
20
|
+
min?: number | string;
|
|
21
|
+
max?: number | string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What a range field measures — the input it renders and the bound it stores. */
|
|
25
|
+
export type RangeFieldKind = "number" | "day";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A one-click window for a range filter: "Hoje", "Acima de R$ 500".
|
|
29
|
+
*
|
|
30
|
+
* `range` is a FUNCTION when the answer depends on when it is clicked and a
|
|
31
|
+
* literal when it does not. "Hoje" must mean today at the moment of the click,
|
|
32
|
+
* not at the moment the config object was built — a long-lived admin tab
|
|
33
|
+
* evaluating it once would go on filtering by the day it was opened.
|
|
34
|
+
*
|
|
35
|
+
* What lands in `DataViewState.ranges` is always the RESOLVED window, so a
|
|
36
|
+
* saved view captures the days "Hoje" meant when it was saved. That is the
|
|
37
|
+
* honest behaviour for a saved view: it is a stored filter, not a stored
|
|
38
|
+
* intent, and re-running it later must not silently answer a different
|
|
39
|
+
* question.
|
|
40
|
+
*/
|
|
41
|
+
export interface RangePreset {
|
|
42
|
+
/** Stable id, used as the React key and in the preset's test id. */
|
|
43
|
+
id: string;
|
|
44
|
+
label: string;
|
|
45
|
+
range: RangeValue | (() => RangeValue);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface RangeFieldBase {
|
|
49
|
+
/** Stable id; the key under `DataViewState.ranges`. */
|
|
50
|
+
id: string;
|
|
51
|
+
label: string;
|
|
52
|
+
/**
|
|
53
|
+
* One-click windows offered above the `De`/`Até` inputs.
|
|
54
|
+
*
|
|
55
|
+
* A `day` field falls back to the calendar defaults (Hoje / Ontem / Esta
|
|
56
|
+
* semana / Este mês / Este ano) when this is omitted, because those windows
|
|
57
|
+
* are the same for every date filter that has ever existed. A `number` field
|
|
58
|
+
* has no such universal set — what counts as a big order is the host's
|
|
59
|
+
* business — so it shows presets only when given them. Pass `[]` to a day
|
|
60
|
+
* field to suppress the defaults.
|
|
61
|
+
*/
|
|
62
|
+
presets?: RangePreset[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** "Filter by amount": price, stock, margin, order total. */
|
|
66
|
+
export interface NumberRangeFieldConfig<T extends Record<string, unknown>> extends RangeFieldBase {
|
|
67
|
+
kind?: "number";
|
|
68
|
+
/** Reads the row's numeric value for this range. */
|
|
69
|
+
accessor: (row: T) => number | null | undefined;
|
|
70
|
+
/** Optional unit suffix shown next to the inputs (e.g. "R$", "un"). */
|
|
71
|
+
unit?: string;
|
|
72
|
+
/** Numeric input step. Defaults to 1. */
|
|
73
|
+
step?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* "Filter by period": a pair of INCLUSIVE calendar days, rendered as two native
|
|
78
|
+
* date inputs. Inclusive is the reason a day range is its own kind rather than a
|
|
79
|
+
* number over timestamps — coercing an `até` of `2026-07-15` to a instant lands
|
|
80
|
+
* on that day's midnight and drops the whole final day (FUT-668).
|
|
81
|
+
*/
|
|
82
|
+
export interface DayRangeFieldConfig<T extends Record<string, unknown>> extends RangeFieldBase {
|
|
83
|
+
kind: "day";
|
|
84
|
+
/** Reads the row's `AAAA-MM-DD` day for this range. */
|
|
85
|
+
accessor: (row: T) => string | null | undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A min/max range filter's configuration. Complements the multi-select pills.
|
|
90
|
+
* A row with a null/undefined value never matches a bounded range.
|
|
91
|
+
*/
|
|
92
|
+
export type RangeFieldConfig<T extends Record<string, unknown>> =
|
|
93
|
+
| NumberRangeFieldConfig<T>
|
|
94
|
+
| DayRangeFieldConfig<T>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WHERE THE SCOPE TABS GO, AND WHEN THEY DO NOT GO THERE AT ALL.
|
|
5
|
+
*
|
|
6
|
+
* Its own module because it is a placement rule rather than a control: the
|
|
7
|
+
* strip itself is `DataViewsScopeTabs`, and this decides whether the grid shows
|
|
8
|
+
* one.
|
|
9
|
+
*/
|
|
10
|
+
import { DataViewsScopeTabs, type ScopeConfig } from "./data-views-scopes";
|
|
11
|
+
import { useDataViewsLayout } from "./data-views-layout-context";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The scope tabs — BELOW the toolbar, and absent when the board is already
|
|
15
|
+
* showing the same partition.
|
|
16
|
+
*
|
|
17
|
+
* A "Quadro" grouped by `situacao` is a column per situação; a scope strip over
|
|
18
|
+
* the same field is a tab per situação. Together they are the same partition
|
|
19
|
+
* offered twice, one of which silently narrows what the other displays —
|
|
20
|
+
* picking the "Cancelado" tab leaves a board with one populated column and the
|
|
21
|
+
* rest empty, which reads as data loss rather than as a filter.
|
|
22
|
+
*
|
|
23
|
+
* Only when they partition by the SAME field: a board grouped by one field
|
|
24
|
+
* beside scopes over another are two different cuts, and both belong.
|
|
25
|
+
*/
|
|
26
|
+
export function ScopeTabsSlot({
|
|
27
|
+
scopes,
|
|
28
|
+
scopeFieldId,
|
|
29
|
+
board,
|
|
30
|
+
value,
|
|
31
|
+
onChange,
|
|
32
|
+
counts,
|
|
33
|
+
testIdPrefix,
|
|
34
|
+
}: {
|
|
35
|
+
scopes: ScopeConfig[];
|
|
36
|
+
scopeFieldId?: string;
|
|
37
|
+
board?: { groupBy: string };
|
|
38
|
+
value?: string;
|
|
39
|
+
onChange: (id: string) => void;
|
|
40
|
+
counts?: Record<string, number>;
|
|
41
|
+
testIdPrefix: string;
|
|
42
|
+
}): React.JSX.Element | null {
|
|
43
|
+
const { layout } = useDataViewsLayout();
|
|
44
|
+
const boardOwnsThisPartition =
|
|
45
|
+
layout === "board" && Boolean(scopeFieldId) && scopeFieldId === board?.groupBy;
|
|
46
|
+
if (boardOwnsThisPartition) return null;
|
|
47
|
+
// Renders nothing (and reserves nothing) for an empty scope list.
|
|
48
|
+
return (
|
|
49
|
+
<DataViewsScopeTabs
|
|
50
|
+
scopes={scopes}
|
|
51
|
+
value={value}
|
|
52
|
+
onChange={onChange}
|
|
53
|
+
counts={counts}
|
|
54
|
+
testIdPrefix={testIdPrefix}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|