rl-core-front 0.1.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 (112) hide show
  1. package/README.md +129 -0
  2. package/package.json +113 -0
  3. package/src/_services/api/axios.factory.ts +97 -0
  4. package/src/_services/api/list-query.ts +26 -0
  5. package/src/_services/api/schema.d.ts +2527 -0
  6. package/src/_services/auth/index.ts +127 -0
  7. package/src/_utils/advanced-filter.ts +386 -0
  8. package/src/_utils/calendar.ts +103 -0
  9. package/src/_utils/crop-image.ts +65 -0
  10. package/src/_utils/filter.ts +313 -0
  11. package/src/_utils/format.ts +35 -0
  12. package/src/_utils/initials.ts +24 -0
  13. package/src/_utils/password.ts +7 -0
  14. package/src/components/app-shell.tsx +314 -0
  15. package/src/components/breadcrumbs.tsx +55 -0
  16. package/src/components/index.ts +7 -0
  17. package/src/components/language-selector.tsx +51 -0
  18. package/src/components/password-field.tsx +40 -0
  19. package/src/components/theme-toggle.tsx +27 -0
  20. package/src/components/ui/alert.tsx +27 -0
  21. package/src/components/ui/avatar.tsx +50 -0
  22. package/src/components/ui/badge.tsx +36 -0
  23. package/src/components/ui/button.tsx +62 -0
  24. package/src/components/ui/card.tsx +86 -0
  25. package/src/components/ui/checkbox.tsx +30 -0
  26. package/src/components/ui/column-visibility-modal.tsx +110 -0
  27. package/src/components/ui/confirm-dialog.tsx +69 -0
  28. package/src/components/ui/data-table.tsx +401 -0
  29. package/src/components/ui/date-range-picker.tsx +250 -0
  30. package/src/components/ui/dialog.tsx +138 -0
  31. package/src/components/ui/dropdown-menu.tsx +108 -0
  32. package/src/components/ui/field.tsx +20 -0
  33. package/src/components/ui/filter-chips.tsx +88 -0
  34. package/src/components/ui/filter-field.tsx +137 -0
  35. package/src/components/ui/filter-group.tsx +155 -0
  36. package/src/components/ui/filter-rule.tsx +221 -0
  37. package/src/components/ui/filter-sheet.tsx +201 -0
  38. package/src/components/ui/index.ts +30 -0
  39. package/src/components/ui/input.tsx +25 -0
  40. package/src/components/ui/label.tsx +23 -0
  41. package/src/components/ui/row-actions.tsx +32 -0
  42. package/src/components/ui/select.tsx +97 -0
  43. package/src/components/ui/separator.tsx +31 -0
  44. package/src/components/ui/sheet.tsx +119 -0
  45. package/src/components/ui/sonner.tsx +28 -0
  46. package/src/components/ui/spinner.tsx +12 -0
  47. package/src/components/ui/switch.tsx +29 -0
  48. package/src/components/ui/table.tsx +84 -0
  49. package/src/components/ui/tabs.tsx +52 -0
  50. package/src/components/user-avatar.tsx +54 -0
  51. package/src/contexts/auth-context.tsx +90 -0
  52. package/src/contexts/color-mode-context.tsx +105 -0
  53. package/src/contexts/i18n-context.tsx +76 -0
  54. package/src/contexts/index.ts +6 -0
  55. package/src/contexts/socket-context.tsx +153 -0
  56. package/src/contexts/toast-context.tsx +40 -0
  57. package/src/features/audit/audit-screen.tsx +299 -0
  58. package/src/features/audit/hooks/use-audit-trail.ts +68 -0
  59. package/src/features/audit/services/audit.service.ts +24 -0
  60. package/src/features/dashboard/dashboard-screen.tsx +73 -0
  61. package/src/features/login/components/backup-codes-dialog.tsx +66 -0
  62. package/src/features/login/components/index.ts +5 -0
  63. package/src/features/login/components/login-form.tsx +62 -0
  64. package/src/features/login/components/two-factor-setup.tsx +71 -0
  65. package/src/features/login/components/two-factor-verify.tsx +56 -0
  66. package/src/features/login/hooks/use-login-flow.ts +119 -0
  67. package/src/features/login/login-screen.tsx +83 -0
  68. package/src/features/login/validation/schemas.ts +30 -0
  69. package/src/features/logs/hooks/use-error-logs.ts +65 -0
  70. package/src/features/logs/hooks/use-request-logs.ts +65 -0
  71. package/src/features/logs/logs-screen.tsx +31 -0
  72. package/src/features/logs/panels/error-logs-panel.tsx +183 -0
  73. package/src/features/logs/panels/index.ts +3 -0
  74. package/src/features/logs/panels/request-logs-panel.tsx +124 -0
  75. package/src/features/logs/services/logs.service.ts +68 -0
  76. package/src/features/notifications/components/notification-item.tsx +67 -0
  77. package/src/features/notifications/hooks/use-notifications.ts +78 -0
  78. package/src/features/notifications/notifications-center.tsx +119 -0
  79. package/src/features/notifications/services/notifications.service.ts +38 -0
  80. package/src/features/profile/components/avatar-crop-dialog.tsx +133 -0
  81. package/src/features/profile/components/index.ts +2 -0
  82. package/src/features/profile/force-password-change.tsx +114 -0
  83. package/src/features/profile/profile-screen.tsx +385 -0
  84. package/src/features/rbac/panels/actions-panel.tsx +20 -0
  85. package/src/features/rbac/panels/catalog-panel.tsx +243 -0
  86. package/src/features/rbac/panels/groups-panel.tsx +269 -0
  87. package/src/features/rbac/panels/index.ts +8 -0
  88. package/src/features/rbac/panels/permissions-panel.tsx +204 -0
  89. package/src/features/rbac/panels/resources-panel.tsx +20 -0
  90. package/src/features/rbac/panels/roles-panel.tsx +224 -0
  91. package/src/features/rbac/panels/scopes-panel.tsx +20 -0
  92. package/src/features/rbac/rbac-screen.tsx +80 -0
  93. package/src/features/rbac/services/rbac.service.ts +110 -0
  94. package/src/features/recovery/forgot-password-screen.tsx +69 -0
  95. package/src/features/recovery/reset-password-screen.tsx +163 -0
  96. package/src/features/users/components/user-form-dialog.tsx +187 -0
  97. package/src/features/users/hooks/use-users.ts +181 -0
  98. package/src/features/users/services/users.service.ts +83 -0
  99. package/src/features/users/users-screen.tsx +331 -0
  100. package/src/hooks/use-column-visibility.ts +54 -0
  101. package/src/hooks/use-confirm.tsx +70 -0
  102. package/src/hooks/use-filter-schema.ts +85 -0
  103. package/src/hooks/use-filters.ts +148 -0
  104. package/src/hooks/use-list-query.ts +69 -0
  105. package/src/hooks/use-request.ts +114 -0
  106. package/src/i18n/messages/en.ts +296 -0
  107. package/src/i18n/messages/pt.ts +305 -0
  108. package/src/index.ts +60 -0
  109. package/src/lib/utils.ts +6 -0
  110. package/src/middleware.ts +42 -0
  111. package/src/styles/core.css +142 -0
  112. package/tailwind-preset.ts +103 -0
@@ -0,0 +1,138 @@
1
+ "use client";
2
+
3
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
4
+ import { X } from "lucide-react";
5
+ import * as React from "react";
6
+
7
+ import { cn } from "#core/lib/utils";
8
+
9
+ const Dialog = DialogPrimitive.Root;
10
+ const DialogTrigger = DialogPrimitive.Trigger;
11
+ const DialogPortal = DialogPrimitive.Portal;
12
+ const DialogClose = DialogPrimitive.Close;
13
+
14
+ const DialogOverlay = React.forwardRef<
15
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
16
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
17
+ >(({ className, ...props }, ref) => (
18
+ <DialogPrimitive.Overlay
19
+ ref={ref}
20
+ className={cn(
21
+ "fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
22
+ className,
23
+ )}
24
+ {...props}
25
+ />
26
+ ));
27
+ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
28
+
29
+ const DialogContent = React.forwardRef<
30
+ 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
+ ));
51
+ DialogContent.displayName = DialogPrimitive.Content.displayName;
52
+
53
+ /**
54
+ * Corpo do modal como formulário — é o que faz o `Enter` submeter.
55
+ * O `preventDefault` fica aqui porque, sem ele, o Enter recarrega a página.
56
+ * O `DialogFooter` com o botão `type="submit"` precisa ficar dentro deste form.
57
+ */
58
+ const DialogForm = React.forwardRef<
59
+ HTMLFormElement,
60
+ React.ComponentPropsWithoutRef<"form">
61
+ >(({ className, onSubmit, ...props }, ref) => (
62
+ <form
63
+ ref={ref}
64
+ onSubmit={(e) => {
65
+ e.preventDefault();
66
+ onSubmit?.(e);
67
+ }}
68
+ className={cn("flex flex-col gap-4", className)}
69
+ {...props}
70
+ />
71
+ ));
72
+ DialogForm.displayName = "DialogForm";
73
+
74
+ const DialogHeader = ({
75
+ className,
76
+ ...props
77
+ }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
78
+ <div
79
+ className={cn("flex flex-col space-y-1.5 text-left", className)}
80
+ {...props}
81
+ />
82
+ );
83
+ DialogHeader.displayName = "DialogHeader";
84
+
85
+ const DialogFooter = ({
86
+ className,
87
+ ...props
88
+ }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
89
+ <div
90
+ className={cn(
91
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
92
+ className,
93
+ )}
94
+ {...props}
95
+ />
96
+ );
97
+ DialogFooter.displayName = "DialogFooter";
98
+
99
+ const DialogTitle = React.forwardRef<
100
+ React.ElementRef<typeof DialogPrimitive.Title>,
101
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
102
+ >(({ className, ...props }, ref) => (
103
+ <DialogPrimitive.Title
104
+ ref={ref}
105
+ className={cn(
106
+ "text-lg font-semibold leading-none tracking-tight",
107
+ className,
108
+ )}
109
+ {...props}
110
+ />
111
+ ));
112
+ DialogTitle.displayName = DialogPrimitive.Title.displayName;
113
+
114
+ const DialogDescription = React.forwardRef<
115
+ React.ElementRef<typeof DialogPrimitive.Description>,
116
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
117
+ >(({ className, ...props }, ref) => (
118
+ <DialogPrimitive.Description
119
+ ref={ref}
120
+ className={cn("text-sm text-muted-foreground", className)}
121
+ {...props}
122
+ />
123
+ ));
124
+ DialogDescription.displayName = DialogPrimitive.Description.displayName;
125
+
126
+ export {
127
+ Dialog,
128
+ DialogClose,
129
+ DialogContent,
130
+ DialogDescription,
131
+ DialogFooter,
132
+ DialogForm,
133
+ DialogHeader,
134
+ DialogOverlay,
135
+ DialogPortal,
136
+ DialogTitle,
137
+ DialogTrigger,
138
+ };
@@ -0,0 +1,108 @@
1
+ "use client";
2
+
3
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
4
+ import { Check } from "lucide-react";
5
+ import * as React from "react";
6
+
7
+ import { cn } from "#core/lib/utils";
8
+
9
+ const DropdownMenu = DropdownMenuPrimitive.Root;
10
+ const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
11
+ const DropdownMenuGroup = DropdownMenuPrimitive.Group;
12
+ const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
13
+
14
+ const DropdownMenuContent = React.forwardRef<
15
+ React.ElementRef<typeof DropdownMenuPrimitive.Content>,
16
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
17
+ >(({ className, sideOffset = 4, ...props }, ref) => (
18
+ <DropdownMenuPrimitive.Portal>
19
+ <DropdownMenuPrimitive.Content
20
+ ref={ref}
21
+ sideOffset={sideOffset}
22
+ className={cn(
23
+ "z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md 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",
24
+ className,
25
+ )}
26
+ {...props}
27
+ />
28
+ </DropdownMenuPrimitive.Portal>
29
+ ));
30
+ DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
31
+
32
+ const DropdownMenuItem = React.forwardRef<
33
+ React.ElementRef<typeof DropdownMenuPrimitive.Item>,
34
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
35
+ inset?: boolean;
36
+ }
37
+ >(({ className, inset, ...props }, ref) => (
38
+ <DropdownMenuPrimitive.Item
39
+ ref={ref}
40
+ className={cn(
41
+ "relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4",
42
+ inset && "pl-8",
43
+ className,
44
+ )}
45
+ {...props}
46
+ />
47
+ ));
48
+ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
49
+
50
+ const DropdownMenuLabel = React.forwardRef<
51
+ React.ElementRef<typeof DropdownMenuPrimitive.Label>,
52
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
53
+ >(({ className, ...props }, ref) => (
54
+ <DropdownMenuPrimitive.Label
55
+ ref={ref}
56
+ className={cn("px-2 py-1.5 text-sm font-semibold", className)}
57
+ {...props}
58
+ />
59
+ ));
60
+ DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
61
+
62
+ const DropdownMenuSeparator = React.forwardRef<
63
+ React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
64
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
65
+ >(({ className, ...props }, ref) => (
66
+ <DropdownMenuPrimitive.Separator
67
+ ref={ref}
68
+ className={cn("-mx-1 my-1 h-px bg-border", className)}
69
+ {...props}
70
+ />
71
+ ));
72
+ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
73
+
74
+ const DropdownMenuCheckboxItem = React.forwardRef<
75
+ React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
76
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
77
+ >(({ className, children, checked, ...props }, ref) => (
78
+ <DropdownMenuPrimitive.CheckboxItem
79
+ ref={ref}
80
+ className={cn(
81
+ "relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground",
82
+ className,
83
+ )}
84
+ checked={checked}
85
+ {...props}
86
+ >
87
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
88
+ <DropdownMenuPrimitive.ItemIndicator>
89
+ <Check className="h-4 w-4" />
90
+ </DropdownMenuPrimitive.ItemIndicator>
91
+ </span>
92
+ {children}
93
+ </DropdownMenuPrimitive.CheckboxItem>
94
+ ));
95
+ DropdownMenuCheckboxItem.displayName =
96
+ DropdownMenuPrimitive.CheckboxItem.displayName;
97
+
98
+ export {
99
+ DropdownMenu,
100
+ DropdownMenuCheckboxItem,
101
+ DropdownMenuContent,
102
+ DropdownMenuGroup,
103
+ DropdownMenuItem,
104
+ DropdownMenuLabel,
105
+ DropdownMenuPortal,
106
+ DropdownMenuSeparator,
107
+ DropdownMenuTrigger,
108
+ };
@@ -0,0 +1,20 @@
1
+ import type { JSX } from "react";
2
+
3
+ import { Label } from "#core/components/ui/label";
4
+
5
+ interface FieldProps {
6
+ label?: string;
7
+ error?: string;
8
+ htmlFor?: string;
9
+ children: React.ReactNode;
10
+ }
11
+
12
+ export function Field({ label, error, htmlFor, children }: FieldProps): JSX.Element {
13
+ return (
14
+ <div className="space-y-1.5">
15
+ {label && <Label htmlFor={htmlFor}>{label}</Label>}
16
+ {children}
17
+ {error && <p className="text-xs font-medium text-destructive">{error}</p>}
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1,88 @@
1
+ "use client";
2
+
3
+ import { SlidersHorizontal, X } from "lucide-react";
4
+ import type { JSX } from "react";
5
+
6
+ import { countConditions } from "#core/_utils/advanced-filter";
7
+ import { describeValue, hasValue } from "#core/_utils/filter";
8
+ import { Button } from "#core/components/ui/button";
9
+ import { useFieldLabel } from "#core/components/ui/filter-sheet";
10
+ import { useI18n } from "#core/contexts";
11
+ import type { FiltersState } from "#core/hooks/use-filters";
12
+
13
+ export interface FilterChipsProps {
14
+ filters: FiltersState;
15
+ }
16
+
17
+ /**
18
+ * Filtros ativos em forma de chip, cada um removível.
19
+ *
20
+ * Existe para o usuário não precisar abrir o painel só para lembrar o que está
21
+ * filtrando — e para não olhar uma tabela vazia sem saber por quê. No modo
22
+ * avançado a expressão não cabe num chip por campo, então vira um resumo com a
23
+ * contagem de condições.
24
+ */
25
+ export function FilterChips({ filters }: FilterChipsProps): JSX.Element | null {
26
+ const { t, locale } = useI18n();
27
+ const labelFor = useFieldLabel();
28
+ const { schema, values, mode, tree } = filters;
29
+
30
+ if (mode === "advanced") {
31
+ const total = tree ? countConditions(tree) : 0;
32
+ if (total === 0 || !filters.filter) {
33
+ return null;
34
+ }
35
+ return (
36
+ <div className="mb-3 flex flex-wrap items-center gap-2">
37
+ <span className="flex items-center gap-1.5 rounded-full border border-border bg-muted/40 px-3 py-1 text-xs">
38
+ <SlidersHorizontal className="h-3 w-3" />
39
+ {t("filters.advancedSummary", { count: total })}
40
+ </span>
41
+ <Button variant="ghost" size="sm" onClick={() => filters.setMode("simple")}>
42
+ {t("filters.clearAll")}
43
+ </Button>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ const active = schema.filter((field) => hasValue(values[field.field]));
49
+ if (active.length === 0) {
50
+ return null;
51
+ }
52
+
53
+ const remove = (field: string): void => {
54
+ const next = { ...values };
55
+ delete next[field];
56
+ filters.setValues(next);
57
+ };
58
+
59
+ const labels = {
60
+ from: t("filters.fromChip"),
61
+ to: t("filters.toChip"),
62
+ yes: t("common.yes"),
63
+ no: t("common.no"),
64
+ locale,
65
+ };
66
+
67
+ return (
68
+ <div className="mb-3 flex flex-wrap items-center gap-2">
69
+ {active.map((field) => (
70
+ <button
71
+ key={field.field}
72
+ type="button"
73
+ onClick={() => remove(field.field)}
74
+ className="flex items-center gap-1.5 rounded-full border border-border bg-muted/40 px-3 py-1 text-xs transition-colors hover:bg-muted"
75
+ >
76
+ <span className="text-muted-foreground">{labelFor(field)}:</span>
77
+ <span className="font-medium">
78
+ {describeValue(field, values[field.field], labels)}
79
+ </span>
80
+ <X className="h-3 w-3" />
81
+ </button>
82
+ ))}
83
+ <Button variant="ghost" size="sm" onClick={() => filters.setValues({})}>
84
+ {t("filters.clearAll")}
85
+ </Button>
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ import type { JSX } from "react";
4
+
5
+ import type { FilterDraftValue, FilterField } from "#core/_utils/filter";
6
+ import { Checkbox } from "#core/components/ui/checkbox";
7
+ import { DateRangePicker } from "#core/components/ui/date-range-picker";
8
+ import { Input } from "#core/components/ui/input";
9
+ import { Label } from "#core/components/ui/label";
10
+ import { useI18n } from "#core/contexts";
11
+
12
+ export interface FilterFieldControlProps {
13
+ field: FilterField;
14
+ /** Rótulo já traduzido pelo painel. */
15
+ label: string;
16
+ value: FilterDraftValue;
17
+ onChange: (value: FilterDraftValue) => void;
18
+ }
19
+
20
+ /**
21
+ * Controle de um campo, escolhido pelo `type` que o backend declarou.
22
+ *
23
+ * O componente não conhece nenhuma feature: o que ele desenha vem do catálogo,
24
+ * então uma listagem nova não mexe aqui.
25
+ */
26
+ export function FilterFieldControl({
27
+ field,
28
+ label,
29
+ value,
30
+ onChange,
31
+ }: FilterFieldControlProps): JSX.Element {
32
+ const { t } = useI18n();
33
+
34
+ // Período de datas: um calendário só, com atalhos. Dois `<input type="date">`
35
+ // obrigavam a digitar duas datas e conferir na mão qual era a maior.
36
+ if (field.type === "DATE" || field.type === "DATETIME") {
37
+ return (
38
+ <div className="space-y-2">
39
+ <span className="text-sm font-medium">{label}</span>
40
+ <DateRangePicker
41
+ label={label}
42
+ value={{ from: value.from, to: value.to }}
43
+ onChange={(range) => onChange(range)}
44
+ />
45
+ </div>
46
+ );
47
+ }
48
+
49
+ if (field.type === "NUMBER") {
50
+ return (
51
+ <fieldset className="space-y-2">
52
+ <legend className="text-sm font-medium">{label}</legend>
53
+ <div className="flex items-center gap-2">
54
+ <Input
55
+ type="number"
56
+ aria-label={`${label} — ${t("filters.from")}`}
57
+ placeholder={t("filters.from")}
58
+ value={value.from ?? ""}
59
+ onChange={(event) => onChange({ ...value, from: event.target.value })}
60
+ />
61
+ <span className="text-sm text-muted-foreground">–</span>
62
+ <Input
63
+ type="number"
64
+ aria-label={`${label} — ${t("filters.to")}`}
65
+ placeholder={t("filters.to")}
66
+ value={value.to ?? ""}
67
+ onChange={(event) => onChange({ ...value, to: event.target.value })}
68
+ />
69
+ </div>
70
+ </fieldset>
71
+ );
72
+ }
73
+
74
+ if (field.type === "BOOLEAN") {
75
+ return (
76
+ <div className="space-y-2">
77
+ <Label htmlFor={`filter-${field.field}`}>{label}</Label>
78
+ <select
79
+ id={`filter-${field.field}`}
80
+ className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
81
+ value={value.value ?? ""}
82
+ onChange={(event) => onChange({ value: event.target.value })}
83
+ >
84
+ <option value="">{t("filters.all")}</option>
85
+ <option value="true">{t("common.yes")}</option>
86
+ <option value="false">{t("common.no")}</option>
87
+ </select>
88
+ </div>
89
+ );
90
+ }
91
+
92
+ if (field.type === "ENUM" || field.type === "SELECT") {
93
+ const selected = value.values ?? [];
94
+ const toggle = (option: string): void => {
95
+ onChange({
96
+ values: selected.includes(option)
97
+ ? selected.filter((item) => item !== option)
98
+ : [...selected, option],
99
+ });
100
+ };
101
+
102
+ return (
103
+ <fieldset className="space-y-2">
104
+ <legend className="text-sm font-medium">{label}</legend>
105
+ {field.options?.length ? (
106
+ <div className="max-h-44 space-y-2 overflow-y-auto rounded-md border border-border p-3">
107
+ {field.options.map((option) => (
108
+ <label
109
+ key={option.value}
110
+ className="flex cursor-pointer items-center gap-2 text-sm"
111
+ >
112
+ <Checkbox
113
+ checked={selected.includes(option.value)}
114
+ onCheckedChange={() => toggle(option.value)}
115
+ />
116
+ {option.label}
117
+ </label>
118
+ ))}
119
+ </div>
120
+ ) : (
121
+ <p className="text-sm text-muted-foreground">{t("filters.noOptions")}</p>
122
+ )}
123
+ </fieldset>
124
+ );
125
+ }
126
+
127
+ return (
128
+ <div className="space-y-2">
129
+ <Label htmlFor={`filter-${field.field}`}>{label}</Label>
130
+ <Input
131
+ id={`filter-${field.field}`}
132
+ value={value.value ?? ""}
133
+ onChange={(event) => onChange({ value: event.target.value })}
134
+ />
135
+ </div>
136
+ );
137
+ }
@@ -0,0 +1,155 @@
1
+ "use client";
2
+
3
+ import { Plus, Trash2 } from "lucide-react";
4
+ import type { JSX } from "react";
5
+
6
+ import type {
7
+ AdvancedGroup,
8
+ AdvancedNode,
9
+ Combinator,
10
+ } from "#core/_utils/advanced-filter";
11
+ import {
12
+ isGroup,
13
+ MAX_DEPTH,
14
+ newGroup,
15
+ newRule,
16
+ } from "#core/_utils/advanced-filter";
17
+ import type { FilterField } from "#core/_utils/filter";
18
+ import { Button } from "#core/components/ui/button";
19
+ import { FilterRuleRow } from "#core/components/ui/filter-rule";
20
+ import { useI18n } from "#core/contexts";
21
+ import { cn } from "#core/lib/utils";
22
+
23
+ export interface FilterGroupBlockProps {
24
+ group: AdvancedGroup;
25
+ schema: FilterField[];
26
+ onChange: (group: AdvancedGroup) => void;
27
+ /** Ausente na raiz — o grupo de fora não pode ser removido. */
28
+ onRemove?: () => void;
29
+ /** 1 é a raiz; usado para não deixar aninhar além do teto do backend. */
30
+ level?: number;
31
+ labelFor: (field: FilterField) => string;
32
+ }
33
+
34
+ /**
35
+ * Um grupo do modo avançado: escolhe `E`/`OU` e contém regras e subgrupos.
36
+ *
37
+ * O aninhamento para em `MAX_DEPTH` porque é o teto que o backend aplica — o
38
+ * botão some antes de o usuário montar algo que voltaria 400.
39
+ */
40
+ export function FilterGroupBlock({
41
+ group,
42
+ schema,
43
+ onChange,
44
+ onRemove,
45
+ level = 1,
46
+ labelFor,
47
+ }: FilterGroupBlockProps): JSX.Element {
48
+ const { t } = useI18n();
49
+
50
+ const setChild = (index: number, child: AdvancedNode | null): void => {
51
+ const children = [...group.children];
52
+ if (child === null) {
53
+ children.splice(index, 1);
54
+ } else {
55
+ children[index] = child;
56
+ }
57
+ onChange({ ...group, children });
58
+ };
59
+
60
+ const setCombinator = (combinator: Combinator): void =>
61
+ onChange({ ...group, combinator });
62
+
63
+ const addRule = (): void => {
64
+ if (schema.length === 0) {
65
+ return;
66
+ }
67
+ onChange({ ...group, children: [...group.children, newRule(schema[0])] });
68
+ };
69
+
70
+ const addGroup = (): void =>
71
+ onChange({ ...group, children: [...group.children, newGroup("$AND")] });
72
+
73
+ return (
74
+ <div
75
+ className={cn(
76
+ "space-y-3 rounded-lg border border-border p-3",
77
+ level > 1 && "bg-muted/30",
78
+ )}
79
+ >
80
+ <div className="flex items-center justify-between gap-2">
81
+ <div className="inline-flex overflow-hidden rounded-md border border-border">
82
+ {(["$AND", "$OR"] as const).map((combinator) => (
83
+ <button
84
+ key={combinator}
85
+ type="button"
86
+ onClick={() => setCombinator(combinator)}
87
+ className={cn(
88
+ "px-3 py-1 text-xs transition-colors",
89
+ group.combinator === combinator
90
+ ? "bg-primary text-primary-foreground"
91
+ : "hover:bg-accent",
92
+ )}
93
+ >
94
+ {combinator === "$AND" ? t("filters.and") : t("filters.or")}
95
+ </button>
96
+ ))}
97
+ </div>
98
+
99
+ {onRemove && (
100
+ <Button
101
+ type="button"
102
+ variant="ghost"
103
+ size="iconSm"
104
+ aria-label={t("filters.removeGroup")}
105
+ onClick={onRemove}
106
+ >
107
+ <Trash2 className="h-4 w-4" />
108
+ </Button>
109
+ )}
110
+ </div>
111
+
112
+ {group.children.length === 0 && (
113
+ <p className="text-sm text-muted-foreground">{t("filters.emptyGroup")}</p>
114
+ )}
115
+
116
+ <div className="space-y-3">
117
+ {group.children.map((child, index) =>
118
+ isGroup(child) ? (
119
+ <FilterGroupBlock
120
+ key={child.id}
121
+ group={child}
122
+ schema={schema}
123
+ level={level + 1}
124
+ labelFor={labelFor}
125
+ onChange={(next) => setChild(index, next)}
126
+ onRemove={() => setChild(index, null)}
127
+ />
128
+ ) : (
129
+ <FilterRuleRow
130
+ key={child.id}
131
+ rule={child}
132
+ schema={schema}
133
+ labelFor={labelFor}
134
+ onChange={(next) => setChild(index, next)}
135
+ onRemove={() => setChild(index, null)}
136
+ />
137
+ ),
138
+ )}
139
+ </div>
140
+
141
+ <div className="flex flex-wrap gap-2">
142
+ <Button type="button" variant="outline" size="sm" onClick={addRule}>
143
+ <Plus className="h-4 w-4" />
144
+ {t("filters.addCondition")}
145
+ </Button>
146
+ {level < MAX_DEPTH && (
147
+ <Button type="button" variant="ghost" size="sm" onClick={addGroup}>
148
+ <Plus className="h-4 w-4" />
149
+ {t("filters.addGroup")}
150
+ </Button>
151
+ )}
152
+ </div>
153
+ </div>
154
+ );
155
+ }