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.
- package/README.md +129 -0
- package/package.json +113 -0
- package/src/_services/api/axios.factory.ts +97 -0
- package/src/_services/api/list-query.ts +26 -0
- package/src/_services/api/schema.d.ts +2527 -0
- package/src/_services/auth/index.ts +127 -0
- package/src/_utils/advanced-filter.ts +386 -0
- package/src/_utils/calendar.ts +103 -0
- package/src/_utils/crop-image.ts +65 -0
- package/src/_utils/filter.ts +313 -0
- package/src/_utils/format.ts +35 -0
- package/src/_utils/initials.ts +24 -0
- package/src/_utils/password.ts +7 -0
- package/src/components/app-shell.tsx +314 -0
- package/src/components/breadcrumbs.tsx +55 -0
- package/src/components/index.ts +7 -0
- package/src/components/language-selector.tsx +51 -0
- package/src/components/password-field.tsx +40 -0
- package/src/components/theme-toggle.tsx +27 -0
- package/src/components/ui/alert.tsx +27 -0
- package/src/components/ui/avatar.tsx +50 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +86 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/column-visibility-modal.tsx +110 -0
- package/src/components/ui/confirm-dialog.tsx +69 -0
- package/src/components/ui/data-table.tsx +401 -0
- package/src/components/ui/date-range-picker.tsx +250 -0
- package/src/components/ui/dialog.tsx +138 -0
- package/src/components/ui/dropdown-menu.tsx +108 -0
- package/src/components/ui/field.tsx +20 -0
- package/src/components/ui/filter-chips.tsx +88 -0
- package/src/components/ui/filter-field.tsx +137 -0
- package/src/components/ui/filter-group.tsx +155 -0
- package/src/components/ui/filter-rule.tsx +221 -0
- package/src/components/ui/filter-sheet.tsx +201 -0
- package/src/components/ui/index.ts +30 -0
- package/src/components/ui/input.tsx +25 -0
- package/src/components/ui/label.tsx +23 -0
- package/src/components/ui/row-actions.tsx +32 -0
- package/src/components/ui/select.tsx +97 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/sheet.tsx +119 -0
- package/src/components/ui/sonner.tsx +28 -0
- package/src/components/ui/spinner.tsx +12 -0
- package/src/components/ui/switch.tsx +29 -0
- package/src/components/ui/table.tsx +84 -0
- package/src/components/ui/tabs.tsx +52 -0
- package/src/components/user-avatar.tsx +54 -0
- package/src/contexts/auth-context.tsx +90 -0
- package/src/contexts/color-mode-context.tsx +105 -0
- package/src/contexts/i18n-context.tsx +76 -0
- package/src/contexts/index.ts +6 -0
- package/src/contexts/socket-context.tsx +153 -0
- package/src/contexts/toast-context.tsx +40 -0
- package/src/features/audit/audit-screen.tsx +299 -0
- package/src/features/audit/hooks/use-audit-trail.ts +68 -0
- package/src/features/audit/services/audit.service.ts +24 -0
- package/src/features/dashboard/dashboard-screen.tsx +73 -0
- package/src/features/login/components/backup-codes-dialog.tsx +66 -0
- package/src/features/login/components/index.ts +5 -0
- package/src/features/login/components/login-form.tsx +62 -0
- package/src/features/login/components/two-factor-setup.tsx +71 -0
- package/src/features/login/components/two-factor-verify.tsx +56 -0
- package/src/features/login/hooks/use-login-flow.ts +119 -0
- package/src/features/login/login-screen.tsx +83 -0
- package/src/features/login/validation/schemas.ts +30 -0
- package/src/features/logs/hooks/use-error-logs.ts +65 -0
- package/src/features/logs/hooks/use-request-logs.ts +65 -0
- package/src/features/logs/logs-screen.tsx +31 -0
- package/src/features/logs/panels/error-logs-panel.tsx +183 -0
- package/src/features/logs/panels/index.ts +3 -0
- package/src/features/logs/panels/request-logs-panel.tsx +124 -0
- package/src/features/logs/services/logs.service.ts +68 -0
- package/src/features/notifications/components/notification-item.tsx +67 -0
- package/src/features/notifications/hooks/use-notifications.ts +78 -0
- package/src/features/notifications/notifications-center.tsx +119 -0
- package/src/features/notifications/services/notifications.service.ts +38 -0
- package/src/features/profile/components/avatar-crop-dialog.tsx +133 -0
- package/src/features/profile/components/index.ts +2 -0
- package/src/features/profile/force-password-change.tsx +114 -0
- package/src/features/profile/profile-screen.tsx +385 -0
- package/src/features/rbac/panels/actions-panel.tsx +20 -0
- package/src/features/rbac/panels/catalog-panel.tsx +243 -0
- package/src/features/rbac/panels/groups-panel.tsx +269 -0
- package/src/features/rbac/panels/index.ts +8 -0
- package/src/features/rbac/panels/permissions-panel.tsx +204 -0
- package/src/features/rbac/panels/resources-panel.tsx +20 -0
- package/src/features/rbac/panels/roles-panel.tsx +224 -0
- package/src/features/rbac/panels/scopes-panel.tsx +20 -0
- package/src/features/rbac/rbac-screen.tsx +80 -0
- package/src/features/rbac/services/rbac.service.ts +110 -0
- package/src/features/recovery/forgot-password-screen.tsx +69 -0
- package/src/features/recovery/reset-password-screen.tsx +163 -0
- package/src/features/users/components/user-form-dialog.tsx +187 -0
- package/src/features/users/hooks/use-users.ts +181 -0
- package/src/features/users/services/users.service.ts +83 -0
- package/src/features/users/users-screen.tsx +331 -0
- package/src/hooks/use-column-visibility.ts +54 -0
- package/src/hooks/use-confirm.tsx +70 -0
- package/src/hooks/use-filter-schema.ts +85 -0
- package/src/hooks/use-filters.ts +148 -0
- package/src/hooks/use-list-query.ts +69 -0
- package/src/hooks/use-request.ts +114 -0
- package/src/i18n/messages/en.ts +296 -0
- package/src/i18n/messages/pt.ts +305 -0
- package/src/index.ts +60 -0
- package/src/lib/utils.ts +6 -0
- package/src/middleware.ts +42 -0
- package/src/styles/core.css +142 -0
- 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
|
+
}
|