@12-apps/ui 1.6.3 → 1.8.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.
Files changed (39) hide show
  1. package/package.json +4 -4
  2. package/src/components/data-display/DataViews/DataViewsTableBase.tsx +1 -1
  3. package/src/components/data-display/DataViews/RESPONSIVE-BEHAVIOUR.md +183 -0
  4. package/src/components/data-display/DataViews/base-card.tsx +93 -23
  5. package/src/components/data-display/DataViews/base-list-card-slots.tsx +256 -0
  6. package/src/components/data-display/DataViews/base-list-card.tsx +352 -0
  7. package/src/components/data-display/DataViews/card-surface.ts +297 -0
  8. package/src/components/data-display/DataViews/data-views-bar-controls.tsx +119 -0
  9. package/src/components/data-display/DataViews/data-views-display-panel.tsx +48 -22
  10. package/src/components/data-display/DataViews/data-views-display-tabs.tsx +64 -31
  11. package/src/components/data-display/DataViews/data-views-drag.tsx +135 -0
  12. package/src/components/data-display/DataViews/data-views-export.tsx +2 -1
  13. package/src/components/data-display/DataViews/data-views-glyphs.tsx +16 -0
  14. package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +5 -1
  15. package/src/components/data-display/DataViews/data-views-grid-parts.tsx +20 -14
  16. package/src/components/data-display/DataViews/data-views-inline-bar.tsx +134 -155
  17. package/src/components/data-display/DataViews/data-views-layout-context.tsx +21 -0
  18. package/src/components/data-display/DataViews/data-views-more-fields.tsx +291 -0
  19. package/src/components/data-display/DataViews/data-views-more-filters.tsx +151 -199
  20. package/src/components/data-display/DataViews/data-views-overflow-costs.ts +100 -0
  21. package/src/components/data-display/DataViews/data-views-overflow.ts +89 -73
  22. package/src/components/data-display/DataViews/data-views-preset-chips.tsx +100 -0
  23. package/src/components/data-display/DataViews/data-views-range-pill.tsx +27 -11
  24. package/src/components/data-display/DataViews/data-views-range-presets.ts +121 -0
  25. package/src/components/data-display/DataViews/data-views-range-types.ts +94 -0
  26. package/src/components/data-display/DataViews/data-views-scope-tabs.tsx +57 -0
  27. package/src/components/data-display/DataViews/data-views-scopes.tsx +2 -2
  28. package/src/components/data-display/DataViews/data-views-search.tsx +19 -1
  29. package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +4 -0
  30. package/src/components/data-display/DataViews/data-views-toolbar.tsx +31 -21
  31. package/src/components/data-display/DataViews/data-views-types.ts +14 -47
  32. package/src/components/data-display/DataViews/index.ts +8 -0
  33. package/src/components/data-display/DataViews/list-card-rails.tsx +141 -0
  34. package/src/components/data-display/DataViews/pedidos.server.ts +118 -0
  35. package/src/components/data-display/DataViews/use-data-views-state.ts +1 -1
  36. package/src/components/data-display/DescriptionItem/DescriptionItem.tsx +26 -3
  37. package/src/components/layout/ContentToolbar/ContentToolbar.tsx +16 -1
  38. package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +9 -1
  39. 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 never hides. Applied controls take the visible slots
16
- * first, so the overflow badge counts hidden FIELDS and never active
17
- * filters — a filter you cannot see is a filter you forget you set, and then
18
- * the list is "wrong" for reasons nothing on screen explains.
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
- * ~7.6px per character at the toolbar's font size, plus 76px of chrome:
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
- * Deliberately generous: over-pricing sheds one control too early, which the
78
- * "Mais" button absorbs invisibly. Under-pricing breaks the line.
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
- return Math.round(text.length * 7.6) + 76 + extra;
81
- }
82
-
83
- /** Is a range bounded at either end? */
84
- function isRangeSet(range: RangeValue | undefined): boolean {
85
- return Boolean(range && (range.min !== undefined || range.max !== undefined));
86
- }
87
-
88
- /** The label a pill actually renders, which is what decides its width. */
89
- function pillText<T extends Record<string, unknown>>(field: OverflowField<T>, values: string[]): string {
90
- if (values.length === 0) return field.label;
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
- // AN APPLIED FILTER NEVER HIDES — it is not merely ranked first, it is
193
- // exempt. Applying a value LENGTHENS a control ("Método: Pix"), so ranking
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 - cost(active);
199
- if (idle.length === 0 || cost(idle) <= available) {
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
- const keep = new Set(active.map((field) => field.id));
206
- for (const field of idle) {
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) break;
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 { inline, overflow, compactControls, searchCollapsed };
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
- endIcon={
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
- <TableFilter.RangeField
331
- label={field.label}
332
- unit={field.unit}
333
- value={numericRange(value)}
334
- onChange={(range) => onChange(range)}
335
- testId={testId}
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
+ }