rl-core-front 0.2.0 → 0.4.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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/_services/api/schema.d.ts +238 -117
  3. package/src/_utils/filter.ts +15 -34
  4. package/src/components/app-shell.tsx +51 -1
  5. package/src/components/brand-header.tsx +23 -8
  6. package/src/components/brand-panel.tsx +41 -0
  7. package/src/components/index.ts +1 -0
  8. package/src/components/ui/button.tsx +5 -0
  9. package/src/components/ui/confirm-dialog.tsx +3 -1
  10. package/src/components/ui/data-table.tsx +13 -4
  11. package/src/components/ui/dialog.tsx +120 -21
  12. package/src/components/ui/filter-sheet.tsx +47 -25
  13. package/src/components/ui/index.ts +1 -1
  14. package/src/components/ui/segmented-control.tsx +74 -0
  15. package/src/contexts/brand-context.tsx +15 -0
  16. package/src/contexts/i18n-context.tsx +69 -4
  17. package/src/features/audit/audit-screen.tsx +13 -140
  18. package/src/features/audit/components/audit-diff.tsx +156 -0
  19. package/src/features/audit/components/index.ts +3 -0
  20. package/src/features/audit/components/record-audit-button.tsx +176 -0
  21. package/src/features/audit/enums/audit-data-action.enum.ts +20 -0
  22. package/src/features/audit/hooks/use-audit-trail.ts +9 -2
  23. package/src/features/audit/services/audit.service.ts +43 -1
  24. package/src/features/login/login-screen.tsx +86 -40
  25. package/src/features/logs/enums/error-type.enum.ts +21 -0
  26. package/src/features/logs/enums/request-outcome.enum.ts +14 -0
  27. package/src/features/logs/hooks/use-request-logs.ts +26 -3
  28. package/src/features/logs/logs-screen.tsx +2 -14
  29. package/src/features/logs/panels/index.ts +0 -1
  30. package/src/features/logs/panels/request-logs-panel.tsx +177 -25
  31. package/src/features/logs/services/logs.service.ts +13 -51
  32. package/src/features/notifications/components/notification-item.tsx +10 -8
  33. package/src/features/notifications/enums/notification-type.enum.ts +8 -0
  34. package/src/features/profile/profile-screen.tsx +2 -1
  35. package/src/features/rbac/panels/groups-panel.tsx +20 -7
  36. package/src/features/users/components/user-form-dialog.tsx +16 -7
  37. package/src/features/users/hooks/use-users.ts +9 -2
  38. package/src/hooks/use-filters.ts +25 -2
  39. package/src/hooks/use-list-query.ts +14 -1
  40. package/src/i18n/messages/en.ts +17 -5
  41. package/src/i18n/messages/pt.ts +18 -5
  42. package/src/index.ts +12 -1
  43. package/src/components/ui/filter-chips.tsx +0 -88
  44. package/src/features/logs/hooks/use-error-logs.ts +0 -65
  45. package/src/features/logs/panels/error-logs-panel.tsx +0 -183
@@ -46,6 +46,30 @@ interface NavItem {
46
46
  perm: string | null;
47
47
  }
48
48
 
49
+ /**
50
+ * Item de menu declarado pelo projeto.
51
+ *
52
+ * O core não conhece as telas de quem o instala (§14), então elas entram por
53
+ * aqui em vez de o projeto reescrever o shell.
54
+ *
55
+ * ```tsx
56
+ * <AppShell navItems={[{ label: "nav.tasks", href: "/tarefas", icon: ListTodo }]}>
57
+ * ```
58
+ */
59
+ export interface AppNavItem {
60
+ /**
61
+ * Chave de i18n (`nav.tasks`) ou o texto pronto (`"Tarefas"`).
62
+ *
63
+ * Chave sem tradução cadastrada volta como veio, então as duas formas
64
+ * funcionam — o projeto só precisa do dicionário quando for bilíngue.
65
+ */
66
+ label: string;
67
+ href: string;
68
+ icon: React.ComponentType<{ className?: string }>;
69
+ /** Permissão exigida para o item aparecer. Ausente, aparece para todos. */
70
+ permission?: string | null;
71
+ }
72
+
49
73
  const DASHBOARD: NavItem = {
50
74
  key: "nav.dashboard",
51
75
  href: "/dashboard",
@@ -71,7 +95,13 @@ const ADMIN_CHILDREN: NavItem[] = [
71
95
  },
72
96
  ];
73
97
 
74
- export function AppShell({ children }: { children: React.ReactNode }): JSX.Element {
98
+ export interface AppShellProps {
99
+ children: React.ReactNode;
100
+ /** Telas do projeto no menu, entre o dashboard e o grupo de administração. */
101
+ navItems?: AppNavItem[];
102
+ }
103
+
104
+ export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
75
105
  const { user, loading, logout, hasPermission } = useAuth();
76
106
  const { t } = useI18n();
77
107
  const brand = useBrand();
@@ -89,6 +119,22 @@ export function AppShell({ children }: { children: React.ReactNode }): JSX.Eleme
89
119
  // eslint-disable-next-line react-hooks/exhaustive-deps
90
120
  [user],
91
121
  );
122
+
123
+ // Os itens do projeto passam pelo mesmo filtro de permissão dos do core: um
124
+ // menu que mostra o que o usuário não pode abrir só entrega 403.
125
+ const projectItems = useMemo<NavItem[]>(
126
+ () =>
127
+ (navItems ?? [])
128
+ .filter((item) => !item.permission || hasPermission(item.permission))
129
+ .map((item) => ({
130
+ key: item.label,
131
+ href: item.href,
132
+ icon: item.icon,
133
+ perm: item.permission ?? null,
134
+ })),
135
+ // eslint-disable-next-line react-hooks/exhaustive-deps
136
+ [user, navItems],
137
+ );
92
138
  const adminActive = adminChildren.some((c) => pathname === c.href);
93
139
  const [adminOpen, setAdminOpen] = useState(adminActive);
94
140
 
@@ -158,6 +204,10 @@ export function AppShell({ children }: { children: React.ReactNode }): JSX.Eleme
158
204
  <div className="flex flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden p-3">
159
205
  <NavLink item={DASHBOARD} mini={mini} />
160
206
 
207
+ {projectItems.map((item) => (
208
+ <NavLink key={item.href} item={item} mini={mini} />
209
+ ))}
210
+
161
211
  {adminChildren.length > 0 &&
162
212
  (mini ? (
163
213
  <>
@@ -7,6 +7,12 @@ import { useBrand } from "#core/contexts";
7
7
  export interface BrandHeaderProps {
8
8
  /** Título do passo ou da tela, exibido sob a marca. */
9
9
  title: string;
10
+ /**
11
+ * Mostra logo e nome acima do título. Desligue quando a marca já estiver na
12
+ * tela por outro caminho — é o caso do `BrandPanel` ao lado do formulário,
13
+ * onde repeti-la no cartão seria dizer a mesma coisa duas vezes.
14
+ */
15
+ showBrand?: boolean;
10
16
  }
11
17
 
12
18
  /**
@@ -17,19 +23,28 @@ export interface BrandHeaderProps {
17
23
  * ela está. O conteúdo vem do `BrandProvider` do projeto; sem ele, do padrão do
18
24
  * core.
19
25
  */
20
- export function BrandHeader({ title }: BrandHeaderProps): JSX.Element {
26
+ export function BrandHeader({
27
+ title,
28
+ showBrand = true,
29
+ }: BrandHeaderProps): JSX.Element {
21
30
  const brand = useBrand();
22
31
 
23
32
  return (
24
33
  <div className="mb-6 flex flex-col items-center gap-1">
25
- <div className="mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
26
- {brand.logo}
27
- </div>
28
- <span className="text-lg font-extrabold">{brand.name}</span>
29
- {brand.tagline && (
30
- <p className="text-sm text-muted-foreground">{brand.tagline}</p>
34
+ {showBrand && (
35
+ <>
36
+ <div className="mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
37
+ {brand.logo}
38
+ </div>
39
+ <span className="text-lg font-extrabold">{brand.name}</span>
40
+ {brand.tagline && (
41
+ <p className="text-sm text-muted-foreground">{brand.tagline}</p>
42
+ )}
43
+ </>
31
44
  )}
32
- <h1 className="mt-2 text-xl font-semibold">{title}</h1>
45
+ <h1 className={showBrand ? "mt-2 text-xl font-semibold" : "text-xl font-semibold"}>
46
+ {title}
47
+ </h1>
33
48
  </div>
34
49
  );
35
50
  }
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import type { JSX } from "react";
4
+
5
+ import { useBrand } from "#core/contexts";
6
+ import { cn } from "#core/lib/utils";
7
+
8
+ export interface BrandPanelProps {
9
+ className?: string;
10
+ }
11
+
12
+ /**
13
+ * A marca ocupando metade da tela de login, ao lado do formulário.
14
+ *
15
+ * É o irmão grande do `BrandHeader`: mesma fonte (o `BrandProvider` do
16
+ * projeto), espaço diferente. Serve a quem tem logo grande — no cabeçalho do
17
+ * cartão ela sairia espremida.
18
+ *
19
+ * O core não dimensiona a logo: ela é um `ReactNode` do projeto, que sabe se é
20
+ * um SVG, um `next/image` ou um ícone. Aqui ela só ganha o espaço.
21
+ */
22
+ export function BrandPanel({ className }: BrandPanelProps): JSX.Element {
23
+ const brand = useBrand();
24
+
25
+ return (
26
+ <div
27
+ className={cn(
28
+ "flex flex-1 flex-col items-center justify-center gap-4 bg-muted/30 p-8 text-center",
29
+ className,
30
+ )}
31
+ >
32
+ {brand.logo}
33
+ <span className="text-3xl font-extrabold">{brand.name}</span>
34
+ {brand.tagline && (
35
+ <p className="max-w-sm text-sm text-muted-foreground">
36
+ {brand.tagline}
37
+ </p>
38
+ )}
39
+ </div>
40
+ );
41
+ }
@@ -1,6 +1,7 @@
1
1
  // Barrel da pasta — importe daqui de fora; dentro da pasta use o caminho direto.
2
2
  export * from "./app-shell";
3
3
  export * from "./brand-header";
4
+ export * from "./brand-panel";
4
5
  export * from "./breadcrumbs";
5
6
  export * from "./language-selector";
6
7
  export * from "./password-field";
@@ -52,6 +52,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
52
52
  <Comp
53
53
  className={cn(buttonVariants({ variant, size, className }))}
54
54
  ref={ref}
55
+ // `<button>` sem `type` nasce `submit`: dentro de um form, um botão que
56
+ // só abre um modal acabava salvando o registro junto. Quem submete
57
+ // declara `type="submit"` — o spread abaixo deixa isso sobrescrever.
58
+ // Com `asChild` o filho pode não ser um `<button>`, então não força.
59
+ {...(asChild ? {} : { type: "button" as const })}
55
60
  {...props}
56
61
  />
57
62
  );
@@ -43,7 +43,9 @@ export function ConfirmDialog({
43
43
  }: ConfirmDialogProps): React.JSX.Element {
44
44
  return (
45
45
  <Dialog open={open} onOpenChange={(o) => !o && onCancel()}>
46
- <DialogContent className="max-w-sm">
46
+ {/* Sem ampliar: são duas linhas e dois botões, e o tamanho pequeno é
47
+ parte do recado — esticado só ganharia vazio. */}
48
+ <DialogContent className="max-w-sm" expandable={false}>
47
49
  <DialogHeader>
48
50
  <DialogTitle>{title}</DialogTitle>
49
51
  {description && <DialogDescription>{description}</DialogDescription>}
@@ -39,7 +39,6 @@ import {
39
39
  ColumnVisibilityModal,
40
40
  ColumnVisibilityOption,
41
41
  } from "#core/components/ui/column-visibility-modal";
42
- import { FilterChips } from "#core/components/ui/filter-chips";
43
42
  import { FilterSheet } from "#core/components/ui/filter-sheet";
44
43
  import { Spinner } from "#core/components/ui/spinner";
45
44
  import {
@@ -94,6 +93,16 @@ interface DataTableProps<T extends RowData> {
94
93
  * não aparece — listagem sem catálogo continua funcionando igual.
95
94
  */
96
95
  filters?: FiltersState;
96
+ /**
97
+ * Campos que a `toolbar` já controla: somem do painel de filtros e não são
98
+ * contados no botão "Filtros", para a mesma escolha não existir em dois
99
+ * lugares.
100
+ *
101
+ * Some da interface, não do catálogo — é o catálogo que serializa o filtro,
102
+ * então o atalho da barra continua funcionando normalmente. O painel também
103
+ * não os desmarca ao aplicar nem ao limpar: não são dele.
104
+ */
105
+ hiddenFilterFields?: string[];
97
106
  }
98
107
 
99
108
  /** Extras que uma coluna pode declarar em `meta`. */
@@ -144,6 +153,7 @@ export function DataTable<T extends RowData>({
144
153
  onSortingChange,
145
154
  enableSorting = true,
146
155
  filters,
156
+ hiddenFilterFields,
147
157
  }: DataTableProps<T>): JSX.Element {
148
158
  const { t } = useI18n();
149
159
  const [colsOpen, setColsOpen] = useState(false);
@@ -220,7 +230,7 @@ export function DataTable<T extends RowData>({
220
230
  ? filters.tree && filters.filter
221
231
  ? countConditions(filters.tree)
222
232
  : 0
223
- : activeFilterCount(filters.values);
233
+ : activeFilterCount(filters.values, hiddenFilterFields);
224
234
 
225
235
  return (
226
236
  <div>
@@ -257,8 +267,6 @@ export function DataTable<T extends RowData>({
257
267
  </div>
258
268
  </div>
259
269
 
260
- {filters && <FilterChips filters={filters} />}
261
-
262
270
  <Card>
263
271
  <Table>
264
272
  <TableHeader>
@@ -394,6 +402,7 @@ export function DataTable<T extends RowData>({
394
402
  open={filtersOpen}
395
403
  onOpenChange={setFiltersOpen}
396
404
  filters={filters}
405
+ hiddenFields={hiddenFilterFields}
397
406
  />
398
407
  )}
399
408
  </div>
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import * as DialogPrimitive from "@radix-ui/react-dialog";
4
- import { X } from "lucide-react";
4
+ import { Maximize2, Minimize2, X } from "lucide-react";
5
5
  import * as React from "react";
6
6
 
7
7
  import { cn } from "#core/lib/utils";
@@ -26,28 +26,127 @@ const DialogOverlay = React.forwardRef<
26
26
  ));
27
27
  DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
28
28
 
29
+ /**
30
+ * Escala de largura do Tailwind, em ordem. Ampliar anda dois degraus: `md` vira
31
+ * `xl`, `2xl` vira `4xl`. O modal cresce mantendo a proporção que já tinha, em
32
+ * vez de saltar para a tela inteira — um formulário de duas colunas esticado a
33
+ * 100% fica pior, não melhor. Um degrau só passava despercebido.
34
+ */
35
+ const WIDTH_SCALE: readonly string[] = [
36
+ "max-w-3xs",
37
+ "max-w-2xs",
38
+ "max-w-xs",
39
+ "max-w-sm",
40
+ "max-w-md",
41
+ "max-w-lg",
42
+ "max-w-xl",
43
+ "max-w-2xl",
44
+ "max-w-3xl",
45
+ "max-w-4xl",
46
+ "max-w-5xl",
47
+ "max-w-6xl",
48
+ "max-w-7xl",
49
+ ];
50
+
51
+ /** Largura de quem não declara nenhuma. */
52
+ const DEFAULT_WIDTH = "max-w-lg";
53
+
54
+ /** Quantos degraus o botão sobe de uma vez. */
55
+ const EXPAND_STEPS = 2;
56
+
57
+ /** Só para quem já está no topo da escala: daí não há degrau, resta a tela. */
58
+ const FULL_WIDTH = "max-w-none";
59
+
60
+ /**
61
+ * A largura do modal ampliado.
62
+ *
63
+ * Lê a **última** `max-w-*` do `className` porque é ela que o `tailwind-merge`
64
+ * deixa valer. Largura fora da escala (`max-w-[42rem]`) não tem degrau a subir:
65
+ * amplia para a tela toda.
66
+ */
67
+ const expandedWidth = (className?: string): string => {
68
+ const declared = (className ?? "")
69
+ .split(/\s+/)
70
+ .filter((item) => item.startsWith("max-w-"));
71
+ const current = declared[declared.length - 1] ?? DEFAULT_WIDTH;
72
+ const index = WIDTH_SCALE.indexOf(current);
73
+ const last = WIDTH_SCALE.length - 1;
74
+
75
+ // Fora da escala não há degrau a subir, e chutar um da escala encolheria um
76
+ // `max-w-[90rem]`. Amplia para a tela, que é o que o botão promete.
77
+ if (index === -1 || index === last) {
78
+ return FULL_WIDTH;
79
+ }
80
+
81
+ // Perto do topo, dois degraus passariam do fim: para no último em vez de
82
+ // cair em tela cheia, que é justamente o salto que a escala evita.
83
+ return WIDTH_SCALE[Math.min(index + EXPAND_STEPS, last)];
84
+ };
85
+
86
+ export interface DialogContentProps
87
+ extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
88
+ /**
89
+ * Botão de ampliar ao lado do fechar.
90
+ *
91
+ * Ligado por padrão: ampliar um degrau não atrapalha modal nenhum. Desligue
92
+ * com `expandable={false}` onde o tamanho é parte do desenho — confirmação
93
+ * curta, por exemplo, que esticada só fica com mais vazio.
94
+ */
95
+ expandable?: boolean;
96
+ }
97
+
29
98
  const DialogContent = React.forwardRef<
30
99
  React.ElementRef<typeof DialogPrimitive.Content>,
31
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
32
- >(({ className, children, ...props }, ref) => (
33
- <DialogPortal>
34
- <DialogOverlay />
35
- <DialogPrimitive.Content
36
- ref={ref}
37
- className={cn(
38
- "fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg rounded-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 max-h-[calc(100%-2rem)] overflow-y-auto",
39
- className,
40
- )}
41
- {...props}
42
- >
43
- {children}
44
- <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
45
- <X className="h-4 w-4" />
46
- <span className="sr-only">Close</span>
47
- </DialogPrimitive.Close>
48
- </DialogPrimitive.Content>
49
- </DialogPortal>
50
- ));
100
+ DialogContentProps
101
+ >(({ className, children, expandable = true, ...props }, ref) => {
102
+ // Reinicia a cada montagem: o Radix desmonta o conteúdo ao fechar, então
103
+ // reabrir traz o modal no tamanho normal — modal pequeno que volta ampliado
104
+ // surpreende quem o abriu.
105
+ const [expanded, setExpanded] = React.useState(false);
106
+
107
+ return (
108
+ <DialogPortal>
109
+ <DialogOverlay />
110
+ <DialogPrimitive.Content
111
+ ref={ref}
112
+ className={cn(
113
+ "fixed left-[50%] top-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 overflow-y-auto",
114
+ "w-[calc(100%-2rem)] max-h-[calc(100%-2rem)] rounded-xl",
115
+ DEFAULT_WIDTH,
116
+ className,
117
+ // Depois do `className`: é o que faz a largura ampliada vencer a que
118
+ // o chamador declarou, e só enquanto estiver ampliado.
119
+ expanded && expandedWidth(className),
120
+ )}
121
+ {...props}
122
+ >
123
+ {children}
124
+ <div className="absolute right-4 top-4 flex items-center gap-2">
125
+ {expandable && (
126
+ <button
127
+ type="button"
128
+ onClick={() => setExpanded((value) => !value)}
129
+ className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring"
130
+ >
131
+ {expanded ? (
132
+ <Minimize2 className="h-4 w-4" />
133
+ ) : (
134
+ <Maximize2 className="h-4 w-4" />
135
+ )}
136
+ <span className="sr-only">
137
+ {expanded ? "Restaurar" : "Ampliar"}
138
+ </span>
139
+ </button>
140
+ )}
141
+ <DialogPrimitive.Close className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
142
+ <X className="h-4 w-4" />
143
+ <span className="sr-only">Fechar</span>
144
+ </DialogPrimitive.Close>
145
+ </div>
146
+ </DialogPrimitive.Content>
147
+ </DialogPortal>
148
+ );
149
+ });
51
150
  DialogContent.displayName = DialogPrimitive.Content.displayName;
52
151
 
53
152
  /**
@@ -10,6 +10,7 @@ import { activeFilterCount } from "#core/_utils/filter";
10
10
  import { Button } from "#core/components/ui/button";
11
11
  import { FilterFieldControl } from "#core/components/ui/filter-field";
12
12
  import { FilterGroupBlock } from "#core/components/ui/filter-group";
13
+ import { SegmentedControl } from "#core/components/ui/segmented-control";
13
14
  import { Separator } from "#core/components/ui/separator";
14
15
  import {
15
16
  Sheet,
@@ -29,6 +30,17 @@ export interface FilterSheetProps {
29
30
  open: boolean;
30
31
  onOpenChange: (open: boolean) => void;
31
32
  filters: FiltersState;
33
+ /**
34
+ * Campos que a barra da listagem já controla — somem do modo simples para a
35
+ * mesma escolha não existir em dois lugares.
36
+ *
37
+ * Continuam no catálogo, e isso não é detalhe: é o catálogo que serializa o
38
+ * filtro (`buildFilter` percorre o schema). Tirá-los de lá faria o atalho da
39
+ * barra parar de filtrar, sem erro nenhum. O modo avançado também segue com
40
+ * todos: é a saída de emergência, e esconder campo ali tornaria ilegível um
41
+ * filtro salvo que o use.
42
+ */
43
+ hiddenFields?: string[];
32
44
  }
33
45
 
34
46
  /**
@@ -55,6 +67,7 @@ export function FilterSheet({
55
67
  open,
56
68
  onOpenChange,
57
69
  filters,
70
+ hiddenFields = [],
58
71
  }: FilterSheetProps): JSX.Element {
59
72
  const { t } = useI18n();
60
73
  const labelFor = useFieldLabel();
@@ -83,6 +96,21 @@ export function FilterSheet({
83
96
  }
84
97
  };
85
98
 
99
+ /**
100
+ * O que o painel não mostra, o painel não mexe.
101
+ *
102
+ * O desfecho de `/logs` é escolhido na barra e não aparece aqui dentro —
103
+ * então nem "Aplicar" nem "Limpar" podem desmarcá-lo. Filtrar por rota
104
+ * enquanto se olha o sucesso é somar uma condição à outra, não trocar de
105
+ * assunto.
106
+ */
107
+ const keepHidden = (): FilterValues =>
108
+ Object.fromEntries(
109
+ hiddenFields
110
+ .filter((field) => values[field])
111
+ .map((field) => [field, values[field]]),
112
+ );
113
+
86
114
  const apply = (): void => {
87
115
  filters.setMode(draftMode);
88
116
  if (draftMode === "advanced") {
@@ -90,18 +118,18 @@ export function FilterSheet({
90
118
  filters.setTree(draftTree);
91
119
  }
92
120
  } else {
93
- filters.setValues(draft);
121
+ filters.setValues({ ...draft, ...keepHidden() });
94
122
  }
95
123
  onOpenChange(false);
96
124
  };
97
125
 
98
126
  const clear = (): void => {
99
- setDraft({});
127
+ setDraft(keepHidden());
100
128
  setDraftTree(initialTree(schema));
101
129
  if (draftMode === "advanced") {
102
130
  filters.setTree(initialTree(schema));
103
131
  } else {
104
- filters.setValues({});
132
+ filters.setValues(keepHidden());
105
133
  }
106
134
  onOpenChange(false);
107
135
  };
@@ -111,13 +139,17 @@ export function FilterSheet({
111
139
  ? draftTree
112
140
  ? countConditions(draftTree)
113
141
  : 0
114
- : activeFilterCount(draft);
142
+ : activeFilterCount(draft, hiddenFields);
115
143
 
116
- const modes: { id: FilterMode; label: string }[] = [
117
- { id: "simple", label: t("filters.simple") },
118
- { id: "advanced", label: t("filters.advanced") },
144
+ const modes = [
145
+ { value: "simple", label: t("filters.simple") },
146
+ { value: "advanced", label: t("filters.advanced") },
119
147
  ];
120
148
 
149
+ const visibleFields = schema.filter(
150
+ (field) => !hiddenFields.includes(field.field),
151
+ );
152
+
121
153
  return (
122
154
  <Sheet open={open} onOpenChange={onOpenChange}>
123
155
  <SheetContent
@@ -131,23 +163,13 @@ export function FilterSheet({
131
163
  </SheetDescription>
132
164
  </SheetHeader>
133
165
 
134
- <div className="inline-flex overflow-hidden rounded-md border border-border">
135
- {modes.map((item) => (
136
- <button
137
- key={item.id}
138
- type="button"
139
- onClick={() => switchMode(item.id)}
140
- className={cn(
141
- "px-3 py-1.5 text-sm transition-colors",
142
- draftMode === item.id
143
- ? "bg-primary text-primary-foreground"
144
- : "hover:bg-accent",
145
- )}
146
- >
147
- {item.label}
148
- </button>
149
- ))}
150
- </div>
166
+ <SegmentedControl
167
+ fullWidth
168
+ label={t("filters.title")}
169
+ options={modes}
170
+ value={draftMode}
171
+ onValueChange={(value) => switchMode(value as FilterMode)}
172
+ />
151
173
 
152
174
  <SheetBody className="-mx-1 space-y-5 px-1">
153
175
  {loading && <Spinner className="mx-auto mt-8" />}
@@ -160,7 +182,7 @@ export function FilterSheet({
160
182
 
161
183
  {!loading && schema.length > 0 && draftMode === "simple" && (
162
184
  <>
163
- {schema.map((field) => (
185
+ {visibleFields.map((field) => (
164
186
  <FilterFieldControl
165
187
  key={field.field}
166
188
  field={field}
@@ -12,7 +12,6 @@ export * from "./date-range-picker";
12
12
  export * from "./dialog";
13
13
  export * from "./dropdown-menu";
14
14
  export * from "./field";
15
- export * from "./filter-chips";
16
15
  export * from "./filter-field";
17
16
  export * from "./filter-group";
18
17
  export * from "./filter-rule";
@@ -20,6 +19,7 @@ export * from "./filter-sheet";
20
19
  export * from "./input";
21
20
  export * from "./label";
22
21
  export * from "./row-actions";
22
+ export * from "./segmented-control";
23
23
  export * from "./select";
24
24
  export * from "./separator";
25
25
  export * from "./sheet";
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { cn } from "#core/lib/utils";
6
+
7
+ export interface SegmentedOption {
8
+ value: string;
9
+ label: string;
10
+ }
11
+
12
+ export interface SegmentedControlProps
13
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
14
+ options: SegmentedOption[];
15
+ value: string;
16
+ onValueChange: (value: string) => void;
17
+ /** Rótulo acessível do grupo — o que essas opções escolhem. */
18
+ label: string;
19
+ /**
20
+ * Ocupa a largura toda, com as opções dividindo o espaço em partes iguais.
21
+ *
22
+ * Para quando o controle é o cabeçalho de um bloco — duas opções encostadas à
23
+ * esquerda de um painel largo ficam tortas. Solto, ele mede pelo conteúdo.
24
+ */
25
+ fullWidth?: boolean;
26
+ }
27
+
28
+ /**
29
+ * Escolha única no formato das abas: uma opção acesa dentro de uma trilha.
30
+ *
31
+ * Tem a pele do `Tabs`, mas não é aba: aba troca de painel, isto escolhe um
32
+ * valor. Por isso são botões de alternância num `role="group"`, e não o
33
+ * `Tabs` do Radix — que emitiria `role="tablist"` com `aria-controls`
34
+ * apontando para um painel inexistente.
35
+ *
36
+ * Vale quando as opções cabem na linha e são poucas (até ~5). Passando disso,
37
+ * `Select` — trilha que quebra em duas linhas fica pior que uma lista.
38
+ */
39
+ export const SegmentedControl = ({
40
+ options,
41
+ value,
42
+ onValueChange,
43
+ label,
44
+ fullWidth = false,
45
+ className,
46
+ ...props
47
+ }: SegmentedControlProps): React.JSX.Element => (
48
+ <div
49
+ role="group"
50
+ aria-label={label}
51
+ className={cn(
52
+ "h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
53
+ fullWidth ? "flex w-full" : "inline-flex",
54
+ className,
55
+ )}
56
+ {...props}
57
+ >
58
+ {options.map((option) => (
59
+ <button
60
+ key={option.value}
61
+ type="button"
62
+ aria-pressed={option.value === value}
63
+ onClick={() => onValueChange(option.value)}
64
+ className={cn(
65
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
66
+ fullWidth && "flex-1",
67
+ option.value === value && "bg-card text-foreground shadow-sm",
68
+ )}
69
+ >
70
+ {option.label}
71
+ </button>
72
+ ))}
73
+ </div>
74
+ );