@softize/opus 18.1.1 → 18.3.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/CHANGELOG.md +35 -0
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/SKILL.md +3 -2
- package/registry/skills/build-opus-ui/references/ui-patterns.md +4 -3
- package/src/core/errors.ts +0 -1
- package/src/core/presentation.ts +143 -58
- package/src/core/types.ts +4 -0
- package/src/ui/components/patterns/data-state.tsx +1 -1
- package/src/ui/components/patterns/list.tsx +606 -251
- package/src/ui/components/patterns/page.tsx +16 -5
- package/src/ui/components/patterns/presentation.tsx +228 -63
- package/src/ui/components/patterns/split.tsx +5 -2
- package/src/ui/components/patterns/surface-assistant.tsx +74 -0
- package/src/ui/components/primitives/field.tsx +23 -2
- package/src/ui/components/primitives/metric-card.tsx +64 -5
- package/src/ui/components/primitives/select.tsx +27 -23
- package/src/ui/components/primitives/table.tsx +4 -1
- package/src/ui/docs/content/action-list.md +84 -46
- package/src/ui/docs/content/field.md +6 -1
- package/src/ui/docs/content/metric-card.md +43 -0
- package/src/ui/docs/content/presentation.md +40 -14
- package/src/ui/docs/content/select.md +4 -0
- package/src/ui/docs/content/split.md +80 -19
- package/src/ui/meta.ts +2 -2
- package/src/ui/react.tsx +6 -0
|
@@ -24,11 +24,11 @@ import {
|
|
|
24
24
|
Fragment,
|
|
25
25
|
isValidElement,
|
|
26
26
|
useEffect,
|
|
27
|
-
useLayoutEffect,
|
|
28
27
|
useId,
|
|
29
28
|
useMemo,
|
|
30
29
|
useRef,
|
|
31
30
|
useState,
|
|
31
|
+
type ComponentProps,
|
|
32
32
|
type ReactNode,
|
|
33
33
|
} from "react";
|
|
34
34
|
import {
|
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
ArrowUp,
|
|
37
37
|
ArrowUpDown,
|
|
38
38
|
Calendar as CalendarIcon,
|
|
39
|
+
Check,
|
|
40
|
+
ChevronDown,
|
|
39
41
|
Eraser,
|
|
40
42
|
Filter,
|
|
41
43
|
RotateCw,
|
|
@@ -67,6 +69,7 @@ import { cn } from "../../lib/cn.ts";
|
|
|
67
69
|
import { objectSchemaShape } from "../../lib/object-schema.ts";
|
|
68
70
|
import { Badge } from "../primitives/badge.tsx";
|
|
69
71
|
import { DictionaryValue } from "../primitives/dictionary-value.tsx";
|
|
72
|
+
import { Dot } from "../primitives/dot.tsx";
|
|
70
73
|
import { EmptyValue, isAbsentValue } from "../primitives/empty-value.tsx";
|
|
71
74
|
import {
|
|
72
75
|
Pagination,
|
|
@@ -81,6 +84,12 @@ import { Button } from "../primitives/button.tsx";
|
|
|
81
84
|
import { ButtonGroup } from "../primitives/button-group.tsx";
|
|
82
85
|
import { Calendar } from "../primitives/calendar.tsx";
|
|
83
86
|
import { Checkbox } from "../primitives/checkbox.tsx";
|
|
87
|
+
import {
|
|
88
|
+
Command,
|
|
89
|
+
CommandGroup,
|
|
90
|
+
CommandItem,
|
|
91
|
+
CommandList,
|
|
92
|
+
} from "../primitives/command.tsx";
|
|
84
93
|
import {
|
|
85
94
|
Popover,
|
|
86
95
|
PopoverContent,
|
|
@@ -103,11 +112,7 @@ import {
|
|
|
103
112
|
} from "../primitives/input-group.tsx";
|
|
104
113
|
import { Separator } from "../primitives/separator.tsx";
|
|
105
114
|
import { Label } from "../primitives/label.tsx";
|
|
106
|
-
import {
|
|
107
|
-
FieldGroup,
|
|
108
|
-
FieldLegend,
|
|
109
|
-
FieldSet,
|
|
110
|
-
} from "../primitives/field.tsx";
|
|
115
|
+
import { FieldGroup, FieldLegend, FieldSet } from "../primitives/field.tsx";
|
|
111
116
|
import { Search } from "lucide-react";
|
|
112
117
|
import { Select, type SelectOption } from "../primitives/select.tsx";
|
|
113
118
|
import {
|
|
@@ -273,20 +278,35 @@ export interface ActionFilterBarProps {
|
|
|
273
278
|
state: ActionFilterState;
|
|
274
279
|
onStateChange: (next: ActionFilterState) => void;
|
|
275
280
|
filterOptions?: Record<string, SelectOption[]>;
|
|
281
|
+
/** Valor de repouso por filtro simples. O limpar só aparece fora desse estado e retorna a ele. */
|
|
282
|
+
filterDefaults?: Record<string, string>;
|
|
283
|
+
/** Ícone leading por filtro, projetado no controle derivado sem alterar o contrato. */
|
|
284
|
+
filterIcons?: Record<string, ReactNode>;
|
|
276
285
|
/** Composição visual do diálogo de filtros avançados. A largura é derivada
|
|
277
286
|
* internamente do número de colunas. */
|
|
278
287
|
advancedFilters?: AdvancedFiltersLayout;
|
|
279
288
|
onRefresh?: () => Promise<void> | void;
|
|
280
289
|
refreshing?: boolean;
|
|
290
|
+
/** Posição do período em relação aos filtros inline. Também pode ancorá-lo após um filtro. */
|
|
291
|
+
periodPosition?: "before" | "after" | { afterFilter: string };
|
|
292
|
+
/** Composição do período com uma dimensão temporal declarada como filtro inline. */
|
|
293
|
+
period?: PeriodLayout;
|
|
281
294
|
/** Controles auxiliares, agrupados com o refresh em um ButtonGroup espaçado. */
|
|
282
295
|
controls?: ReactNode;
|
|
283
296
|
/** Ações do consumidor, separadas do grupo de controles. */
|
|
284
297
|
actions?: ReactNode;
|
|
285
298
|
}
|
|
286
299
|
|
|
300
|
+
export interface PeriodLayout {
|
|
301
|
+
/** Nome do filtro inline que escolhe qual data o período recorta. */
|
|
302
|
+
basisFilter: string;
|
|
303
|
+
}
|
|
304
|
+
|
|
287
305
|
export interface AdvancedFiltersLayout {
|
|
288
306
|
/** Número de colunas dos grupos de filtros. */
|
|
289
307
|
columns?: 1 | 2 | 3;
|
|
308
|
+
/** Ícone de cada seção, indexado pelo mesmo rótulo declarado nos filtros. */
|
|
309
|
+
sectionIcons?: Record<string, ReactNode>;
|
|
290
310
|
}
|
|
291
311
|
|
|
292
312
|
/** Uma view alternativa da MESMA listagem (board, galeria, lista, calendário…). A
|
|
@@ -316,6 +336,8 @@ export interface ActionListProps<TInput, TItem> {
|
|
|
316
336
|
action: ListActionLike;
|
|
317
337
|
/** Input BASE (escopo fixo, ex.: { workspaceId }) — a toolbar soma por cima. */
|
|
318
338
|
input: TInput;
|
|
339
|
+
/** `fill` ocupa a altura disponível e mantém a rolagem dentro da região de resultados. */
|
|
340
|
+
layout?: "flow" | "fill";
|
|
319
341
|
/** Ações do consumidor no fim da barra de filtros, depois dos controles de exibição. */
|
|
320
342
|
toolbarActions?: ReactNode;
|
|
321
343
|
/** Tabela explícita — sobrepõe as colunas do contrato. */
|
|
@@ -324,8 +346,16 @@ export interface ActionListProps<TInput, TItem> {
|
|
|
324
346
|
cells?: Record<string, (item: TItem) => ReactNode>;
|
|
325
347
|
/** Opções de runtime pros filtros select/lookup (chave = nome do filtro). */
|
|
326
348
|
filterOptions?: Record<string, SelectOption[]>;
|
|
349
|
+
/** Valor de repouso por filtro simples. */
|
|
350
|
+
filterDefaults?: Record<string, string>;
|
|
351
|
+
/** Ícone leading por filtro, projetado no controle derivado sem alterar o contrato. */
|
|
352
|
+
filterIcons?: Record<string, ReactNode>;
|
|
327
353
|
/** Composição visual do diálogo de filtros avançados. Default: uma coluna. */
|
|
328
354
|
advancedFilters?: AdvancedFiltersLayout;
|
|
355
|
+
/** Posição do período em relação aos filtros inline. Também pode ancorá-lo após um filtro. */
|
|
356
|
+
periodPosition?: "before" | "after" | { afterFilter: string };
|
|
357
|
+
/** Composição do período com uma dimensão temporal declarada como filtro inline. */
|
|
358
|
+
period?: PeriodLayout;
|
|
329
359
|
/** Views alternativas da mesma listagem — o segment na toolbar alterna entre a
|
|
330
360
|
* tabela ('table', quando há columns) e estas. Ex.: { board: { label, render } }. */
|
|
331
361
|
views?: Record<string, ActionListView<TItem>>;
|
|
@@ -478,6 +508,9 @@ function FilterField({
|
|
|
478
508
|
value,
|
|
479
509
|
onChange,
|
|
480
510
|
options,
|
|
511
|
+
icon,
|
|
512
|
+
embedded = false,
|
|
513
|
+
defaultValue = "",
|
|
481
514
|
allValues,
|
|
482
515
|
className,
|
|
483
516
|
}: {
|
|
@@ -486,11 +519,16 @@ function FilterField({
|
|
|
486
519
|
value: unknown;
|
|
487
520
|
onChange: (v: unknown) => void;
|
|
488
521
|
options: SelectOption[];
|
|
522
|
+
/** Ícone leading do controle, definido pela superfície consumidora. */
|
|
523
|
+
icon?: ReactNode;
|
|
524
|
+
/** Remove a casca própria quando o filtro participa de um controle composto. */
|
|
525
|
+
embedded?: boolean;
|
|
526
|
+
/** Valor ao qual a ação de limpar retorna. */
|
|
527
|
+
defaultValue?: string;
|
|
489
528
|
/** Todos os valores de filtro vigentes — habilita `depends` e alimenta o lookup. */
|
|
490
529
|
allValues: Record<string, unknown>;
|
|
491
|
-
/** Largura do contexto
|
|
492
|
-
*
|
|
493
|
-
* `w-full`, que vence pelo merge de classes. */
|
|
530
|
+
/** Largura do contexto. Sem override, selects de valor único acompanham o valor dentro
|
|
531
|
+
* de limites previsíveis; múltiplo e texto mantêm largura fixa. O modal manda `w-full`. */
|
|
494
532
|
className?: string;
|
|
495
533
|
}): React.ReactElement {
|
|
496
534
|
const label = text(spec.label, name);
|
|
@@ -504,7 +542,21 @@ function FilterField({
|
|
|
504
542
|
// Precedência: runtime/estáticas (prop `options`) > dictionary do provider;
|
|
505
543
|
// sem nenhuma, as do lookup (server-side).
|
|
506
544
|
const base = options.length > 0 ? options : dictOptionsFor(spec, dicts);
|
|
507
|
-
const effOptions =
|
|
545
|
+
const effOptions =
|
|
546
|
+
lookup.onSearch !== undefined && base.length > 0
|
|
547
|
+
? [
|
|
548
|
+
...base,
|
|
549
|
+
...lookup.options.filter(
|
|
550
|
+
(candidate) =>
|
|
551
|
+
!base.some((option) => option.value === candidate.value),
|
|
552
|
+
),
|
|
553
|
+
]
|
|
554
|
+
: base.length > 0
|
|
555
|
+
? base
|
|
556
|
+
: lookup.options;
|
|
557
|
+
const singleValue = (value as string | undefined) ?? defaultValue;
|
|
558
|
+
const changedFromDefault = singleValue !== defaultValue;
|
|
559
|
+
const acceptsEmptyValue = spec.allowEmpty !== false;
|
|
508
560
|
|
|
509
561
|
if (spec.multiple === true) {
|
|
510
562
|
return (
|
|
@@ -516,8 +568,9 @@ function FilterField({
|
|
|
516
568
|
value={(value as string[] | undefined) ?? []}
|
|
517
569
|
onChange={(v) => onChange(v)}
|
|
518
570
|
options={effOptions}
|
|
571
|
+
icon={icon}
|
|
519
572
|
placeholder={placeholder}
|
|
520
|
-
className={
|
|
573
|
+
className={className ?? "w-52"}
|
|
521
574
|
disabled={!enabled}
|
|
522
575
|
{...(lookup.onSearch !== undefined
|
|
523
576
|
? { onSearch: lookup.onSearch, loading: lookup.loading }
|
|
@@ -528,16 +581,24 @@ function FilterField({
|
|
|
528
581
|
if (spec.type === "lookup") {
|
|
529
582
|
// Lookup é typeahead por natureza: Select searchable single (server-side quando a origem
|
|
530
583
|
// é uma action; client quando as opções vieram estáticas/de runtime).
|
|
584
|
+
const ALL = "__all__";
|
|
531
585
|
return (
|
|
532
586
|
<Select
|
|
533
587
|
searchable
|
|
534
|
-
clearable
|
|
588
|
+
clearable={embedded ? false : changedFromDefault}
|
|
535
589
|
id={`filter-${name}`}
|
|
536
|
-
value={
|
|
537
|
-
onChange={(v) => onChange(v)}
|
|
538
|
-
options={
|
|
590
|
+
value={singleValue === "" ? ALL : singleValue}
|
|
591
|
+
onChange={(v) => onChange(v === "" ? defaultValue : v === ALL ? "" : v)}
|
|
592
|
+
options={[
|
|
593
|
+
...(acceptsEmptyValue
|
|
594
|
+
? [{ value: ALL, label: text(spec.placeholder, "Todos") }]
|
|
595
|
+
: []),
|
|
596
|
+
...effOptions.filter((option) => option.value !== ALL),
|
|
597
|
+
]}
|
|
598
|
+
icon={icon}
|
|
599
|
+
variant={embedded ? "ghost" : "default"}
|
|
539
600
|
placeholder={placeholder}
|
|
540
|
-
className={
|
|
601
|
+
className={className ?? "w-fit min-w-0 max-w-full"}
|
|
541
602
|
disabled={!enabled}
|
|
542
603
|
{...(lookup.onSearch !== undefined
|
|
543
604
|
? { onSearch: lookup.onSearch, loading: lookup.loading }
|
|
@@ -551,18 +612,21 @@ function FilterField({
|
|
|
551
612
|
const ALL = "__all__";
|
|
552
613
|
return (
|
|
553
614
|
<Select
|
|
554
|
-
|
|
615
|
+
searchable={spec.searchable === true}
|
|
616
|
+
clearable={embedded ? false : changedFromDefault}
|
|
555
617
|
id={`filter-${name}`}
|
|
556
|
-
value={
|
|
557
|
-
|
|
558
|
-
}
|
|
559
|
-
onChange={(v) => onChange(v === ALL ? "" : v)}
|
|
618
|
+
value={singleValue === "" ? ALL : singleValue}
|
|
619
|
+
onChange={(v) => onChange(v === "" ? defaultValue : v === ALL ? "" : v)}
|
|
560
620
|
options={[
|
|
561
|
-
|
|
621
|
+
...(acceptsEmptyValue
|
|
622
|
+
? [{ value: ALL, label: text(spec.placeholder, "Todos") }]
|
|
623
|
+
: []),
|
|
562
624
|
...base,
|
|
563
625
|
]}
|
|
626
|
+
icon={icon}
|
|
627
|
+
variant={embedded ? "ghost" : "default"}
|
|
564
628
|
disabled={!enabled}
|
|
565
|
-
className={
|
|
629
|
+
className={className ?? "w-fit min-w-0 max-w-full"}
|
|
566
630
|
/>
|
|
567
631
|
);
|
|
568
632
|
}
|
|
@@ -570,11 +634,11 @@ function FilterField({
|
|
|
570
634
|
return (
|
|
571
635
|
<DateFilterField
|
|
572
636
|
id={`filter-${name}`}
|
|
573
|
-
value={
|
|
637
|
+
value={singleValue}
|
|
574
638
|
placeholder={placeholder}
|
|
575
639
|
className={cn("w-40", className)}
|
|
576
640
|
disabled={!enabled}
|
|
577
|
-
onChange={onChange}
|
|
641
|
+
onChange={(next) => onChange(next === "" ? defaultValue : next)}
|
|
578
642
|
/>
|
|
579
643
|
);
|
|
580
644
|
}
|
|
@@ -584,7 +648,8 @@ function FilterField({
|
|
|
584
648
|
<ClearableInput
|
|
585
649
|
id={`filter-${name}`}
|
|
586
650
|
type={type}
|
|
587
|
-
applied={
|
|
651
|
+
applied={singleValue}
|
|
652
|
+
clearValue={defaultValue}
|
|
588
653
|
placeholder={placeholder}
|
|
589
654
|
className={cn("w-40", className)}
|
|
590
655
|
disabled={!enabled}
|
|
@@ -656,7 +721,12 @@ function DateFilterField({
|
|
|
656
721
|
disabled={disabled}
|
|
657
722
|
>
|
|
658
723
|
<CalendarIcon className="h-3.5 w-3.5 shrink-0" />
|
|
659
|
-
<span
|
|
724
|
+
<span
|
|
725
|
+
className={cn(
|
|
726
|
+
"truncate",
|
|
727
|
+
selected === undefined && "text-muted-foreground",
|
|
728
|
+
)}
|
|
729
|
+
>
|
|
660
730
|
{label}
|
|
661
731
|
</span>
|
|
662
732
|
</Button>
|
|
@@ -694,12 +764,14 @@ function DateFilterField({
|
|
|
694
764
|
}
|
|
695
765
|
|
|
696
766
|
/** Input com limpar (a receita clearable dos textos): Input/InputGroup são locked, o X
|
|
697
|
-
* entra por composição — addon à direita, visível
|
|
698
|
-
* no blur/Enter, como a busca); o X
|
|
767
|
+
* entra por composição — addon à direita, visível quando o valor diverge do repouso.
|
|
768
|
+
* Uncontrolled (aplica no blur/Enter, como a busca); o X restaura e aplica o repouso. */
|
|
699
769
|
function ClearableInput({
|
|
700
770
|
id,
|
|
701
771
|
type,
|
|
702
772
|
applied,
|
|
773
|
+
clearValue = "",
|
|
774
|
+
shape = "default",
|
|
703
775
|
placeholder,
|
|
704
776
|
className,
|
|
705
777
|
leading,
|
|
@@ -709,6 +781,10 @@ function ClearableInput({
|
|
|
709
781
|
id?: string;
|
|
710
782
|
type?: string;
|
|
711
783
|
applied: string;
|
|
784
|
+
/** Valor restaurado pela ação de limpar. */
|
|
785
|
+
clearValue?: string;
|
|
786
|
+
/** Forma visual do grupo; a busca da toolbar usa pill. */
|
|
787
|
+
shape?: ComponentProps<typeof InputGroup>["shape"];
|
|
712
788
|
placeholder?: string;
|
|
713
789
|
className?: string;
|
|
714
790
|
/** Addon à esquerda (ex.: a lupa da busca). */
|
|
@@ -717,15 +793,15 @@ function ClearableInput({
|
|
|
717
793
|
onApply: (v: string) => void;
|
|
718
794
|
}): React.ReactElement {
|
|
719
795
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
720
|
-
const [
|
|
796
|
+
const [canClear, setCanClear] = useState(applied !== clearValue);
|
|
721
797
|
// Valor aplicado mudou por fora (chip removido, URL) → re-sincroniza o campo.
|
|
722
798
|
useEffect(() => {
|
|
723
799
|
if (inputRef.current !== null && inputRef.current.value !== applied)
|
|
724
800
|
inputRef.current.value = applied;
|
|
725
|
-
|
|
726
|
-
}, [applied]);
|
|
801
|
+
setCanClear(applied !== clearValue);
|
|
802
|
+
}, [applied, clearValue]);
|
|
727
803
|
return (
|
|
728
|
-
<InputGroup className={className}>
|
|
804
|
+
<InputGroup className={className} shape={shape}>
|
|
729
805
|
{leading !== undefined && <InputGroupAddon>{leading}</InputGroupAddon>}
|
|
730
806
|
<InputGroupInput
|
|
731
807
|
ref={inputRef}
|
|
@@ -735,7 +811,7 @@ function ClearableInput({
|
|
|
735
811
|
disabled={disabled}
|
|
736
812
|
defaultValue={applied}
|
|
737
813
|
placeholder={placeholder}
|
|
738
|
-
onChange={(e) =>
|
|
814
|
+
onChange={(e) => setCanClear(e.target.value !== clearValue)}
|
|
739
815
|
onBlur={(e) => {
|
|
740
816
|
if (e.target.value !== applied) onApply(e.target.value);
|
|
741
817
|
}}
|
|
@@ -746,14 +822,17 @@ function ClearableInput({
|
|
|
746
822
|
}
|
|
747
823
|
}}
|
|
748
824
|
/>
|
|
749
|
-
{
|
|
825
|
+
{canClear && (
|
|
750
826
|
<InputGroupAddon align="inline-end">
|
|
751
827
|
<InputGroupButton
|
|
828
|
+
size="icon-xs"
|
|
752
829
|
aria-label="Limpar"
|
|
830
|
+
className="text-muted-foreground hover:text-foreground"
|
|
753
831
|
onClick={() => {
|
|
754
|
-
if (inputRef.current !== null)
|
|
755
|
-
|
|
756
|
-
|
|
832
|
+
if (inputRef.current !== null)
|
|
833
|
+
inputRef.current.value = clearValue;
|
|
834
|
+
setCanClear(false);
|
|
835
|
+
onApply(clearValue);
|
|
757
836
|
}}
|
|
758
837
|
>
|
|
759
838
|
<X className="size-3.5" />
|
|
@@ -792,23 +871,34 @@ function Labeled({
|
|
|
792
871
|
label,
|
|
793
872
|
children,
|
|
794
873
|
slot,
|
|
874
|
+
showLabel = true,
|
|
875
|
+
className,
|
|
795
876
|
}: {
|
|
796
877
|
label: string;
|
|
797
878
|
children: ReactNode;
|
|
798
|
-
/** data-slot do wrapper (ex.:
|
|
879
|
+
/** data-slot do wrapper (ex.: identifica os filtros inline na barra). */
|
|
799
880
|
slot?: string;
|
|
881
|
+
/** Omite a label quando o controle é o único recorte inline da barra. */
|
|
882
|
+
showLabel?: boolean;
|
|
883
|
+
/** Limite externo do controle; o wrapper pode encolher junto com a toolbar. */
|
|
884
|
+
className?: string;
|
|
800
885
|
}): React.ReactElement {
|
|
801
886
|
return (
|
|
802
|
-
<div
|
|
887
|
+
<div
|
|
888
|
+
data-slot={slot}
|
|
889
|
+
className={cn("flex min-w-0 shrink flex-col gap-1", className)}
|
|
890
|
+
>
|
|
803
891
|
{/* w-0 + min-w-full: a label acompanha a largura do controle e nunca a dita —
|
|
804
892
|
um rótulo longo trunca em vez de alargar o filtro. */}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
893
|
+
{showLabel ? (
|
|
894
|
+
<span
|
|
895
|
+
data-slot="action-filter-label"
|
|
896
|
+
title={label}
|
|
897
|
+
className="block w-0 min-w-full truncate px-0.5 text-xs font-medium text-muted-foreground"
|
|
898
|
+
>
|
|
899
|
+
{label}
|
|
900
|
+
</span>
|
|
901
|
+
) : null}
|
|
812
902
|
{children}
|
|
813
903
|
</div>
|
|
814
904
|
);
|
|
@@ -829,12 +919,18 @@ function PeriodControl({
|
|
|
829
919
|
period,
|
|
830
920
|
from,
|
|
831
921
|
to,
|
|
922
|
+
clearable,
|
|
923
|
+
embedded = false,
|
|
924
|
+
onClear,
|
|
832
925
|
onChange,
|
|
833
926
|
}: {
|
|
834
927
|
periods: PeriodSpec[];
|
|
835
928
|
period: string | null;
|
|
836
929
|
from: string | null;
|
|
837
930
|
to: string | null;
|
|
931
|
+
clearable: boolean;
|
|
932
|
+
embedded?: boolean;
|
|
933
|
+
onClear?: () => void;
|
|
838
934
|
onChange: (next: {
|
|
839
935
|
period: string | null;
|
|
840
936
|
from: string | null;
|
|
@@ -878,48 +974,94 @@ function PeriodControl({
|
|
|
878
974
|
}
|
|
879
975
|
}}
|
|
880
976
|
>
|
|
881
|
-
<
|
|
882
|
-
<
|
|
883
|
-
<
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
977
|
+
<div className="relative w-fit min-w-0 max-w-full">
|
|
978
|
+
<PopoverTrigger asChild>
|
|
979
|
+
<Button
|
|
980
|
+
variant={embedded ? "ghost" : "outline"}
|
|
981
|
+
data-slot="action-list-period"
|
|
982
|
+
className={cn(
|
|
983
|
+
"max-w-full gap-1.5 overflow-hidden bg-muted/30 px-3 hover:bg-muted/30 hover:text-foreground dark:bg-input/30 dark:hover:bg-input/30",
|
|
984
|
+
clearable && "pr-10",
|
|
985
|
+
embedded &&
|
|
986
|
+
"gap-1 rounded-none border-0 bg-transparent pl-0 pr-0 font-normal shadow-none hover:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent dark:hover:bg-transparent",
|
|
987
|
+
)}
|
|
988
|
+
>
|
|
989
|
+
{embedded ? null : <CalendarIcon className="h-3.5 w-3.5" />}
|
|
990
|
+
<span className="min-w-0 truncate">{label}</span>
|
|
991
|
+
{embedded ? (
|
|
992
|
+
<span className="shrink-0 opacity-50">
|
|
993
|
+
<ChevronDown className="size-4" aria-hidden="true" />
|
|
994
|
+
</span>
|
|
995
|
+
) : null}
|
|
996
|
+
</Button>
|
|
997
|
+
</PopoverTrigger>
|
|
998
|
+
{clearable ? (
|
|
999
|
+
<InputGroupButton
|
|
1000
|
+
size="icon-xs"
|
|
1001
|
+
aria-label="Limpar período"
|
|
1002
|
+
className="absolute top-1/2 right-1.5 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
1003
|
+
onClick={() => {
|
|
1004
|
+
setOpen(false);
|
|
1005
|
+
setShowCalendar(false);
|
|
1006
|
+
setDraft(undefined);
|
|
1007
|
+
onClear?.();
|
|
1008
|
+
}}
|
|
1009
|
+
>
|
|
1010
|
+
<X className="size-3.5" />
|
|
1011
|
+
</InputGroupButton>
|
|
1012
|
+
) : null}
|
|
1013
|
+
</div>
|
|
1014
|
+
<PopoverContent
|
|
1015
|
+
align="start"
|
|
1016
|
+
className="w-auto min-w-(--radix-popover-trigger-width) p-0"
|
|
1017
|
+
>
|
|
888
1018
|
<div className="flex">
|
|
889
|
-
<
|
|
1019
|
+
<Command
|
|
890
1020
|
className={cn(
|
|
891
|
-
"
|
|
1021
|
+
"h-auto min-w-40 rounded-none",
|
|
892
1022
|
showCalendar && "border-r border-border",
|
|
893
1023
|
)}
|
|
894
1024
|
>
|
|
895
|
-
|
|
896
|
-
<
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1025
|
+
<CommandList className="max-h-none">
|
|
1026
|
+
<CommandGroup>
|
|
1027
|
+
{periods.map((p) => (
|
|
1028
|
+
<CommandItem
|
|
1029
|
+
key={p.value}
|
|
1030
|
+
value={text(p.label, p.value)}
|
|
1031
|
+
onSelect={() => {
|
|
1032
|
+
onChange({ period: p.value, from: null, to: null });
|
|
1033
|
+
setOpen(false);
|
|
1034
|
+
}}
|
|
1035
|
+
>
|
|
1036
|
+
<span className="min-w-0 flex-1 truncate">
|
|
1037
|
+
{text(p.label, p.value)}
|
|
1038
|
+
</span>
|
|
1039
|
+
<Check
|
|
1040
|
+
className={cn(
|
|
1041
|
+
"ml-auto size-4",
|
|
1042
|
+
period === p.value ? "opacity-100" : "opacity-0",
|
|
1043
|
+
)}
|
|
1044
|
+
/>
|
|
1045
|
+
</CommandItem>
|
|
1046
|
+
))}
|
|
1047
|
+
<CommandItem
|
|
1048
|
+
value="Personalizado"
|
|
1049
|
+
onSelect={() => {
|
|
1050
|
+
setShowCalendar(true);
|
|
1051
|
+
setDraft(undefined);
|
|
1052
|
+
}}
|
|
1053
|
+
>
|
|
1054
|
+
<span className="min-w-0 flex-1 truncate">Personalizado</span>
|
|
1055
|
+
<Check
|
|
1056
|
+
className={cn(
|
|
1057
|
+
"ml-auto size-4",
|
|
1058
|
+
period === "custom" ? "opacity-100" : "opacity-0",
|
|
1059
|
+
)}
|
|
1060
|
+
/>
|
|
1061
|
+
</CommandItem>
|
|
1062
|
+
</CommandGroup>
|
|
1063
|
+
</CommandList>
|
|
1064
|
+
</Command>
|
|
923
1065
|
{showCalendar && (
|
|
924
1066
|
<Calendar
|
|
925
1067
|
mode="range"
|
|
@@ -960,6 +1102,9 @@ function AdvancedFiltersDialog({
|
|
|
960
1102
|
values,
|
|
961
1103
|
onApply,
|
|
962
1104
|
filterOptions,
|
|
1105
|
+
filterDefaults,
|
|
1106
|
+
filterIcons,
|
|
1107
|
+
sectionIcons,
|
|
963
1108
|
cascade,
|
|
964
1109
|
columns = 1,
|
|
965
1110
|
}: {
|
|
@@ -969,6 +1114,9 @@ function AdvancedFiltersDialog({
|
|
|
969
1114
|
values: Record<string, unknown>;
|
|
970
1115
|
onApply: (next: Record<string, unknown>) => void;
|
|
971
1116
|
filterOptions?: Record<string, SelectOption[]> | undefined;
|
|
1117
|
+
filterDefaults?: Record<string, string> | undefined;
|
|
1118
|
+
filterIcons?: Record<string, ReactNode> | undefined;
|
|
1119
|
+
sectionIcons?: Record<string, ReactNode> | undefined;
|
|
972
1120
|
columns?: 1 | 2 | 3;
|
|
973
1121
|
/** A cascata do `depends` do pai — mudar um campo limpa os dependentes no draft. */
|
|
974
1122
|
cascade: (
|
|
@@ -1015,13 +1163,19 @@ function AdvancedFiltersDialog({
|
|
|
1015
1163
|
className="w-full"
|
|
1016
1164
|
onChange={(v) => setDraft((d) => cascade(name, { ...d, [name]: v }))}
|
|
1017
1165
|
options={optionsFor(name, spec, filterOptions)}
|
|
1166
|
+
defaultValue={filterDefaults?.[name]}
|
|
1167
|
+
icon={filterIcons?.[name]}
|
|
1018
1168
|
/>
|
|
1019
1169
|
</div>
|
|
1020
1170
|
);
|
|
1021
1171
|
|
|
1022
1172
|
const clear = (): void => {
|
|
1023
1173
|
const cleared = { ...draft };
|
|
1024
|
-
for (const [name] of specs)
|
|
1174
|
+
for (const [name] of specs) {
|
|
1175
|
+
const defaultValue = filterDefaults?.[name];
|
|
1176
|
+
if (defaultValue === undefined) delete cleared[name];
|
|
1177
|
+
else cleared[name] = defaultValue;
|
|
1178
|
+
}
|
|
1025
1179
|
onApply(cleared);
|
|
1026
1180
|
onOpenChange(false);
|
|
1027
1181
|
};
|
|
@@ -1038,25 +1192,30 @@ function AdvancedFiltersDialog({
|
|
|
1038
1192
|
<DialogTitle>Filtros</DialogTitle>
|
|
1039
1193
|
</DialogHeader>
|
|
1040
1194
|
<DialogBody className="space-y-4">
|
|
1041
|
-
{sectioned
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
</FieldGroup>
|
|
1048
|
-
</FieldSet>
|
|
1049
|
-
))
|
|
1050
|
-
: (
|
|
1195
|
+
{sectioned ? (
|
|
1196
|
+
sections.map(([section, entries]) => (
|
|
1197
|
+
<FieldSet key={section} className="gap-0">
|
|
1198
|
+
<FieldLegend variant="section" icon={sectionIcons?.[section]}>
|
|
1199
|
+
{section}
|
|
1200
|
+
</FieldLegend>
|
|
1051
1201
|
<FieldGroup className={cn("grid gap-4", gridColumns)}>
|
|
1052
|
-
{
|
|
1202
|
+
{entries.map(field)}
|
|
1053
1203
|
</FieldGroup>
|
|
1054
|
-
|
|
1204
|
+
</FieldSet>
|
|
1205
|
+
))
|
|
1206
|
+
) : (
|
|
1207
|
+
<FieldGroup className={cn("grid gap-4", gridColumns)}>
|
|
1208
|
+
{specs.map(field)}
|
|
1209
|
+
</FieldGroup>
|
|
1210
|
+
)}
|
|
1055
1211
|
</DialogBody>
|
|
1056
|
-
<DialogFooter>
|
|
1057
|
-
<
|
|
1058
|
-
|
|
1059
|
-
|
|
1212
|
+
<DialogFooter className="justify-between">
|
|
1213
|
+
<Button variant="ghost" icon={<Eraser />} onClick={clear}>
|
|
1214
|
+
Limpar
|
|
1215
|
+
</Button>
|
|
1216
|
+
<ButtonGroup mode="spaced">
|
|
1217
|
+
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
|
1218
|
+
Cancelar
|
|
1060
1219
|
</Button>
|
|
1061
1220
|
<Button onClick={apply}>Aplicar</Button>
|
|
1062
1221
|
</ButtonGroup>
|
|
@@ -1072,9 +1231,13 @@ export function ActionFilterBar({
|
|
|
1072
1231
|
state,
|
|
1073
1232
|
onStateChange,
|
|
1074
1233
|
filterOptions,
|
|
1234
|
+
filterDefaults,
|
|
1235
|
+
filterIcons,
|
|
1075
1236
|
advancedFilters,
|
|
1076
1237
|
onRefresh,
|
|
1077
1238
|
refreshing = false,
|
|
1239
|
+
periodPosition = "before",
|
|
1240
|
+
period,
|
|
1078
1241
|
controls,
|
|
1079
1242
|
actions,
|
|
1080
1243
|
}: ActionFilterBarProps): React.ReactElement {
|
|
@@ -1093,9 +1256,11 @@ export function ActionFilterBar({
|
|
|
1093
1256
|
periods[0]?.value ??
|
|
1094
1257
|
null;
|
|
1095
1258
|
const activePeriod = state.period ?? defaultPeriod;
|
|
1259
|
+
const periodBasisEntry =
|
|
1260
|
+
periods.length > 0 && period !== undefined
|
|
1261
|
+
? inlineSpecs.find(([name]) => name === period.basisFilter)
|
|
1262
|
+
: undefined;
|
|
1096
1263
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
1097
|
-
const toolbarRowRef = useRef<HTMLDivElement | null>(null);
|
|
1098
|
-
const [inlineFit, setInlineFit] = useState(Number.POSITIVE_INFINITY);
|
|
1099
1264
|
|
|
1100
1265
|
const cascadeClear = (
|
|
1101
1266
|
changed: string,
|
|
@@ -1113,41 +1278,8 @@ export function ActionFilterBar({
|
|
|
1113
1278
|
return next;
|
|
1114
1279
|
};
|
|
1115
1280
|
|
|
1116
|
-
useLayoutEffect(() => {
|
|
1117
|
-
const row = toolbarRowRef.current;
|
|
1118
|
-
if (row === null) return;
|
|
1119
|
-
const overflowing = (): boolean =>
|
|
1120
|
-
row.scrollWidth > row.clientWidth + 1 ||
|
|
1121
|
-
Array.from(row.children).some(
|
|
1122
|
-
(child) => child.scrollWidth > child.clientWidth + 1,
|
|
1123
|
-
);
|
|
1124
|
-
const compute = (): void => {
|
|
1125
|
-
row.classList.remove("flex-wrap");
|
|
1126
|
-
const nodes = Array.from(
|
|
1127
|
-
row.querySelectorAll<HTMLElement>('[data-slot="action-filter-inline"]'),
|
|
1128
|
-
);
|
|
1129
|
-
for (const node of nodes) node.style.removeProperty("display");
|
|
1130
|
-
let count = nodes.length;
|
|
1131
|
-
while (count > 0 && overflowing()) {
|
|
1132
|
-
count -= 1;
|
|
1133
|
-
nodes[count]?.style.setProperty("display", "none");
|
|
1134
|
-
}
|
|
1135
|
-
if (count === 0 && overflowing()) row.classList.add("flex-wrap");
|
|
1136
|
-
setInlineFit(count >= nodes.length ? Number.POSITIVE_INFINITY : count);
|
|
1137
|
-
};
|
|
1138
|
-
compute();
|
|
1139
|
-
const observer = new ResizeObserver(compute);
|
|
1140
|
-
observer.observe(row);
|
|
1141
|
-
return () => observer.disconnect();
|
|
1142
|
-
}, [inlineSpecs.length, state.filters, inlineFit]);
|
|
1143
|
-
|
|
1144
|
-
const overflowSpecs = Number.isFinite(inlineFit)
|
|
1145
|
-
? inlineSpecs.slice(inlineFit)
|
|
1146
|
-
: [];
|
|
1147
1281
|
const modalSpecs = visibleFilterSpecs.filter(
|
|
1148
|
-
([
|
|
1149
|
-
filterPlacement(spec) === "advanced" ||
|
|
1150
|
-
overflowSpecs.some(([overflowName]) => overflowName === name),
|
|
1282
|
+
([, spec]) => filterPlacement(spec) === "advanced",
|
|
1151
1283
|
);
|
|
1152
1284
|
const setFilter = (name: string, value: unknown): void => {
|
|
1153
1285
|
onStateChange({
|
|
@@ -1156,7 +1288,10 @@ export function ActionFilterBar({
|
|
|
1156
1288
|
});
|
|
1157
1289
|
};
|
|
1158
1290
|
const chips = modalSpecs
|
|
1159
|
-
.filter(([name]) =>
|
|
1291
|
+
.filter(([name]) => {
|
|
1292
|
+
const value = state.filters[name] ?? filterDefaults?.[name] ?? "";
|
|
1293
|
+
return !isEmptyValue(value) && value !== (filterDefaults?.[name] ?? "");
|
|
1294
|
+
})
|
|
1160
1295
|
.map(([name, spec]) => {
|
|
1161
1296
|
const raw = state.filters[name];
|
|
1162
1297
|
const fromProps = optionsFor(name, spec, filterOptions);
|
|
@@ -1170,76 +1305,227 @@ export function ActionFilterBar({
|
|
|
1170
1305
|
value: Array.isArray(raw) ? raw.map(display).join(", ") : display(raw),
|
|
1171
1306
|
};
|
|
1172
1307
|
});
|
|
1173
|
-
const
|
|
1174
|
-
|
|
1308
|
+
const inlineControlCount =
|
|
1309
|
+
inlineSpecs.filter(([name]) => name !== periodBasisEntry?.[0]).length +
|
|
1310
|
+
(periods.length > 0 ? 1 : 0) +
|
|
1311
|
+
(hasSearch ? 1 : 0);
|
|
1312
|
+
const showInlineLabels = inlineControlCount > 1;
|
|
1313
|
+
const searchAtActionsEnd =
|
|
1314
|
+
hasSearch && (inlineSpecs.length > 0 || periods.length > 0);
|
|
1315
|
+
const periodAfterFilter =
|
|
1316
|
+
typeof periodPosition === "object" ? periodPosition.afterFilter : null;
|
|
1317
|
+
const anchoredPeriodExists = inlineSpecs.some(
|
|
1318
|
+
([name]) => name === periodAfterFilter,
|
|
1319
|
+
);
|
|
1320
|
+
const periodField =
|
|
1321
|
+
periods.length > 0 ? (
|
|
1322
|
+
<PeriodControl
|
|
1323
|
+
periods={periods}
|
|
1324
|
+
period={activePeriod}
|
|
1325
|
+
from={state.from}
|
|
1326
|
+
to={state.to}
|
|
1327
|
+
clearable={state.period !== null}
|
|
1328
|
+
onClear={() =>
|
|
1329
|
+
onStateChange({
|
|
1330
|
+
...state,
|
|
1331
|
+
period: null,
|
|
1332
|
+
from: null,
|
|
1333
|
+
to: null,
|
|
1334
|
+
})
|
|
1335
|
+
}
|
|
1336
|
+
onChange={(next) =>
|
|
1337
|
+
onStateChange({
|
|
1338
|
+
...state,
|
|
1339
|
+
...next,
|
|
1340
|
+
period: next.period === defaultPeriod ? null : next.period,
|
|
1341
|
+
})
|
|
1342
|
+
}
|
|
1343
|
+
/>
|
|
1344
|
+
) : null;
|
|
1345
|
+
const periodControl =
|
|
1346
|
+
periodField === null || periodBasisEntry !== undefined ? null : (
|
|
1347
|
+
<Labeled
|
|
1348
|
+
label="Período"
|
|
1349
|
+
showLabel={showInlineLabels}
|
|
1350
|
+
className="max-w-80"
|
|
1351
|
+
>
|
|
1352
|
+
{periodField}
|
|
1353
|
+
</Labeled>
|
|
1354
|
+
);
|
|
1355
|
+
const periodBasisDefault =
|
|
1356
|
+
periodBasisEntry === undefined
|
|
1357
|
+
? ""
|
|
1358
|
+
: (filterDefaults?.[periodBasisEntry[0]] ?? "");
|
|
1359
|
+
const periodBasisValue =
|
|
1360
|
+
periodBasisEntry === undefined
|
|
1361
|
+
? ""
|
|
1362
|
+
: ((state.filters[periodBasisEntry[0]] as string | undefined) ??
|
|
1363
|
+
periodBasisDefault);
|
|
1364
|
+
const periodGroupClearable =
|
|
1365
|
+
periodBasisEntry !== undefined &&
|
|
1366
|
+
(state.period !== null || periodBasisValue !== periodBasisDefault);
|
|
1175
1367
|
const searchBox = hasSearch ? (
|
|
1176
|
-
<
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1368
|
+
<Labeled
|
|
1369
|
+
label="Busca"
|
|
1370
|
+
slot="action-filter-search"
|
|
1371
|
+
showLabel={showInlineLabels}
|
|
1372
|
+
className="w-[clamp(8rem,20vw,16rem)] max-w-64 basis-[clamp(8rem,20vw,16rem)]"
|
|
1373
|
+
>
|
|
1374
|
+
<ClearableInput
|
|
1375
|
+
className="w-full min-w-0"
|
|
1376
|
+
shape="pill"
|
|
1377
|
+
leading={<Search />}
|
|
1378
|
+
applied={state.q}
|
|
1379
|
+
placeholder={text(action.text?.placeholder, "Buscar…")}
|
|
1380
|
+
onApply={(q) => q !== state.q && onStateChange({ ...state, q })}
|
|
1381
|
+
/>
|
|
1382
|
+
</Labeled>
|
|
1183
1383
|
) : null;
|
|
1184
1384
|
|
|
1185
1385
|
return (
|
|
1186
1386
|
<TooltipProvider>
|
|
1187
1387
|
<div data-slot="action-filter-bar" className="space-y-2">
|
|
1188
|
-
<div
|
|
1189
|
-
{
|
|
1190
|
-
{
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
onStateChange({
|
|
1199
|
-
...state,
|
|
1200
|
-
...next,
|
|
1201
|
-
period: next.period === defaultPeriod ? null : next.period,
|
|
1202
|
-
})
|
|
1203
|
-
}
|
|
1204
|
-
/>
|
|
1205
|
-
</Labeled>
|
|
1206
|
-
)}
|
|
1207
|
-
{inlineSpecs.map(([name, spec]) => (
|
|
1208
|
-
<Labeled
|
|
1209
|
-
key={name}
|
|
1210
|
-
label={text(spec.label, name)}
|
|
1211
|
-
slot="action-filter-inline"
|
|
1212
|
-
>
|
|
1213
|
-
<FilterField
|
|
1214
|
-
name={name}
|
|
1215
|
-
spec={spec}
|
|
1216
|
-
value={state.filters[name]}
|
|
1217
|
-
allValues={state.filters}
|
|
1218
|
-
onChange={(value) => setFilter(name, value)}
|
|
1219
|
-
options={optionsFor(name, spec, filterOptions)}
|
|
1220
|
-
/>
|
|
1221
|
-
</Labeled>
|
|
1222
|
-
))}
|
|
1223
|
-
{modalSpecs.length > 0 && (
|
|
1224
|
-
<Tooltip>
|
|
1225
|
-
<TooltipTrigger asChild>
|
|
1226
|
-
<Button
|
|
1227
|
-
variant="ghost"
|
|
1228
|
-
size="icon"
|
|
1229
|
-
className="shrink-0"
|
|
1230
|
-
aria-label="Filtros"
|
|
1231
|
-
onClick={() => setAdvancedOpen(true)}
|
|
1388
|
+
<div className="flex min-w-0 items-end gap-3">
|
|
1389
|
+
{periodPosition === "before" && periodControl}
|
|
1390
|
+
{inlineSpecs.map(([name, spec]) => {
|
|
1391
|
+
if (name === periodBasisEntry?.[0]) {
|
|
1392
|
+
return (
|
|
1393
|
+
<Labeled
|
|
1394
|
+
key={name}
|
|
1395
|
+
label="Período"
|
|
1396
|
+
showLabel={showInlineLabels}
|
|
1397
|
+
className="max-w-80"
|
|
1232
1398
|
>
|
|
1233
|
-
<
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1399
|
+
<div
|
|
1400
|
+
data-slot="action-filter-period-group"
|
|
1401
|
+
className={cn(
|
|
1402
|
+
"flex min-h-9 w-fit min-w-0 max-w-full items-center rounded-md border border-input bg-muted/30 text-foreground transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[0.1875rem] focus-within:ring-ring/50 dark:bg-input/30",
|
|
1403
|
+
!periodGroupClearable && "pr-3",
|
|
1404
|
+
)}
|
|
1405
|
+
>
|
|
1406
|
+
<CalendarIcon
|
|
1407
|
+
className="ml-3 size-3.5 shrink-0 text-muted-foreground"
|
|
1408
|
+
aria-hidden="true"
|
|
1409
|
+
/>
|
|
1410
|
+
<FilterField
|
|
1411
|
+
name={name}
|
|
1412
|
+
spec={spec}
|
|
1413
|
+
value={state.filters[name]}
|
|
1414
|
+
allValues={state.filters}
|
|
1415
|
+
onChange={(value) => setFilter(name, value)}
|
|
1416
|
+
options={optionsFor(name, spec, filterOptions)}
|
|
1417
|
+
defaultValue={filterDefaults?.[name]}
|
|
1418
|
+
embedded
|
|
1419
|
+
className="w-fit min-w-0 max-w-80 [&_[data-slot=select-control]]:gap-1 [&_[data-slot=select-control]]:rounded-none [&_[data-slot=select-control]]:bg-transparent [&_[data-slot=select-control]]:pl-2 [&_[data-slot=select-control]]:pr-0 [&_[data-slot=select-control]]:font-normal [&_[data-slot=select-control]]:text-foreground [&_[data-slot=select-control]]:shadow-none [&_[data-slot=select-control]:hover]:bg-transparent [&_[data-slot=select-control]:focus-within]:ring-0 [&_[data-slot=select-control]:focus-within]:ring-offset-0"
|
|
1420
|
+
/>
|
|
1421
|
+
<Separator
|
|
1422
|
+
data-slot="action-filter-period-separator"
|
|
1423
|
+
orientation="vertical"
|
|
1424
|
+
className="ml-1.5 mr-2.5 h-4 shrink-0"
|
|
1425
|
+
/>
|
|
1426
|
+
<PeriodControl
|
|
1427
|
+
periods={periods}
|
|
1428
|
+
period={activePeriod}
|
|
1429
|
+
from={state.from}
|
|
1430
|
+
to={state.to}
|
|
1431
|
+
clearable={false}
|
|
1432
|
+
embedded
|
|
1433
|
+
onChange={(next) =>
|
|
1434
|
+
onStateChange({
|
|
1435
|
+
...state,
|
|
1436
|
+
...next,
|
|
1437
|
+
period:
|
|
1438
|
+
next.period === defaultPeriod ? null : next.period,
|
|
1439
|
+
})
|
|
1440
|
+
}
|
|
1441
|
+
/>
|
|
1442
|
+
{periodGroupClearable ? (
|
|
1443
|
+
<InputGroupButton
|
|
1444
|
+
size="icon-xs"
|
|
1445
|
+
aria-label="Limpar período"
|
|
1446
|
+
className="ml-0.5 mr-1.5 shrink-0 text-muted-foreground hover:text-foreground"
|
|
1447
|
+
onClick={() => {
|
|
1448
|
+
const filters = { ...state.filters };
|
|
1449
|
+
if (periodBasisDefault === "") delete filters[name];
|
|
1450
|
+
else filters[name] = periodBasisDefault;
|
|
1451
|
+
onStateChange({
|
|
1452
|
+
...state,
|
|
1453
|
+
filters: cascadeClear(name, filters),
|
|
1454
|
+
period: null,
|
|
1455
|
+
from: null,
|
|
1456
|
+
to: null,
|
|
1457
|
+
});
|
|
1458
|
+
}}
|
|
1459
|
+
>
|
|
1460
|
+
<X className="size-3.5" />
|
|
1461
|
+
</InputGroupButton>
|
|
1462
|
+
) : null}
|
|
1463
|
+
</div>
|
|
1464
|
+
</Labeled>
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
return (
|
|
1468
|
+
<Fragment key={name}>
|
|
1469
|
+
<Labeled
|
|
1470
|
+
label={text(spec.label, name)}
|
|
1471
|
+
slot="action-filter-inline"
|
|
1472
|
+
showLabel={showInlineLabels}
|
|
1473
|
+
className="max-w-80"
|
|
1474
|
+
>
|
|
1475
|
+
<FilterField
|
|
1476
|
+
name={name}
|
|
1477
|
+
spec={spec}
|
|
1478
|
+
value={state.filters[name]}
|
|
1479
|
+
allValues={state.filters}
|
|
1480
|
+
onChange={(value) => setFilter(name, value)}
|
|
1481
|
+
options={optionsFor(name, spec, filterOptions)}
|
|
1482
|
+
defaultValue={filterDefaults?.[name]}
|
|
1483
|
+
icon={filterIcons?.[name]}
|
|
1484
|
+
/>
|
|
1485
|
+
</Labeled>
|
|
1486
|
+
{periodAfterFilter === name && periodControl}
|
|
1487
|
+
</Fragment>
|
|
1488
|
+
);
|
|
1489
|
+
})}
|
|
1490
|
+
{(periodPosition === "after" ||
|
|
1491
|
+
(periodAfterFilter !== null && !anchoredPeriodExists)) &&
|
|
1492
|
+
periodControl}
|
|
1493
|
+
{!searchAtActionsEnd && searchBox}
|
|
1494
|
+
<div
|
|
1495
|
+
data-slot="action-filter-actions"
|
|
1496
|
+
className="ml-auto flex min-w-0 items-end gap-2"
|
|
1497
|
+
>
|
|
1498
|
+
{searchAtActionsEnd && searchBox}
|
|
1499
|
+
{(modalSpecs.length > 0 ||
|
|
1500
|
+
onRefresh !== undefined ||
|
|
1501
|
+
controls !== undefined) && (
|
|
1242
1502
|
<ButtonGroup mode="spaced">
|
|
1503
|
+
{modalSpecs.length > 0 && (
|
|
1504
|
+
<Tooltip>
|
|
1505
|
+
<TooltipTrigger asChild>
|
|
1506
|
+
<Button
|
|
1507
|
+
variant="ghost"
|
|
1508
|
+
size="icon"
|
|
1509
|
+
className="relative shrink-0"
|
|
1510
|
+
aria-label="Filtros"
|
|
1511
|
+
onClick={() => setAdvancedOpen(true)}
|
|
1512
|
+
>
|
|
1513
|
+
<Filter className="h-3.5 w-3.5" />
|
|
1514
|
+
{chips.length > 0 && (
|
|
1515
|
+
<Dot
|
|
1516
|
+
context="primary"
|
|
1517
|
+
className="absolute top-1 right-1 ring-2 ring-background"
|
|
1518
|
+
/>
|
|
1519
|
+
)}
|
|
1520
|
+
</Button>
|
|
1521
|
+
</TooltipTrigger>
|
|
1522
|
+
<TooltipContent>
|
|
1523
|
+
{chips.length === 0
|
|
1524
|
+
? "Filtros"
|
|
1525
|
+
: `${chips.length} ${chips.length === 1 ? "filtro aplicado" : "filtros aplicados"}`}
|
|
1526
|
+
</TooltipContent>
|
|
1527
|
+
</Tooltip>
|
|
1528
|
+
)}
|
|
1243
1529
|
{onRefresh !== undefined && (
|
|
1244
1530
|
<Tooltip>
|
|
1245
1531
|
<TooltipTrigger asChild>
|
|
@@ -1307,7 +1593,10 @@ export function ActionFilterBar({
|
|
|
1307
1593
|
cascade={cascadeClear}
|
|
1308
1594
|
onApply={(filters) => onStateChange({ ...state, filters })}
|
|
1309
1595
|
filterOptions={filterOptions}
|
|
1596
|
+
filterDefaults={filterDefaults}
|
|
1597
|
+
filterIcons={filterIcons}
|
|
1310
1598
|
columns={advancedFilters?.columns}
|
|
1599
|
+
sectionIcons={advancedFilters?.sectionIcons}
|
|
1311
1600
|
/>
|
|
1312
1601
|
)}
|
|
1313
1602
|
</div>
|
|
@@ -1347,11 +1636,16 @@ function RowActions({ children }: { children: ReactNode }) {
|
|
|
1347
1636
|
export function ActionList<TInput, TItem>({
|
|
1348
1637
|
action,
|
|
1349
1638
|
input,
|
|
1639
|
+
layout = "flow",
|
|
1350
1640
|
toolbarActions,
|
|
1351
1641
|
columns,
|
|
1352
1642
|
cells,
|
|
1353
1643
|
filterOptions,
|
|
1644
|
+
filterDefaults,
|
|
1645
|
+
filterIcons,
|
|
1354
1646
|
advancedFilters,
|
|
1647
|
+
periodPosition,
|
|
1648
|
+
period,
|
|
1355
1649
|
views,
|
|
1356
1650
|
batch,
|
|
1357
1651
|
rowId = (item: TItem) => String((item as { id?: unknown }).id ?? ""),
|
|
@@ -1434,7 +1728,10 @@ export function ActionList<TInput, TItem>({
|
|
|
1434
1728
|
// — Input efetivo: escopo base + filtros preenchidos + período (→ from/to) + q + sort —
|
|
1435
1729
|
const effectiveInput = useMemo(() => {
|
|
1436
1730
|
const filled: Record<string, unknown> = {};
|
|
1437
|
-
for (const [name, v] of Object.entries(
|
|
1731
|
+
for (const [name, v] of Object.entries({
|
|
1732
|
+
...filterDefaults,
|
|
1733
|
+
...state.filters,
|
|
1734
|
+
})) {
|
|
1438
1735
|
if (isEmptyValue(v)) continue;
|
|
1439
1736
|
const filter = action.filters?.[name];
|
|
1440
1737
|
if (
|
|
@@ -1473,7 +1770,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1473
1770
|
limit: state.limit ?? pageSize,
|
|
1474
1771
|
page: state.page,
|
|
1475
1772
|
} as TInput;
|
|
1476
|
-
}, [input, state, pageSize]);
|
|
1773
|
+
}, [input, state, pageSize, filterDefaults]);
|
|
1477
1774
|
|
|
1478
1775
|
const inputKey = JSON.stringify(effectiveInput);
|
|
1479
1776
|
const { items, total, isLoading, isFetching, isError, error, refetch } =
|
|
@@ -1617,7 +1914,11 @@ export function ActionList<TInput, TItem>({
|
|
|
1617
1914
|
state={state}
|
|
1618
1915
|
onStateChange={(next) => setStateResetPage({ ...state, ...next })}
|
|
1619
1916
|
filterOptions={filterOptions}
|
|
1917
|
+
filterDefaults={filterDefaults}
|
|
1918
|
+
filterIcons={filterIcons}
|
|
1620
1919
|
advancedFilters={advancedFilters}
|
|
1920
|
+
periodPosition={periodPosition}
|
|
1921
|
+
period={period}
|
|
1621
1922
|
onRefresh={() => refetch()}
|
|
1622
1923
|
refreshing={isLoading}
|
|
1623
1924
|
controls={
|
|
@@ -1751,6 +2052,55 @@ export function ActionList<TInput, TItem>({
|
|
|
1751
2052
|
// — Estados de carga —
|
|
1752
2053
|
const busy = isFetching || extraLoading === true;
|
|
1753
2054
|
const isEmpty = empty !== undefined ? empty(items) : items.length === 0;
|
|
2055
|
+
const initialLoading =
|
|
2056
|
+
busy && items.length === 0 && !(empty !== undefined && !isEmpty);
|
|
2057
|
+
const tableFillsAvailableHeight =
|
|
2058
|
+
layout === "fill" && children === undefined && activeView === "table";
|
|
2059
|
+
const tableStateActive =
|
|
2060
|
+
tableFillsAvailableHeight && (initialLoading || isError || isEmpty);
|
|
2061
|
+
const tableColumnCount = Math.max(
|
|
2062
|
+
1,
|
|
2063
|
+
derived.length +
|
|
2064
|
+
(hasSelection ? 1 : 0) +
|
|
2065
|
+
(rowActions !== undefined ? 1 : 0),
|
|
2066
|
+
);
|
|
2067
|
+
const tableRows = items.map((item, idx) => (
|
|
2068
|
+
<TableRow
|
|
2069
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
2070
|
+
key={idx}
|
|
2071
|
+
className={onRowClick !== undefined ? "cursor-pointer" : undefined}
|
|
2072
|
+
onClick={onRowClick !== undefined ? () => onRowClick(item) : undefined}
|
|
2073
|
+
>
|
|
2074
|
+
{hasSelection && (
|
|
2075
|
+
<TableCell className="w-px" onClick={(e) => e.stopPropagation()}>
|
|
2076
|
+
<Checkbox
|
|
2077
|
+
aria-label="Selecionar linha"
|
|
2078
|
+
disabled={!selectable(item)}
|
|
2079
|
+
checked={selected.has(rowId(item))}
|
|
2080
|
+
onCheckedChange={(v) => {
|
|
2081
|
+
const next = new Set(selected);
|
|
2082
|
+
if (v === true) next.add(rowId(item));
|
|
2083
|
+
else next.delete(rowId(item));
|
|
2084
|
+
setSelected(next);
|
|
2085
|
+
}}
|
|
2086
|
+
/>
|
|
2087
|
+
</TableCell>
|
|
2088
|
+
)}
|
|
2089
|
+
{derived.map((col) => (
|
|
2090
|
+
<TableCell key={col.key} className={col.className}>
|
|
2091
|
+
{col.cell(item)}
|
|
2092
|
+
</TableCell>
|
|
2093
|
+
))}
|
|
2094
|
+
{rowActions !== undefined && (
|
|
2095
|
+
<TableCell
|
|
2096
|
+
className="w-px whitespace-nowrap text-right"
|
|
2097
|
+
onClick={(e) => e.stopPropagation()}
|
|
2098
|
+
>
|
|
2099
|
+
<RowActions>{rowActions(item)}</RowActions>
|
|
2100
|
+
</TableCell>
|
|
2101
|
+
)}
|
|
2102
|
+
</TableRow>
|
|
2103
|
+
));
|
|
1754
2104
|
let content: ReactNode;
|
|
1755
2105
|
if (children !== undefined) {
|
|
1756
2106
|
// Modo COMPOSIÇÃO: o layout é de quem compõe; a toolbar e os estados seguem daqui.
|
|
@@ -1760,8 +2110,22 @@ export function ActionList<TInput, TItem>({
|
|
|
1760
2110
|
content = views[activeView].render(items, () => refetch());
|
|
1761
2111
|
} else {
|
|
1762
2112
|
content = (
|
|
1763
|
-
<Table
|
|
1764
|
-
|
|
2113
|
+
<Table
|
|
2114
|
+
variant="framed"
|
|
2115
|
+
className={tableStateActive ? "h-full" : undefined}
|
|
2116
|
+
containerClassName={
|
|
2117
|
+
tableFillsAvailableHeight
|
|
2118
|
+
? "absolute inset-0 overflow-auto"
|
|
2119
|
+
: undefined
|
|
2120
|
+
}
|
|
2121
|
+
>
|
|
2122
|
+
<TableHeader
|
|
2123
|
+
className={
|
|
2124
|
+
tableFillsAvailableHeight
|
|
2125
|
+
? "sticky top-0 z-10 bg-muted/30 dark:bg-input/30"
|
|
2126
|
+
: undefined
|
|
2127
|
+
}
|
|
2128
|
+
>
|
|
1765
2129
|
<TableRow>
|
|
1766
2130
|
{hasSelection && (
|
|
1767
2131
|
<TableHead className="w-px">
|
|
@@ -1808,58 +2172,38 @@ export function ActionList<TInput, TItem>({
|
|
|
1808
2172
|
{rowActions !== undefined && <TableHead className="w-px" />}
|
|
1809
2173
|
</TableRow>
|
|
1810
2174
|
</TableHeader>
|
|
1811
|
-
<TableBody
|
|
1812
|
-
{
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
}
|
|
2175
|
+
<TableBody
|
|
2176
|
+
className={
|
|
2177
|
+
tableStateActive
|
|
2178
|
+
? "h-full [&_[data-slot=data-state]]:h-full"
|
|
2179
|
+
: undefined
|
|
2180
|
+
}
|
|
2181
|
+
>
|
|
2182
|
+
{tableFillsAvailableHeight ? (
|
|
2183
|
+
<DataState
|
|
2184
|
+
loading={initialLoading}
|
|
2185
|
+
error={isError ? error : null}
|
|
2186
|
+
empty={isEmpty}
|
|
2187
|
+
emptyMessage={emptyMessage}
|
|
2188
|
+
{...(errorMessage !== undefined ? { errorMessage } : {})}
|
|
2189
|
+
{...(retryLabel !== undefined ? { retryLabel } : {})}
|
|
2190
|
+
onRetry={() => refetch()}
|
|
2191
|
+
colSpan={tableColumnCount}
|
|
1822
2192
|
>
|
|
1823
|
-
{
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
<Checkbox
|
|
1829
|
-
aria-label="Selecionar linha"
|
|
1830
|
-
disabled={!selectable(item)}
|
|
1831
|
-
checked={selected.has(rowId(item))}
|
|
1832
|
-
onCheckedChange={(v) => {
|
|
1833
|
-
const next = new Set(selected);
|
|
1834
|
-
if (v === true) next.add(rowId(item));
|
|
1835
|
-
else next.delete(rowId(item));
|
|
1836
|
-
setSelected(next);
|
|
1837
|
-
}}
|
|
1838
|
-
/>
|
|
1839
|
-
</TableCell>
|
|
1840
|
-
)}
|
|
1841
|
-
{derived.map((col) => (
|
|
1842
|
-
<TableCell key={col.key} className={col.className}>
|
|
1843
|
-
{col.cell(item)}
|
|
1844
|
-
</TableCell>
|
|
1845
|
-
))}
|
|
1846
|
-
{rowActions !== undefined && (
|
|
1847
|
-
<TableCell
|
|
1848
|
-
className="w-px whitespace-nowrap text-right"
|
|
1849
|
-
onClick={(e) => e.stopPropagation()}
|
|
1850
|
-
>
|
|
1851
|
-
<RowActions>{rowActions(item)}</RowActions>
|
|
1852
|
-
</TableCell>
|
|
1853
|
-
)}
|
|
1854
|
-
</TableRow>
|
|
1855
|
-
))}
|
|
2193
|
+
{tableRows}
|
|
2194
|
+
</DataState>
|
|
2195
|
+
) : (
|
|
2196
|
+
tableRows
|
|
2197
|
+
)}
|
|
1856
2198
|
</TableBody>
|
|
1857
2199
|
</Table>
|
|
1858
2200
|
);
|
|
1859
2201
|
}
|
|
1860
|
-
const body = (
|
|
2202
|
+
const body = tableFillsAvailableHeight ? (
|
|
2203
|
+
content
|
|
2204
|
+
) : (
|
|
1861
2205
|
<DataState
|
|
1862
|
-
loading={
|
|
2206
|
+
loading={initialLoading}
|
|
1863
2207
|
error={isError ? error : null}
|
|
1864
2208
|
empty={isEmpty}
|
|
1865
2209
|
emptyMessage={emptyMessage}
|
|
@@ -1982,7 +2326,15 @@ export function ActionList<TInput, TItem>({
|
|
|
1982
2326
|
|
|
1983
2327
|
return (
|
|
1984
2328
|
<TooltipProvider>
|
|
1985
|
-
<div
|
|
2329
|
+
<div
|
|
2330
|
+
data-action={action.name}
|
|
2331
|
+
data-layout={layout}
|
|
2332
|
+
className={cn(
|
|
2333
|
+
layout === "fill"
|
|
2334
|
+
? "flex h-full min-h-0 flex-1 flex-col gap-3"
|
|
2335
|
+
: "space-y-3",
|
|
2336
|
+
)}
|
|
2337
|
+
>
|
|
1986
2338
|
{toolbar}
|
|
1987
2339
|
{beforeContent}
|
|
1988
2340
|
{selectionBar}
|
|
@@ -1990,6 +2342,9 @@ export function ActionList<TInput, TItem>({
|
|
|
1990
2342
|
<div
|
|
1991
2343
|
aria-busy={busy}
|
|
1992
2344
|
className={cn(
|
|
2345
|
+
layout === "fill" && "relative flex min-h-0 flex-1 flex-col",
|
|
2346
|
+
layout === "fill" &&
|
|
2347
|
+
(tableFillsAvailableHeight ? "overflow-hidden" : "overflow-auto"),
|
|
1993
2348
|
busy && items.length > 0 && "pointer-events-none opacity-60",
|
|
1994
2349
|
)}
|
|
1995
2350
|
>
|