rl-core-front 0.14.3 → 0.15.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/package.json +1 -1
- package/src/_services/api/schema.d.ts +286 -5
- package/src/components/app-shell.tsx +26 -1
- package/src/components/ui/create-button.tsx +64 -0
- package/src/components/ui/data-table.tsx +120 -3
- package/src/components/ui/dialog.tsx +27 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/input.tsx +73 -16
- package/src/features/queues/hooks/use-queue-jobs.ts +17 -4
- package/src/features/queues/queues-screen.tsx +56 -6
- package/src/features/queues/services/queues.service.ts +25 -6
- package/src/features/shortcuts/components/shortcut-card.tsx +97 -0
- package/src/features/shortcuts/shortcuts-screen.tsx +96 -0
- package/src/features/users/users-screen.tsx +12 -6
- package/src/hooks/use-shortcut.ts +267 -0
- package/src/i18n/messages/en.ts +40 -0
- package/src/i18n/messages/pt.ts +41 -0
- package/src/index.ts +10 -0
|
@@ -4,6 +4,11 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
|
4
4
|
import { Expand, Shrink, X } from "lucide-react";
|
|
5
5
|
import * as React from "react";
|
|
6
6
|
|
|
7
|
+
import {
|
|
8
|
+
EXPAND_SHORTCUT_KEY,
|
|
9
|
+
EXPAND_SHORTCUT_LABEL,
|
|
10
|
+
useShortcut,
|
|
11
|
+
} from "#core/hooks/use-shortcut";
|
|
7
12
|
import { cn } from "#core/lib/utils";
|
|
8
13
|
|
|
9
14
|
const Dialog = DialogPrimitive.Root;
|
|
@@ -203,6 +208,26 @@ const DialogContent = React.forwardRef<
|
|
|
203
208
|
const [expanded, setExpanded] = React.useState(false);
|
|
204
209
|
const [confirmandoSaida, setConfirmandoSaida] = React.useState(false);
|
|
205
210
|
|
|
211
|
+
const alternarTamanho = React.useCallback(
|
|
212
|
+
() => setExpanded((value) => !value),
|
|
213
|
+
[],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// O par do botão de ampliar, e o par do `Esc`: vale em qualquer lugar do
|
|
217
|
+
// modal, inclusive com o cursor num campo — atalho do modal que só funciona
|
|
218
|
+
// fora dos campos não serve, porque o modal é feito de campos.
|
|
219
|
+
//
|
|
220
|
+
// `Alt + Enter` não escreve nada, então vale igual dentro e fora dos campos —
|
|
221
|
+
// e o `preventDefault` do hook é o que impede o `Enter` de enviar o
|
|
222
|
+
// formulário junto. Desligado quando o modal não é ampliável: atalho que não
|
|
223
|
+
// faz nada é pior que atalho nenhum.
|
|
224
|
+
useShortcut(EXPAND_SHORTCUT_KEY, alternarTamanho, {
|
|
225
|
+
alt: true,
|
|
226
|
+
allowInDialog: true,
|
|
227
|
+
allowWhileTyping: true,
|
|
228
|
+
enabled: expandable,
|
|
229
|
+
});
|
|
230
|
+
|
|
206
231
|
/**
|
|
207
232
|
* Fecha de verdade, depois de confirmado.
|
|
208
233
|
*
|
|
@@ -290,7 +315,8 @@ const DialogContent = React.forwardRef<
|
|
|
290
315
|
{expandable && (
|
|
291
316
|
<button
|
|
292
317
|
type="button"
|
|
293
|
-
onClick={
|
|
318
|
+
onClick={alternarTamanho}
|
|
319
|
+
title={`${expanded ? "Restaurar" : "Ampliar"} (${EXPAND_SHORTCUT_LABEL})`}
|
|
294
320
|
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
295
321
|
>
|
|
296
322
|
{expanded ? (
|
|
@@ -8,6 +8,7 @@ export * from "./checkbox";
|
|
|
8
8
|
export * from "./column-visibility-modal";
|
|
9
9
|
export * from "./combobox";
|
|
10
10
|
export * from "./confirm-dialog";
|
|
11
|
+
export * from "./create-button";
|
|
11
12
|
export * from "./data-table";
|
|
12
13
|
export * from "./date-picker";
|
|
13
14
|
export * from "./date-range-picker";
|
|
@@ -4,22 +4,79 @@ import * as React from "react";
|
|
|
4
4
|
|
|
5
5
|
import { cn } from "#core/lib/utils";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
7
|
+
/**
|
|
8
|
+
* O `prefix` sai do tipo base de propósito: o React o declara como `string`
|
|
9
|
+
* porque existe um `prefix` de RDFa em todo elemento HTML — atributo que
|
|
10
|
+
* ninguém escreve num `<input>`, e cujo tipo impediria passar um ícone aqui.
|
|
11
|
+
*/
|
|
12
|
+
export interface InputProps
|
|
13
|
+
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix"> {
|
|
14
|
+
/**
|
|
15
|
+
* Marca fixa colada à esquerda do campo — `R$`, `@`, `https://`.
|
|
16
|
+
*
|
|
17
|
+
* Dentro da borda, e não num rótulo acima: a unidade é do valor, e lida
|
|
18
|
+
* longe dele ela vira decoração que some quando o formulário fica cheio. É
|
|
19
|
+
* também o que evita quem digita repetir a marca no próprio texto.
|
|
20
|
+
*/
|
|
21
|
+
prefix?: React.ReactNode;
|
|
22
|
+
/** Mesma ideia à direita — `%`, `kg`, `/mês`. */
|
|
23
|
+
suffix?: React.ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A marca não é campo: não recebe clique nem foco, e o cursor vai para o input. */
|
|
27
|
+
const Affix = ({
|
|
28
|
+
children,
|
|
29
|
+
className,
|
|
30
|
+
}: {
|
|
31
|
+
children: React.ReactNode;
|
|
32
|
+
className?: string;
|
|
33
|
+
}): React.JSX.Element => (
|
|
34
|
+
<span
|
|
35
|
+
aria-hidden
|
|
36
|
+
className={cn(
|
|
37
|
+
"pointer-events-none absolute inset-y-0 flex items-center text-sm text-muted-foreground",
|
|
38
|
+
className,
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
{children}
|
|
42
|
+
</span>
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
46
|
+
({ className, type, prefix, suffix, ...props }, ref) => {
|
|
47
|
+
const campo = (
|
|
48
|
+
<input
|
|
49
|
+
type={type}
|
|
50
|
+
className={cn(
|
|
51
|
+
"flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
|
52
|
+
// Abre espaço para a marca em vez de deixar o texto passar por baixo
|
|
53
|
+
// dela. Largura fixa e não `w-auto`: a marca é curta por definição, e
|
|
54
|
+
// medir a real exigiria ler o DOM a cada render.
|
|
55
|
+
prefix && "pl-10",
|
|
56
|
+
suffix && "pr-10",
|
|
57
|
+
className,
|
|
58
|
+
)}
|
|
59
|
+
ref={ref}
|
|
60
|
+
{...props}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Sem marca o `<input>` volta a ser o elemento raiz: envolver todo campo
|
|
65
|
+
// do produto num `div` mudaria a largura de quem já usa `Input` dentro de
|
|
66
|
+
// um flex, e a maioria não tem marca nenhuma.
|
|
67
|
+
if (!prefix && !suffix) {
|
|
68
|
+
return campo;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="relative w-full">
|
|
73
|
+
{prefix && <Affix className="left-3">{prefix}</Affix>}
|
|
74
|
+
{campo}
|
|
75
|
+
{suffix && <Affix className="right-3">{suffix}</Affix>}
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
);
|
|
23
80
|
Input.displayName = "Input";
|
|
24
81
|
|
|
25
82
|
export { Input };
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
QueueCounts,
|
|
8
8
|
QueueJob,
|
|
9
9
|
QueueJobState,
|
|
10
|
+
QueueName,
|
|
10
11
|
queuesService,
|
|
11
12
|
} from "#core/features/queues/services/queues.service";
|
|
12
13
|
import { useLatestRequest } from "#core/hooks/use-latest-request";
|
|
@@ -27,12 +28,14 @@ export interface UseQueueJobsResult {
|
|
|
27
28
|
page: number;
|
|
28
29
|
pageSize: number;
|
|
29
30
|
state: QueueJobState;
|
|
31
|
+
queue: QueueName;
|
|
30
32
|
sorting: SortingState;
|
|
31
33
|
loading: boolean;
|
|
32
34
|
setPage: (page: number) => void;
|
|
33
35
|
setPageSize: (size: number) => void;
|
|
34
36
|
setSorting: (sorting: SortingState) => void;
|
|
35
37
|
setState: (state: QueueJobState) => void;
|
|
38
|
+
setQueue: (queue: QueueName) => void;
|
|
36
39
|
refresh: () => Promise<void>;
|
|
37
40
|
retry: (id: string) => Promise<void>;
|
|
38
41
|
remove: (id: string) => Promise<void>;
|
|
@@ -54,6 +57,7 @@ export function useQueueJobs(): UseQueueJobsResult {
|
|
|
54
57
|
const [page, setPage] = useState(0);
|
|
55
58
|
const [pageSize, setPageSize] = useState(20);
|
|
56
59
|
const [state, setStateValue] = useState<QueueJobState>(QueueJobState.FAILED);
|
|
60
|
+
const [queue, setQueueValue] = useState<QueueName>(QueueName.JOBS);
|
|
57
61
|
// Sem ordem escolhida, a lista sai na ordem do Redis — que é a mais recente
|
|
58
62
|
// primeiro e não custa varredura nenhuma no backend.
|
|
59
63
|
const [sorting, setSorting] = useState<SortingState>([]);
|
|
@@ -67,6 +71,7 @@ export function useQueueJobs(): UseQueueJobsResult {
|
|
|
67
71
|
run(() =>
|
|
68
72
|
queuesService.list({
|
|
69
73
|
state,
|
|
74
|
+
queue,
|
|
70
75
|
page: page + 1,
|
|
71
76
|
limit: pageSize,
|
|
72
77
|
sortBy: sorting[0]?.id,
|
|
@@ -77,7 +82,7 @@ export function useQueueJobs(): UseQueueJobsResult {
|
|
|
77
82
|
: undefined,
|
|
78
83
|
}),
|
|
79
84
|
),
|
|
80
|
-
run(() => queuesService.counts()),
|
|
85
|
+
run(() => queuesService.counts(queue)),
|
|
81
86
|
]),
|
|
82
87
|
);
|
|
83
88
|
if (!result) {
|
|
@@ -91,7 +96,7 @@ export function useQueueJobs(): UseQueueJobsResult {
|
|
|
91
96
|
if (summary) {
|
|
92
97
|
setCounts(summary);
|
|
93
98
|
}
|
|
94
|
-
}, [latest, run, state, page, pageSize, sorting]);
|
|
99
|
+
}, [latest, run, state, queue, page, pageSize, sorting]);
|
|
95
100
|
|
|
96
101
|
useEffect(() => {
|
|
97
102
|
void fetch();
|
|
@@ -104,6 +109,12 @@ export function useQueueJobs(): UseQueueJobsResult {
|
|
|
104
109
|
setPage(0);
|
|
105
110
|
};
|
|
106
111
|
|
|
112
|
+
/** Trocar de fila é trocar de conjunto inteiro: página e contadores vão junto. */
|
|
113
|
+
const setQueue = (next: QueueName): void => {
|
|
114
|
+
setQueueValue(next);
|
|
115
|
+
setPage(0);
|
|
116
|
+
};
|
|
117
|
+
|
|
107
118
|
/**
|
|
108
119
|
* Depois de reprocessar ou remover, relê a lista **e** os contadores: o job
|
|
109
120
|
* mudou de estado, e a tela ficaria mostrando o que já não está ali.
|
|
@@ -120,14 +131,16 @@ export function useQueueJobs(): UseQueueJobsResult {
|
|
|
120
131
|
page,
|
|
121
132
|
pageSize,
|
|
122
133
|
state,
|
|
134
|
+
queue,
|
|
123
135
|
sorting,
|
|
124
136
|
loading,
|
|
125
137
|
setPage,
|
|
126
138
|
setPageSize,
|
|
127
139
|
setSorting,
|
|
128
140
|
setState,
|
|
141
|
+
setQueue,
|
|
129
142
|
refresh: fetch,
|
|
130
|
-
retry: (id) => act(queuesService.retry(id)),
|
|
131
|
-
remove: (id) => act(queuesService.remove(id)),
|
|
143
|
+
retry: (id) => act(queuesService.retry(id, queue)),
|
|
144
|
+
remove: (id) => act(queuesService.remove(id, queue)),
|
|
132
145
|
};
|
|
133
146
|
}
|
|
@@ -25,6 +25,7 @@ import { useQueueJobs } from "#core/features/queues/hooks/use-queue-jobs";
|
|
|
25
25
|
import {
|
|
26
26
|
QueueJob,
|
|
27
27
|
QueueJobState,
|
|
28
|
+
QueueName,
|
|
28
29
|
} from "#core/features/queues/services/queues.service";
|
|
29
30
|
import { useConfirm } from "#core/hooks/use-confirm";
|
|
30
31
|
|
|
@@ -37,6 +38,12 @@ const STATE_LABEL: Record<QueueJobState, string> = {
|
|
|
37
38
|
[QueueJobState.DELAYED]: "queues.stateDelayed",
|
|
38
39
|
};
|
|
39
40
|
|
|
41
|
+
/** Rótulo de cada fila. A geral e a de relatórios rodam em processos diferentes. */
|
|
42
|
+
const QUEUE_LABEL: Record<QueueName, string> = {
|
|
43
|
+
[QueueName.JOBS]: "queues.queueJobs",
|
|
44
|
+
[QueueName.EXPORTS]: "queues.queueExports",
|
|
45
|
+
};
|
|
46
|
+
|
|
40
47
|
const STATE_VARIANT: Record<
|
|
41
48
|
QueueJobState,
|
|
42
49
|
"secondary" | "warning" | "success" | "destructive"
|
|
@@ -73,6 +80,15 @@ export function QueuesScreen(): JSX.Element {
|
|
|
73
80
|
[t],
|
|
74
81
|
);
|
|
75
82
|
|
|
83
|
+
const queueOptions = useMemo<SegmentedOption[]>(
|
|
84
|
+
() =>
|
|
85
|
+
Object.values(QueueName).map((value) => ({
|
|
86
|
+
value,
|
|
87
|
+
label: t(QUEUE_LABEL[value]),
|
|
88
|
+
})),
|
|
89
|
+
[t],
|
|
90
|
+
);
|
|
91
|
+
|
|
76
92
|
/** Remover é sem volta: some da fila e não há de onde reprocessar depois. */
|
|
77
93
|
const removeJob = async (id: string): Promise<void> => {
|
|
78
94
|
const confirmed = await confirm({
|
|
@@ -133,6 +149,32 @@ export function QueuesScreen(): JSX.Element {
|
|
|
133
149
|
meta: { label: t("queues.createdAt") },
|
|
134
150
|
cell: ({ getValue }) => new Date(String(getValue())).toLocaleString(),
|
|
135
151
|
},
|
|
152
|
+
{
|
|
153
|
+
accessorKey: "payload",
|
|
154
|
+
header: t("queues.payload"),
|
|
155
|
+
enableSorting: false,
|
|
156
|
+
// O conteúdo do job, cru. É o que responde *qual* pedido falhou, e não
|
|
157
|
+
// só *que* falhou — sem ele, um relatório que quebrou não diz qual
|
|
158
|
+
// recorte quebrou. Sai só aqui porque esta tela é de monitoramento do
|
|
159
|
+
// administrador, onde ver tudo é a função.
|
|
160
|
+
meta: { label: t("queues.payload") },
|
|
161
|
+
cell: ({ getValue }) => {
|
|
162
|
+
const payload = getValue();
|
|
163
|
+
if (payload == null) {
|
|
164
|
+
return "—";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const text = JSON.stringify(payload);
|
|
168
|
+
return (
|
|
169
|
+
<span
|
|
170
|
+
className="line-clamp-2 max-w-md font-mono text-xs"
|
|
171
|
+
title={text}
|
|
172
|
+
>
|
|
173
|
+
{text}
|
|
174
|
+
</span>
|
|
175
|
+
);
|
|
176
|
+
},
|
|
177
|
+
},
|
|
136
178
|
{
|
|
137
179
|
accessorKey: "failedReason",
|
|
138
180
|
header: t("queues.reason"),
|
|
@@ -253,12 +295,20 @@ export function QueuesScreen(): JSX.Element {
|
|
|
253
295
|
onSortingChange={onSortingChange}
|
|
254
296
|
emptyMessage={t("queues.empty")}
|
|
255
297
|
toolbar={
|
|
256
|
-
<
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
298
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
299
|
+
<SegmentedControl
|
|
300
|
+
value={queue.queue}
|
|
301
|
+
onValueChange={(value) => queue.setQueue(value as QueueName)}
|
|
302
|
+
options={queueOptions}
|
|
303
|
+
label={t("queues.queue")}
|
|
304
|
+
/>
|
|
305
|
+
<SegmentedControl
|
|
306
|
+
value={queue.state}
|
|
307
|
+
onValueChange={(value) => queue.setState(value as QueueJobState)}
|
|
308
|
+
options={stateOptions}
|
|
309
|
+
label={t("queues.state")}
|
|
310
|
+
/>
|
|
311
|
+
</div>
|
|
262
312
|
}
|
|
263
313
|
/>
|
|
264
314
|
|
|
@@ -24,6 +24,18 @@ export enum QueueJobState {
|
|
|
24
24
|
DELAYED = "delayed",
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* As filas do sistema. Espelha o `QUEUE_NAMES` do backend.
|
|
29
|
+
*
|
|
30
|
+
* São duas porque a exportação roda em processo próprio: um worker do BullMQ
|
|
31
|
+
* puxa qualquer job da fila em que está registrado, e misturá-las faria o
|
|
32
|
+
* consumidor de relatórios roubar o trabalho de outra feature.
|
|
33
|
+
*/
|
|
34
|
+
export enum QueueName {
|
|
35
|
+
JOBS = "jobs",
|
|
36
|
+
EXPORTS = "exports",
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
export interface QueueJobsQuery {
|
|
28
40
|
state: QueueJobState;
|
|
29
41
|
page: number;
|
|
@@ -31,11 +43,14 @@ export interface QueueJobsQuery {
|
|
|
31
43
|
/** Campo da whitelist do backend (`SORTABLE_JOB_FIELDS`). */
|
|
32
44
|
sortBy?: string;
|
|
33
45
|
sortDir?: "ASC" | "DESC";
|
|
46
|
+
queue?: QueueName;
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
export const queuesService = {
|
|
37
|
-
counts(): Promise<QueueCounts> {
|
|
38
|
-
return api
|
|
50
|
+
counts(queue?: QueueName): Promise<QueueCounts> {
|
|
51
|
+
return api
|
|
52
|
+
.get<QueueCounts>("/queues/counts", { params: { queue } })
|
|
53
|
+
.then((r) => r.data);
|
|
39
54
|
},
|
|
40
55
|
|
|
41
56
|
/**
|
|
@@ -50,11 +65,15 @@ export const queuesService = {
|
|
|
50
65
|
.then((r) => r.data);
|
|
51
66
|
},
|
|
52
67
|
|
|
53
|
-
retry(id: string): Promise<void> {
|
|
54
|
-
return api
|
|
68
|
+
retry(id: string, queue?: QueueName): Promise<void> {
|
|
69
|
+
return api
|
|
70
|
+
.post(`/queues/jobs/${id}/retry`, undefined, { params: { queue } })
|
|
71
|
+
.then(() => undefined);
|
|
55
72
|
},
|
|
56
73
|
|
|
57
|
-
remove(id: string): Promise<void> {
|
|
58
|
-
return api
|
|
74
|
+
remove(id: string, queue?: QueueName): Promise<void> {
|
|
75
|
+
return api
|
|
76
|
+
.delete(`/queues/jobs/${id}`, { params: { queue } })
|
|
77
|
+
.then(() => undefined);
|
|
59
78
|
},
|
|
60
79
|
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import { Badge, Card } from "#core/components/ui";
|
|
6
|
+
import { useI18n } from "#core/contexts";
|
|
7
|
+
|
|
8
|
+
export interface Shortcut {
|
|
9
|
+
/** Chave de i18n do nome. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** As combinações que servem — mais de uma quando o teclado varia. */
|
|
12
|
+
combos: readonly (readonly string[])[];
|
|
13
|
+
/**
|
|
14
|
+
* Teclas em sequência, e não juntas.
|
|
15
|
+
*
|
|
16
|
+
* A diferença muda o desenho: `Alt + Enter` se aperta ao mesmo tempo e leva o
|
|
17
|
+
* `+`; `1` `1` são duas batidas seguidas e o `+` ali seria mentira.
|
|
18
|
+
*/
|
|
19
|
+
sequence?: boolean;
|
|
20
|
+
/** Onde ele vale. */
|
|
21
|
+
where: string;
|
|
22
|
+
/** Quando ele **não** dispara — a metade que evita surpresa. */
|
|
23
|
+
caveat: string;
|
|
24
|
+
/** Observação de teclado, quando a tecla tem outro nome em algum sistema. */
|
|
25
|
+
note?: string;
|
|
26
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ShortcutCardProps {
|
|
30
|
+
shortcut: Shortcut;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A tecla desenhada como tecla. */
|
|
34
|
+
const Key = ({ children }: { children: string }): JSX.Element => (
|
|
35
|
+
<kbd className="inline-flex min-w-9 items-center justify-center rounded-md border border-b-2 border-border bg-muted px-2 py-1 font-mono text-sm font-semibold text-foreground shadow-sm">
|
|
36
|
+
{children}
|
|
37
|
+
</kbd>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/** Um atalho: o que faz, como se aperta, onde vale e onde não vale. */
|
|
41
|
+
export function ShortcutCard({ shortcut }: ShortcutCardProps): JSX.Element {
|
|
42
|
+
const { t } = useI18n();
|
|
43
|
+
const Icon = shortcut.icon;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Card className="flex flex-col gap-4 p-5 transition-colors hover:border-primary/40">
|
|
47
|
+
<div className="flex items-center gap-2">
|
|
48
|
+
<span className="rounded-lg bg-primary/10 p-2 text-primary">
|
|
49
|
+
<Icon className="h-5 w-5" />
|
|
50
|
+
</span>
|
|
51
|
+
<h2 className="text-base font-semibold">{t(shortcut.name)}</h2>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
{/* Combinações alternativas separadas por "ou": teclado não é um só, e
|
|
55
|
+
mostrar as duas evita a pergunta "por que não funciona aqui?". */}
|
|
56
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
57
|
+
{shortcut.combos.map((combo, index) => (
|
|
58
|
+
<span key={combo.join("+")} className="flex items-center gap-2">
|
|
59
|
+
{index > 0 && (
|
|
60
|
+
<span className="text-xs text-muted-foreground">
|
|
61
|
+
{t("shortcuts.or")}
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
<span className="flex items-center gap-1">
|
|
65
|
+
{combo.map((key, position) => (
|
|
66
|
+
<span
|
|
67
|
+
key={`${key}-${position}`}
|
|
68
|
+
className="flex items-center gap-1"
|
|
69
|
+
>
|
|
70
|
+
{position > 0 && !shortcut.sequence && (
|
|
71
|
+
<span className="text-muted-foreground">+</span>
|
|
72
|
+
)}
|
|
73
|
+
<Key>{key}</Key>
|
|
74
|
+
</span>
|
|
75
|
+
))}
|
|
76
|
+
</span>
|
|
77
|
+
{shortcut.sequence && (
|
|
78
|
+
<span className="text-xs text-muted-foreground">
|
|
79
|
+
{t("shortcuts.twice")}
|
|
80
|
+
</span>
|
|
81
|
+
)}
|
|
82
|
+
</span>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div className="flex flex-col gap-2 text-sm">
|
|
87
|
+
<p className="text-muted-foreground">{t(shortcut.where)}</p>
|
|
88
|
+
<Badge variant="secondary" className="w-fit font-normal">
|
|
89
|
+
{t(shortcut.caveat)}
|
|
90
|
+
</Badge>
|
|
91
|
+
{shortcut.note && (
|
|
92
|
+
<p className="text-xs text-muted-foreground">{t(shortcut.note)}</p>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
</Card>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CornerDownLeft,
|
|
5
|
+
Keyboard,
|
|
6
|
+
Maximize2,
|
|
7
|
+
PlusSquare,
|
|
8
|
+
XSquare,
|
|
9
|
+
} from "lucide-react";
|
|
10
|
+
import type { JSX } from "react";
|
|
11
|
+
|
|
12
|
+
import { useI18n } from "#core/contexts";
|
|
13
|
+
import {
|
|
14
|
+
Shortcut,
|
|
15
|
+
ShortcutCard,
|
|
16
|
+
} from "#core/features/shortcuts/components/shortcut-card";
|
|
17
|
+
import {
|
|
18
|
+
CREATE_SHORTCUT_KEYS,
|
|
19
|
+
EXPAND_SHORTCUT_LABEL,
|
|
20
|
+
} from "#core/hooks/use-shortcut";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* As teclas saem das constantes do hook, e não escritas à mão.
|
|
24
|
+
*
|
|
25
|
+
* É o que impede esta tela de virar documentação mentirosa: trocar
|
|
26
|
+
* `CREATE_SHORTCUT_KEYS` muda o atalho e muda o que está escrito aqui, no mesmo
|
|
27
|
+
* gesto. Tela de ajuda que envelhece é pior que tela de ajuda nenhuma.
|
|
28
|
+
*
|
|
29
|
+
* `Enter` e `Esc` não têm constante porque não são código nosso: o primeiro é a
|
|
30
|
+
* submissão implícita do formulário HTML, o segundo é do próprio modal.
|
|
31
|
+
*/
|
|
32
|
+
const SHORTCUTS: Shortcut[] = [
|
|
33
|
+
{
|
|
34
|
+
name: "shortcuts.create.name",
|
|
35
|
+
combos: CREATE_SHORTCUT_KEYS.map((key) => [key, key]),
|
|
36
|
+
sequence: true,
|
|
37
|
+
where: "shortcuts.create.where",
|
|
38
|
+
caveat: "shortcuts.create.caveat",
|
|
39
|
+
icon: PlusSquare,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "shortcuts.expand.name",
|
|
43
|
+
combos: [EXPAND_SHORTCUT_LABEL.split(" + ")],
|
|
44
|
+
where: "shortcuts.expand.where",
|
|
45
|
+
caveat: "shortcuts.expand.caveat",
|
|
46
|
+
note: "shortcuts.expand.note",
|
|
47
|
+
icon: Maximize2,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "shortcuts.submit.name",
|
|
51
|
+
combos: [["Enter"]],
|
|
52
|
+
where: "shortcuts.submit.where",
|
|
53
|
+
caveat: "shortcuts.submit.caveat",
|
|
54
|
+
icon: CornerDownLeft,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "shortcuts.close.name",
|
|
58
|
+
combos: [["Esc"]],
|
|
59
|
+
where: "shortcuts.close.where",
|
|
60
|
+
caveat: "shortcuts.close.caveat",
|
|
61
|
+
icon: XSquare,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Atalhos de teclado do sistema.
|
|
67
|
+
*
|
|
68
|
+
* Existe porque atalho que só aparece no `title` de um botão não é descoberto:
|
|
69
|
+
* quem não passa o mouse ali nunca fica sabendo. Aqui eles têm um lugar, e o
|
|
70
|
+
* menu tem como apontar para ele.
|
|
71
|
+
*/
|
|
72
|
+
export function ShortcutsScreen(): JSX.Element {
|
|
73
|
+
const { t } = useI18n();
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div>
|
|
77
|
+
<div className="mb-1 flex items-center gap-2">
|
|
78
|
+
<Keyboard className="h-7 w-7 text-primary" />
|
|
79
|
+
<h1 className="text-3xl font-bold">{t("shortcuts.title")}</h1>
|
|
80
|
+
</div>
|
|
81
|
+
<p className="mb-6 max-w-2xl text-sm text-muted-foreground">
|
|
82
|
+
{t("shortcuts.subtitle")}
|
|
83
|
+
</p>
|
|
84
|
+
|
|
85
|
+
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
86
|
+
{SHORTCUTS.map((shortcut) => (
|
|
87
|
+
<ShortcutCard key={shortcut.name} shortcut={shortcut} />
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<p className="mt-6 max-w-2xl text-xs text-muted-foreground">
|
|
92
|
+
{t("shortcuts.footer")}
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
KeyRound,
|
|
13
13
|
MailPlus,
|
|
14
14
|
Pencil,
|
|
15
|
-
Plus,
|
|
16
15
|
ShieldOff,
|
|
17
16
|
Trash2,
|
|
18
17
|
} from "lucide-react";
|
|
@@ -21,7 +20,14 @@ import { useMemo, useState } from "react";
|
|
|
21
20
|
|
|
22
21
|
import { anyOrTeam } from "#core/_utils/permission";
|
|
23
22
|
import { fullName } from "#core/_utils/user";
|
|
24
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
Badge,
|
|
25
|
+
Button,
|
|
26
|
+
CreateButton,
|
|
27
|
+
DataTable,
|
|
28
|
+
DataTableFeatures,
|
|
29
|
+
RowActions,
|
|
30
|
+
} from "#core/components/ui";
|
|
25
31
|
import { useAuth, useI18n } from "#core/contexts";
|
|
26
32
|
import { UserFormDialog } from "#core/features/users/components/user-form-dialog";
|
|
27
33
|
import { useUsers } from "#core/features/users/hooks/use-users";
|
|
@@ -310,10 +316,10 @@ export function UsersScreen(): JSX.Element {
|
|
|
310
316
|
filters={u.filters}
|
|
311
317
|
toolbar={
|
|
312
318
|
hasAnyPermission(...anyOrTeam("users:create")) && (
|
|
313
|
-
<
|
|
314
|
-
|
|
315
|
-
{
|
|
316
|
-
|
|
319
|
+
<CreateButton
|
|
320
|
+
label={t("users.newUser")}
|
|
321
|
+
onCreate={openCreate}
|
|
322
|
+
/>
|
|
317
323
|
)
|
|
318
324
|
}
|
|
319
325
|
/>
|