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.
@@ -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={() => setExpanded((value) => !value)}
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
- const Input = React.forwardRef<
8
- HTMLInputElement,
9
- React.InputHTMLAttributes<HTMLInputElement>
10
- >(({ className, type, ...props }, ref) => {
11
- return (
12
- <input
13
- type={type}
14
- className={cn(
15
- "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",
16
- className,
17
- )}
18
- ref={ref}
19
- {...props}
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
- <SegmentedControl
257
- value={queue.state}
258
- onValueChange={(value) => queue.setState(value as QueueJobState)}
259
- options={stateOptions}
260
- label={t("queues.state")}
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.get<QueueCounts>("/queues/counts").then((r) => r.data);
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.post(`/queues/jobs/${id}/retry`).then(() => undefined);
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.delete(`/queues/jobs/${id}`).then(() => undefined);
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 { Badge, Button, DataTable, DataTableFeatures, RowActions } from "#core/components/ui";
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
- <Button onClick={openCreate}>
314
- <Plus className="h-4 w-4" />
315
- {t("users.newUser")}
316
- </Button>
319
+ <CreateButton
320
+ label={t("users.newUser")}
321
+ onCreate={openCreate}
322
+ />
317
323
  )
318
324
  }
319
325
  />