rl-core-front 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ "use client";
2
+
3
+ import type {
4
+ ColumnDef,
5
+ OnChangeFn,
6
+ PaginationState,
7
+ } from "@tanstack/react-table";
8
+ import { RotateCcw, Trash2 } from "lucide-react";
9
+ import type { JSX } from "react";
10
+ import { useMemo } from "react";
11
+
12
+ import {
13
+ Badge,
14
+ Button,
15
+ Card,
16
+ DataTable,
17
+ DataTableFeatures,
18
+ RowActions,
19
+ SegmentedControl,
20
+ SegmentedOption,
21
+ } from "#core/components/ui";
22
+ import { useAuth, useI18n } from "#core/contexts";
23
+ import { useQueueJobs } from "#core/features/queues/hooks/use-queue-jobs";
24
+ import {
25
+ QueueJob,
26
+ QueueJobState,
27
+ } from "#core/features/queues/services/queues.service";
28
+ import { useConfirm } from "#core/hooks/use-confirm";
29
+
30
+ /** Rótulo de cada estado — a mesma chave serve à barra e aos cartões. */
31
+ const STATE_LABEL: Record<QueueJobState, string> = {
32
+ [QueueJobState.WAITING]: "queues.stateWaiting",
33
+ [QueueJobState.ACTIVE]: "queues.stateActive",
34
+ [QueueJobState.COMPLETED]: "queues.stateCompleted",
35
+ [QueueJobState.FAILED]: "queues.stateFailed",
36
+ [QueueJobState.DELAYED]: "queues.stateDelayed",
37
+ };
38
+
39
+ const STATE_VARIANT: Record<
40
+ QueueJobState,
41
+ "secondary" | "warning" | "success" | "destructive"
42
+ > = {
43
+ [QueueJobState.WAITING]: "secondary",
44
+ [QueueJobState.ACTIVE]: "warning",
45
+ [QueueJobState.COMPLETED]: "success",
46
+ [QueueJobState.FAILED]: "destructive",
47
+ [QueueJobState.DELAYED]: "secondary",
48
+ };
49
+
50
+ /**
51
+ * Fila de processamento — o que está rodando, o que falhou e o que fazer com
52
+ * isso.
53
+ *
54
+ * Substitui o painel do bull-board: aqui a tela mora no mesmo shell, com a
55
+ * mesma permissão e a mesma tradução do resto do sistema, em vez de um segundo
56
+ * frontend embutido no backend com autenticação própria.
57
+ */
58
+ export function QueuesScreen(): JSX.Element {
59
+ const { t } = useI18n();
60
+ const { hasPermission } = useAuth();
61
+ const { confirm, confirmDialog } = useConfirm();
62
+ const queue = useQueueJobs();
63
+
64
+ const canManage = hasPermission("queues:manage:any");
65
+
66
+ const stateOptions = useMemo<SegmentedOption[]>(
67
+ () =>
68
+ Object.values(QueueJobState).map((value) => ({
69
+ value,
70
+ label: t(STATE_LABEL[value]),
71
+ })),
72
+ [t],
73
+ );
74
+
75
+ /** Remover é sem volta: some da fila e não há de onde reprocessar depois. */
76
+ const removeJob = async (id: string): Promise<void> => {
77
+ const confirmed = await confirm({
78
+ title: t("queues.removeTitle"),
79
+ description: t("queues.removeDescription"),
80
+ destructive: true,
81
+ });
82
+ if (confirmed) {
83
+ await queue.remove(id);
84
+ }
85
+ };
86
+
87
+ const columns = useMemo<ColumnDef<DataTableFeatures, QueueJob, unknown>[]>(
88
+ () => [
89
+ {
90
+ accessorKey: "name",
91
+ header: t("queues.job"),
92
+ meta: { label: t("queues.job") },
93
+ enableSorting: false,
94
+ cell: ({ row }) => (
95
+ <div className="flex flex-col">
96
+ <span className="font-medium">{row.original.name}</span>
97
+ <span className="font-mono text-xs text-muted-foreground">
98
+ #{row.original.id}
99
+ </span>
100
+ </div>
101
+ ),
102
+ },
103
+ {
104
+ accessorKey: "state",
105
+ header: t("queues.state"),
106
+ meta: { label: t("queues.state") },
107
+ enableSorting: false,
108
+ cell: ({ row }) => {
109
+ const state = row.original.state as QueueJobState;
110
+ return (
111
+ <Badge variant={STATE_VARIANT[state] ?? "secondary"}>
112
+ {t(STATE_LABEL[state] ?? "queues.stateWaiting")}
113
+ </Badge>
114
+ );
115
+ },
116
+ },
117
+ {
118
+ accessorKey: "percent",
119
+ header: t("queues.progress"),
120
+ meta: { label: t("queues.progress") },
121
+ enableSorting: false,
122
+ cell: ({ getValue }) => {
123
+ const percent = getValue() as number | null;
124
+ return percent == null ? "—" : `${percent}%`;
125
+ },
126
+ },
127
+ {
128
+ accessorKey: "attemptsMade",
129
+ header: t("queues.attempts"),
130
+ meta: { label: t("queues.attempts") },
131
+ enableSorting: false,
132
+ },
133
+ {
134
+ accessorKey: "createdAt",
135
+ header: t("queues.createdAt"),
136
+ meta: { label: t("queues.createdAt") },
137
+ enableSorting: false,
138
+ cell: ({ getValue }) => new Date(String(getValue())).toLocaleString(),
139
+ },
140
+ {
141
+ accessorKey: "failedReason",
142
+ header: t("queues.reason"),
143
+ meta: { label: t("queues.reason") },
144
+ enableSorting: false,
145
+ // Cru e por inteiro no `title`: quem lê esta tela é quem vai consertar
146
+ // a causa, e a mensagem truncada some justamente na parte útil.
147
+ cell: ({ getValue }) => {
148
+ const reason = getValue() as string | null;
149
+ return reason ? (
150
+ <span
151
+ className="line-clamp-2 max-w-md font-mono text-xs"
152
+ title={reason}
153
+ >
154
+ {reason}
155
+ </span>
156
+ ) : (
157
+ "—"
158
+ );
159
+ },
160
+ },
161
+ {
162
+ id: "actions",
163
+ header: () => <div className="text-center">{t("common.actions")}</div>,
164
+ meta: { shrink: true },
165
+ enableHiding: false,
166
+ enableSorting: false,
167
+ cell: ({ row }) =>
168
+ canManage ? (
169
+ <RowActions>
170
+ <Button
171
+ variant="ghost"
172
+ size="sm"
173
+ onClick={() => void queue.retry(row.original.id)}
174
+ >
175
+ <RotateCcw className="h-4 w-4" />
176
+ {t("queues.retry")}
177
+ </Button>
178
+ <Button
179
+ variant="ghost"
180
+ size="sm"
181
+ className="text-destructive"
182
+ onClick={() => void removeJob(row.original.id)}
183
+ >
184
+ <Trash2 className="h-4 w-4" />
185
+ {t("common.delete")}
186
+ </Button>
187
+ </RowActions>
188
+ ) : null,
189
+ },
190
+ ],
191
+ // eslint-disable-next-line react-hooks/exhaustive-deps
192
+ [t, canManage, queue],
193
+ );
194
+
195
+ const pagination: PaginationState = {
196
+ pageIndex: queue.page,
197
+ pageSize: queue.pageSize,
198
+ };
199
+ const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
200
+ const next = typeof updater === "function" ? updater(pagination) : updater;
201
+ if (next.pageSize !== queue.pageSize) {
202
+ queue.setPageSize(next.pageSize);
203
+ queue.setPage(0);
204
+ } else if (next.pageIndex !== queue.page) {
205
+ queue.setPage(next.pageIndex);
206
+ }
207
+ };
208
+
209
+ return (
210
+ <div>
211
+ <h1 className="mb-1 text-3xl font-bold">{t("queues.title")}</h1>
212
+ <p className="mb-4 text-sm text-muted-foreground">
213
+ {t("queues.subtitle")}
214
+ </p>
215
+
216
+ {/* Os cartões são o resumo E o atalho: clicar troca o estado listado —
217
+ ver "3 falhos" e não poder abri-los seria uma informação morta. */}
218
+ <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-5">
219
+ {Object.values(QueueJobState).map((state) => (
220
+ <Card
221
+ key={state}
222
+ role="button"
223
+ tabIndex={0}
224
+ onClick={() => queue.setState(state)}
225
+ className={`cursor-pointer p-3 transition-colors hover:bg-accent ${
226
+ queue.state === state ? "border-primary" : ""
227
+ }`}
228
+ >
229
+ <p className="text-xs text-muted-foreground">
230
+ {t(STATE_LABEL[state])}
231
+ </p>
232
+ <p className="text-2xl font-bold tabular-nums">
233
+ {queue.counts[state]}
234
+ </p>
235
+ </Card>
236
+ ))}
237
+ </div>
238
+
239
+ <DataTable
240
+ columns={columns}
241
+ data={queue.rows}
242
+ getRowId={(r) => r.id}
243
+ storageKey="queues-jobs"
244
+ loading={queue.loading}
245
+ manualPagination
246
+ rowCount={queue.total}
247
+ pagination={pagination}
248
+ onPaginationChange={onPaginationChange}
249
+ enableSorting={false}
250
+ emptyMessage={t("queues.empty")}
251
+ toolbar={
252
+ <SegmentedControl
253
+ value={queue.state}
254
+ onValueChange={(value) => queue.setState(value as QueueJobState)}
255
+ options={stateOptions}
256
+ label={t("queues.state")}
257
+ />
258
+ }
259
+ />
260
+
261
+ {confirmDialog}
262
+ </div>
263
+ );
264
+ }
@@ -0,0 +1,57 @@
1
+ import { api } from "#core/_services/api/axios.factory";
2
+ import type { components } from "#core/_services/api/schema";
3
+
4
+ /** Tipos derivados do `openapi.json` (§5) — não redigitar a forma da resposta. */
5
+ export type QueueJob = components["schemas"]["QueueJobResponse"];
6
+ export type QueueCounts = components["schemas"]["QueueCountsResponse"];
7
+
8
+ export interface Paginated<T> {
9
+ items: T[];
10
+ meta: components["schemas"]["PaginationMetaResponse"];
11
+ }
12
+
13
+ /**
14
+ * Estados que a tela lista.
15
+ *
16
+ * Espelha o `LISTABLE_STATES` do backend. `paused` não está aqui porque é
17
+ * estado da fila inteira, não de um job.
18
+ */
19
+ export enum QueueJobState {
20
+ WAITING = "waiting",
21
+ ACTIVE = "active",
22
+ COMPLETED = "completed",
23
+ FAILED = "failed",
24
+ DELAYED = "delayed",
25
+ }
26
+
27
+ export interface QueueJobsQuery {
28
+ state: QueueJobState;
29
+ page: number;
30
+ limit: number;
31
+ }
32
+
33
+ export const queuesService = {
34
+ counts(): Promise<QueueCounts> {
35
+ return api.get<QueueCounts>("/queues/counts").then((r) => r.data);
36
+ },
37
+
38
+ /**
39
+ * Uma página de jobs de **um** estado.
40
+ *
41
+ * O estado é obrigatório: no Redis cada estado é uma estrutura própria, e
42
+ * "todos" não é uma consulta que exista.
43
+ */
44
+ list(query: QueueJobsQuery): Promise<Paginated<QueueJob>> {
45
+ return api
46
+ .get<Paginated<QueueJob>>("/queues/jobs", { params: query })
47
+ .then((r) => r.data);
48
+ },
49
+
50
+ retry(id: string): Promise<void> {
51
+ return api.post(`/queues/jobs/${id}/retry`).then(() => undefined);
52
+ },
53
+
54
+ remove(id: string): Promise<void> {
55
+ return api.delete(`/queues/jobs/${id}`).then(() => undefined);
56
+ },
57
+ };
@@ -0,0 +1,210 @@
1
+ "use client";
2
+
3
+ import { ChevronDown } from "lucide-react";
4
+ import type { JSX } from "react";
5
+ import { useMemo, useState } from "react";
6
+
7
+ import { Badge, Checkbox, Input } from "#core/components/ui";
8
+ import { useI18n } from "#core/contexts";
9
+ import { Permission } from "#core/features/rbac/services/rbac.service";
10
+
11
+ /** Uma área do sistema com tudo que se pode fazer nela. */
12
+ interface ResourceGroup {
13
+ name: string;
14
+ label: string;
15
+ permissions: Permission[];
16
+ }
17
+
18
+ export interface PermissionMatrixProps {
19
+ /** Catálogo inteiro, como `/rbac/permissions` devolve. */
20
+ permissions: Permission[];
21
+ /** Ids marcados. */
22
+ value: string[];
23
+ onChange: (permissionIds: string[]) => void;
24
+ /** Somente leitura: usado no cartão de resumo do papel. */
25
+ readOnly?: boolean;
26
+ }
27
+
28
+ /**
29
+ * As permissões de um papel na forma como se pensa nelas: **uma área por
30
+ * bloco, e dentro dela o que se pode fazer ali**.
31
+ *
32
+ * Substitui a lista chapada de checkboxes com o código cru (`users:read:any`),
33
+ * que obrigava a ler 48 strings técnicas numa caixa de 240px para montar um
34
+ * papel — e que só piora, porque cada feature nova do projeto acrescenta a sua
35
+ * leva. Agrupar por recurso é o que mantém a tela legível quando o catálogo
36
+ * dobra de tamanho.
37
+ *
38
+ * Os rótulos saem da descrição que o backend já manda, com o nome cru de
39
+ * reserva: catálogo estendido pelo projeto (`finance`, `funkos`) aparece aqui
40
+ * sem precisar de tradução no core.
41
+ */
42
+ export function PermissionMatrix({
43
+ permissions,
44
+ value,
45
+ onChange,
46
+ readOnly,
47
+ }: PermissionMatrixProps): JSX.Element {
48
+ const { t } = useI18n();
49
+ const [search, setSearch] = useState("");
50
+ const [collapsed, setCollapsed] = useState<string[]>([]);
51
+
52
+ const groups = useMemo<ResourceGroup[]>(
53
+ () => groupByResource(permissions),
54
+ [permissions],
55
+ );
56
+
57
+ const visible = useMemo<ResourceGroup[]>(() => {
58
+ const term = search.trim().toLowerCase();
59
+ if (!term) {
60
+ return groups;
61
+ }
62
+ // Busca pelo nome da área e pelo código: quem já sabe o que procura
63
+ // digita `finance`, quem não sabe procura "Financeiro".
64
+ return groups
65
+ .map((group) => ({
66
+ ...group,
67
+ permissions: group.label.toLowerCase().includes(term)
68
+ ? group.permissions
69
+ : group.permissions.filter((p) =>
70
+ `${p.code} ${p.description ?? ""}`.toLowerCase().includes(term),
71
+ ),
72
+ }))
73
+ .filter((group) => group.permissions.length > 0);
74
+ }, [groups, search]);
75
+
76
+ const toggle = (id: string): void =>
77
+ onChange(
78
+ value.includes(id) ? value.filter((x) => x !== id) : [...value, id],
79
+ );
80
+
81
+ const toggleGroup = (group: ResourceGroup): void => {
82
+ const ids = group.permissions.map((p) => p.id);
83
+ const allOn = ids.every((id) => value.includes(id));
84
+ onChange(
85
+ allOn
86
+ ? value.filter((id) => !ids.includes(id))
87
+ : [...value, ...ids.filter((id) => !value.includes(id))],
88
+ );
89
+ };
90
+
91
+ const toggleCollapse = (name: string): void =>
92
+ setCollapsed((c) =>
93
+ c.includes(name) ? c.filter((x) => x !== name) : [...c, name],
94
+ );
95
+
96
+ return (
97
+ <div className="space-y-3">
98
+ {!readOnly && (
99
+ <Input
100
+ value={search}
101
+ onChange={(e) => setSearch(e.target.value)}
102
+ placeholder={t("rbac.searchPermissions")}
103
+ />
104
+ )}
105
+
106
+ {/* Sem scroll próprio: quem rola é o corpo do modal. Dois scrolls
107
+ aninhados prendem a roda do mouse no de dentro. */}
108
+ <div className="space-y-2">
109
+ {visible.map((group) => {
110
+ const ids = group.permissions.map((p) => p.id);
111
+ const selected = ids.filter((id) => value.includes(id)).length;
112
+ const isCollapsed = collapsed.includes(group.name);
113
+
114
+ return (
115
+ <div
116
+ key={group.name}
117
+ className="rounded-md border border-border bg-card"
118
+ >
119
+ <div className="flex items-center gap-2 px-3 py-2">
120
+ {!readOnly && (
121
+ <Checkbox
122
+ checked={selected === ids.length}
123
+ onCheckedChange={() => toggleGroup(group)}
124
+ aria-label={group.label}
125
+ />
126
+ )}
127
+ <button
128
+ type="button"
129
+ onClick={() => toggleCollapse(group.name)}
130
+ className="flex flex-1 items-center gap-2 text-left"
131
+ >
132
+ <span className="text-sm font-medium">{group.label}</span>
133
+ <Badge variant="outline" className="font-mono text-xs">
134
+ {selected}/{ids.length}
135
+ </Badge>
136
+ <ChevronDown
137
+ className={`ml-auto h-4 w-4 text-muted-foreground transition-transform ${
138
+ isCollapsed ? "-rotate-90" : ""
139
+ }`}
140
+ />
141
+ </button>
142
+ </div>
143
+
144
+ {!isCollapsed && (
145
+ <div className="grid grid-cols-1 gap-1 border-t border-border px-3 py-2 sm:grid-cols-2">
146
+ {group.permissions.map((p) => (
147
+ <label
148
+ key={p.id}
149
+ className="flex cursor-pointer items-start gap-2 rounded px-1 py-1 text-sm hover:bg-muted/50"
150
+ >
151
+ <Checkbox
152
+ checked={value.includes(p.id)}
153
+ onCheckedChange={() => toggle(p.id)}
154
+ disabled={readOnly}
155
+ className="mt-0.5"
156
+ />
157
+ <span className="flex flex-col">
158
+ <span>{actionLabel(p)}</span>
159
+ <span className="font-mono text-xs text-muted-foreground">
160
+ {p.code}
161
+ </span>
162
+ </span>
163
+ </label>
164
+ ))}
165
+ </div>
166
+ )}
167
+ </div>
168
+ );
169
+ })}
170
+
171
+ {visible.length === 0 && (
172
+ <p className="py-6 text-center text-sm text-muted-foreground">
173
+ {t("rbac.noPermissionsFound")}
174
+ </p>
175
+ )}
176
+ </div>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ /**
182
+ * Agrupa por recurso, caindo no prefixo do código quando a relação não veio.
183
+ *
184
+ * O fallback existe porque `code` é a única parte que **sempre** chega: uma
185
+ * permissão órfã (resource apagado) sumiria da tela em vez de aparecer para
186
+ * ser corrigida, e uma permissão invisível continua valendo no backend.
187
+ */
188
+ const groupByResource = (permissions: Permission[]): ResourceGroup[] => {
189
+ const map = new Map<string, ResourceGroup>();
190
+
191
+ for (const permission of permissions) {
192
+ const name = permission.resource?.name ?? permission.code.split(":")[0];
193
+ const label =
194
+ permission.resource?.description?.trim() || name;
195
+
196
+ const group = map.get(name) ?? { name, label, permissions: [] };
197
+ group.permissions.push(permission);
198
+ map.set(name, group);
199
+ }
200
+
201
+ return [...map.values()].sort((a, b) => a.label.localeCompare(b.label));
202
+ };
203
+
204
+ /** "Ler/visualizar · Apenas recursos próprios" — ação e alcance, nesta ordem. */
205
+ const actionLabel = (permission: Permission): string => {
206
+ const [, action, scope] = permission.code.split(":");
207
+ const actionText = permission.action?.description?.trim() || action;
208
+ const scopeText = permission.scope?.description?.trim() || scope;
209
+ return `${actionText} · ${scopeText}`;
210
+ };
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import type { JSX } from "react";
4
+
5
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "#core/components/ui";
6
+ import { useAuth, useI18n } from "#core/contexts";
7
+ import { ActionsPanel } from "#core/features/rbac/panels/actions-panel";
8
+ import { PermissionsPanel } from "#core/features/rbac/panels/permissions-panel";
9
+ import { ResourcesPanel } from "#core/features/rbac/panels/resources-panel";
10
+ import { ScopesPanel } from "#core/features/rbac/panels/scopes-panel";
11
+
12
+ /**
13
+ * A mecânica do RBAC — as peças de que um código `resource:action:scope` é
14
+ * feito, mais o catálogo de permissões montado com elas.
15
+ *
16
+ * As quatro viviam como abas irmãs de "Papéis", o que punha a manutenção do
17
+ * modelo no mesmo nível do trabalho de todo dia: quem entra para dar acesso a
18
+ * alguém não deveria esbarrar em "Resources" e "Scopes" no caminho. Aqui elas
19
+ * continuam inteiras, mas atrás de uma porta só.
20
+ */
21
+ export function DefinitionsPanel(): JSX.Element {
22
+ const { t } = useI18n();
23
+ const { hasPermission } = useAuth();
24
+
25
+ const tabs = [
26
+ {
27
+ value: "permissions",
28
+ label: t("rbac.tabs.permissions"),
29
+ perm: "permissions:read:any",
30
+ panel: <PermissionsPanel />,
31
+ },
32
+ {
33
+ value: "resources",
34
+ label: t("rbac.tabs.resources"),
35
+ perm: "resources:read:any",
36
+ panel: <ResourcesPanel />,
37
+ },
38
+ {
39
+ value: "actions",
40
+ label: t("rbac.tabs.actions"),
41
+ perm: "actions:read:any",
42
+ panel: <ActionsPanel />,
43
+ },
44
+ {
45
+ value: "scopes",
46
+ label: t("rbac.tabs.scopes"),
47
+ perm: "scopes:read:any",
48
+ panel: <ScopesPanel />,
49
+ },
50
+ ].filter((tab) => hasPermission(tab.perm));
51
+
52
+ if (tabs.length === 0) {
53
+ return <></>;
54
+ }
55
+
56
+ return (
57
+ <div className="space-y-4">
58
+ <p className="text-sm text-muted-foreground">{t("rbac.definitionsHint")}</p>
59
+ <Tabs defaultValue={tabs[0].value}>
60
+ <TabsList>
61
+ {tabs.map((tab) => (
62
+ <TabsTrigger key={tab.value} value={tab.value}>
63
+ {tab.label}
64
+ </TabsTrigger>
65
+ ))}
66
+ </TabsList>
67
+ {tabs.map((tab) => (
68
+ <TabsContent key={tab.value} value={tab.value}>
69
+ {tab.panel}
70
+ </TabsContent>
71
+ ))}
72
+ </Tabs>
73
+ </div>
74
+ );
75
+ }
@@ -1,6 +1,7 @@
1
1
  // Barrel da pasta — importe daqui de fora; dentro da pasta use o caminho direto.
2
2
  export * from "./actions-panel";
3
3
  export * from "./catalog-panel";
4
+ export * from "./definitions-panel";
4
5
  export * from "./groups-panel";
5
6
  export * from "./permissions-panel";
6
7
  export * from "./resources-panel";