rl-core-front 0.18.7 → 0.18.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/_services/api/axios.factory.ts +3 -0
  3. package/src/_utils/storage.ts +31 -0
  4. package/src/components/app-shell.tsx +173 -114
  5. package/src/components/ui/column-visibility-modal.tsx +11 -4
  6. package/src/components/ui/filter-sheet.tsx +9 -3
  7. package/src/contexts/auth-context.tsx +6 -1
  8. package/src/contexts/color-mode-context.tsx +29 -22
  9. package/src/contexts/i18n-context.tsx +5 -15
  10. package/src/contexts/socket-context.tsx +10 -1
  11. package/src/features/audit/components/record-audit-button.tsx +3 -1
  12. package/src/features/audit/hooks/use-audit-trail.ts +3 -1
  13. package/src/features/logs/hooks/use-request-logs.ts +3 -1
  14. package/src/features/notifications/hooks/use-notifications.ts +3 -1
  15. package/src/features/profile/profile-screen.tsx +3 -1
  16. package/src/features/queues/hooks/use-queue-jobs.ts +3 -1
  17. package/src/features/rbac/panels/catalog-panel.tsx +3 -1
  18. package/src/features/rbac/panels/groups-panel.tsx +3 -1
  19. package/src/features/rbac/panels/permissions-panel.tsx +3 -1
  20. package/src/features/rbac/panels/roles-panel.tsx +8 -4
  21. package/src/features/recovery/reset-password-screen.tsx +17 -5
  22. package/src/features/users/hooks/use-users.ts +3 -1
  23. package/src/hooks/use-column-visibility.ts +30 -18
  24. package/src/hooks/use-document-title.ts +49 -18
  25. package/src/hooks/use-filter-schema.ts +3 -1
  26. package/src/hooks/use-filters.ts +36 -10
  27. package/src/hooks/use-is-hydrated.ts +21 -0
  28. package/src/hooks/use-job-progress.ts +11 -4
  29. package/src/hooks/use-list-query.ts +9 -4
  30. package/src/hooks/use-stored-value.ts +35 -0
  31. package/src/hooks/use-tab-state.ts +62 -28
  32. package/src/hooks/use-table-view.ts +15 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.18.7",
3
+ "version": "0.18.8",
4
4
  "description": "Telas e componentes Next.js do core: login com 2FA, usuários, RBAC, auditoria, logs e listagens com filtro dinâmico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -57,6 +57,9 @@ export class AxiosFactory {
57
57
  // middleware ainda vê os cookies (inválidos, mas presentes) e manda de volta
58
58
  // pro dashboard, gerando loop entre /dashboard e /login?reason=expired.
59
59
  await api.post("/auth/logout").catch(() => {});
60
+ // Fora de componente não há `router`, e a recarga é o que se quer:
61
+ // a sessão venceu e o que está em memória não vale mais.
62
+ // eslint-disable-next-line @next/next/no-location-assign-relative-destination
60
63
  window.location.href = "/login?reason=expired";
61
64
  }
62
65
  }
@@ -18,6 +18,15 @@
18
18
  */
19
19
  const memory = new Map<string, string>();
20
20
 
21
+ /** Quem quer saber que uma chave mudou. */
22
+ const listeners = new Map<string, Set<() => void>>();
23
+
24
+ const notify = (key: string): void => {
25
+ for (const listener of listeners.get(key) ?? []) {
26
+ listener();
27
+ }
28
+ };
29
+
21
30
  /**
22
31
  * Espelho em memória para quando o navegador recusa o armazenamento.
23
32
  *
@@ -48,6 +57,7 @@ export const safeStorage = {
48
57
  // Cota estourada ou armazenamento negado: guarda na sessão e segue.
49
58
  memory.set(key, value);
50
59
  }
60
+ notify(key);
51
61
  },
52
62
 
53
63
  remove(key: string): void {
@@ -60,5 +70,26 @@ export const safeStorage = {
60
70
  } catch {
61
71
  // Já saiu do espelho em memória; não há mais nada a fazer.
62
72
  }
73
+ notify(key);
74
+ },
75
+
76
+ /**
77
+ * Avisa quando uma chave muda, para quem lê por `useSyncExternalStore`.
78
+ *
79
+ * É o que permite a preferência salva ser lida direto no render, em vez de
80
+ * copiada para um `useState` dentro de um efeito: quem escreve é este mesmo
81
+ * módulo, então a notificação sai daqui.
82
+ */
83
+ subscribe(key: string, listener: () => void): () => void {
84
+ const current = listeners.get(key) ?? new Set<() => void>();
85
+ current.add(listener);
86
+ listeners.set(key, current);
87
+
88
+ return () => {
89
+ current.delete(listener);
90
+ if (current.size === 0) {
91
+ listeners.delete(key);
92
+ }
93
+ };
63
94
  },
64
95
  };
@@ -212,6 +212,164 @@ export interface AppShellProps {
212
212
  forbidden?: React.ReactNode;
213
213
  }
214
214
 
215
+ /**
216
+ * O que os blocos do menu precisam do shell para se desenhar.
217
+ *
218
+ * Eles moram aqui fora, e não dentro do `AppShell`, porque componente criado
219
+ * no render é um tipo novo a cada render: o React desmontava e remontava o
220
+ * menu inteiro a cada clique, perdendo foco e a rolagem da lista.
221
+ */
222
+ interface NavRender {
223
+ pathname: string;
224
+ t: (key: string) => string;
225
+ go: (href: string) => void;
226
+ isGroupOpen: (group: NavGroup) => boolean;
227
+ toggleGroup: (group: NavGroup) => void;
228
+ }
229
+
230
+ const NavLink = ({
231
+ item,
232
+ mini,
233
+ nested,
234
+ parentKey,
235
+ nav,
236
+ }: {
237
+ item: NavItem;
238
+ mini: boolean;
239
+ nested?: boolean;
240
+ /**
241
+ * Grupo a que o item pertence, só para o tooltip do modo recolhido.
242
+ *
243
+ * Ali os filhos aparecem soltos, sem o pai: "Catálogos" sozinho, ao lado de
244
+ * "Tarefas", não diria de quê. O caminho no tooltip resolve isso sem
245
+ * obrigar o rótulo a repetir o nome da feature no menu aberto.
246
+ */
247
+ parentKey?: string;
248
+ nav: NavRender;
249
+ }): JSX.Element => {
250
+ const { pathname, t, go } = nav;
251
+ const active = pathname === item.href;
252
+ const Icon = item.icon;
253
+ const tooltip = parentKey
254
+ ? `${t(parentKey)} › ${t(item.key)}`
255
+ : t(item.key);
256
+ return (
257
+ <button
258
+ onClick={() => go(item.href)}
259
+ title={mini ? tooltip : undefined}
260
+ className={cn(
261
+ "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
262
+ mini && "justify-center px-2",
263
+ nested && !mini && "pl-10",
264
+ active
265
+ ? "bg-primary/10 text-primary"
266
+ : "text-muted-foreground hover:bg-accent hover:text-foreground",
267
+ )}
268
+ >
269
+ <Icon className="h-5 w-5 shrink-0" />
270
+ {!mini && <span className="truncate">{t(item.key)}</span>}
271
+ </button>
272
+ );
273
+ };
274
+
275
+ /**
276
+ * Um grupo do menu — o pai que abre e fecha, e os filhos indentados.
277
+ *
278
+ * Com a barra recolhida não há onde desenhar seta nem rótulo, então os
279
+ * filhos aparecem soltos, separados do resto; é o que o "Administração" já
280
+ * fazia, e agora vale para qualquer grupo.
281
+ */
282
+ const NavGroupBlock = ({
283
+ group,
284
+ mini,
285
+ nav,
286
+ }: {
287
+ group: NavGroup;
288
+ mini: boolean;
289
+ nav: NavRender;
290
+ }): JSX.Element => {
291
+ const { t, isGroupOpen, toggleGroup } = nav;
292
+ const Icon = group.icon;
293
+ const open = isGroupOpen(group);
294
+ if (mini) {
295
+ return (
296
+ <>
297
+ <Separator className="my-2" />
298
+ {group.children.map((child) => (
299
+ <NavLink
300
+ key={child.href}
301
+ item={child}
302
+ mini
303
+ parentKey={group.key}
304
+ nav={nav}
305
+ />
306
+ ))}
307
+ </>
308
+ );
309
+ }
310
+ return (
311
+ <div className="mt-2">
312
+ <button
313
+ onClick={() => toggleGroup(group)}
314
+ className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
315
+ >
316
+ <Icon className="h-5 w-5 shrink-0" />
317
+ <span className="flex-1 text-left">{t(group.key)}</span>
318
+ <ChevronRight
319
+ className={cn("h-4 w-4 transition-transform", open && "rotate-90")}
320
+ />
321
+ </button>
322
+ {open && (
323
+ <div className="mt-1 flex flex-col gap-1">
324
+ {group.children.map((child) => (
325
+ <NavLink
326
+ key={child.href}
327
+ item={child}
328
+ mini={false}
329
+ nested
330
+ nav={nav}
331
+ />
332
+ ))}
333
+ </div>
334
+ )}
335
+ </div>
336
+ );
337
+ };
338
+
339
+ const NavContent = ({
340
+ mini,
341
+ nav,
342
+ projectEntries,
343
+ adminChildren,
344
+ adminGroup,
345
+ }: {
346
+ mini: boolean;
347
+ nav: NavRender;
348
+ projectEntries: NavEntry[];
349
+ adminChildren: NavItem[];
350
+ adminGroup: NavGroup;
351
+ }): JSX.Element => (
352
+ <div className="flex flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden p-3">
353
+ <NavLink item={DASHBOARD} mini={mini} nav={nav} />
354
+
355
+ {projectEntries.map((entry) =>
356
+ isGroup(entry) ? (
357
+ <NavGroupBlock key={entry.key} group={entry} mini={mini} nav={nav} />
358
+ ) : (
359
+ <NavLink key={entry.href} item={entry} mini={mini} nav={nav} />
360
+ ),
361
+ )}
362
+
363
+ {adminChildren.length > 0 && (
364
+ <NavGroupBlock group={adminGroup} mini={mini} nav={nav} />
365
+ )}
366
+
367
+ <div className="mt-1">
368
+ <NavGroupBlock group={SETTINGS_GROUP} mini={mini} nav={nav} />
369
+ </div>
370
+ </div>
371
+ );
372
+
215
373
  export function AppShell({
216
374
  children,
217
375
  navItems,
@@ -364,118 +522,7 @@ export function AppShell({
364
522
  setMobileOpen(false);
365
523
  };
366
524
 
367
- const NavLink = ({
368
- item,
369
- mini,
370
- nested,
371
- parentKey,
372
- }: {
373
- item: NavItem;
374
- mini: boolean;
375
- nested?: boolean;
376
- /**
377
- * Grupo a que o item pertence, só para o tooltip do modo recolhido.
378
- *
379
- * Ali os filhos aparecem soltos, sem o pai: "Catálogos" sozinho, ao lado de
380
- * "Tarefas", não diria de quê. O caminho no tooltip resolve isso sem
381
- * obrigar o rótulo a repetir o nome da feature no menu aberto.
382
- */
383
- parentKey?: string;
384
- }): JSX.Element => {
385
- const active = pathname === item.href;
386
- const Icon = item.icon;
387
- const tooltip = parentKey
388
- ? `${t(parentKey)} › ${t(item.key)}`
389
- : t(item.key);
390
- return (
391
- <button
392
- onClick={() => go(item.href)}
393
- title={mini ? tooltip : undefined}
394
- className={cn(
395
- "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
396
- mini && "justify-center px-2",
397
- nested && !mini && "pl-10",
398
- active
399
- ? "bg-primary/10 text-primary"
400
- : "text-muted-foreground hover:bg-accent hover:text-foreground",
401
- )}
402
- >
403
- <Icon className="h-5 w-5 shrink-0" />
404
- {!mini && <span className="truncate">{t(item.key)}</span>}
405
- </button>
406
- );
407
- };
408
-
409
- /**
410
- * Um grupo do menu — o pai que abre e fecha, e os filhos indentados.
411
- *
412
- * Com a barra recolhida não há onde desenhar seta nem rótulo, então os
413
- * filhos aparecem soltos, separados do resto; é o que o "Administração" já
414
- * fazia, e agora vale para qualquer grupo.
415
- */
416
- const NavGroupBlock = ({
417
- group,
418
- mini,
419
- }: {
420
- group: NavGroup;
421
- mini: boolean;
422
- }): JSX.Element => {
423
- const Icon = group.icon;
424
- const open = isGroupOpen(group);
425
- if (mini) {
426
- return (
427
- <>
428
- <Separator className="my-2" />
429
- {group.children.map((child) => (
430
- <NavLink key={child.href} item={child} mini parentKey={group.key} />
431
- ))}
432
- </>
433
- );
434
- }
435
- return (
436
- <div className="mt-2">
437
- <button
438
- onClick={() => toggleGroup(group)}
439
- className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
440
- >
441
- <Icon className="h-5 w-5 shrink-0" />
442
- <span className="flex-1 text-left">{t(group.key)}</span>
443
- <ChevronRight
444
- className={cn("h-4 w-4 transition-transform", open && "rotate-90")}
445
- />
446
- </button>
447
- {open && (
448
- <div className="mt-1 flex flex-col gap-1">
449
- {group.children.map((child) => (
450
- <NavLink key={child.href} item={child} mini={false} nested />
451
- ))}
452
- </div>
453
- )}
454
- </div>
455
- );
456
- };
457
-
458
- const NavContent = ({ mini }: { mini: boolean }): JSX.Element => (
459
- <div className="flex flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden p-3">
460
- <NavLink item={DASHBOARD} mini={mini} />
461
-
462
- {projectEntries.map((entry) =>
463
- isGroup(entry) ? (
464
- <NavGroupBlock key={entry.key} group={entry} mini={mini} />
465
- ) : (
466
- <NavLink key={entry.href} item={entry} mini={mini} />
467
- ),
468
- )}
469
-
470
- {adminChildren.length > 0 && (
471
- <NavGroupBlock group={adminGroup} mini={mini} />
472
- )}
473
-
474
- <div className="mt-1">
475
- <NavGroupBlock group={SETTINGS_GROUP} mini={mini} />
476
- </div>
477
- </div>
478
- );
525
+ const nav: NavRender = { pathname, t, go, isGroupOpen, toggleGroup };
479
526
 
480
527
  return (
481
528
  <div className="min-h-screen bg-background">
@@ -557,7 +604,13 @@ export function AppShell({
557
604
  collapsed ? "w-[76px]" : "w-64",
558
605
  )}
559
606
  >
560
- <NavContent mini={collapsed} />
607
+ <NavContent
608
+ mini={collapsed}
609
+ nav={nav}
610
+ projectEntries={projectEntries}
611
+ adminChildren={adminChildren}
612
+ adminGroup={adminGroup}
613
+ />
561
614
  </aside>
562
615
 
563
616
  {/* Mobile sidebar */}
@@ -572,7 +625,13 @@ export function AppShell({
572
625
  {brand.mark}
573
626
  <span className="text-lg font-extrabold">{brand.name}</span>
574
627
  </div>
575
- <NavContent mini={false} />
628
+ <NavContent
629
+ mini={false}
630
+ nav={nav}
631
+ projectEntries={projectEntries}
632
+ adminChildren={adminChildren}
633
+ adminGroup={adminGroup}
634
+ />
576
635
  </aside>
577
636
  </div>
578
637
  )}
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import type { JSX } from "react";
4
- import { useEffect, useState } from "react";
4
+ import { useState } from "react";
5
5
 
6
6
  import { Button } from "#core/components/ui/button";
7
7
  import {
@@ -37,10 +37,17 @@ export function ColumnVisibilityModal({
37
37
  }: Props): JSX.Element {
38
38
  const { t } = useI18n();
39
39
  const [draftHidden, setDraftHidden] = useState<string[]>(hiddenColumns);
40
+ const [wasOpen, setWasOpen] = useState(open);
40
41
 
41
- useEffect(() => {
42
- if (open) {setDraftHidden(hiddenColumns);}
43
- }, [open, hiddenColumns]);
42
+ // Abrir recomeça o rascunho do que a tabela mostra hoje. O ajuste é no
43
+ // render, e não num efeito: assim o modal já nasce com as colunas certas,
44
+ // sem o quadro intermediário com a escolha da vez anterior.
45
+ if (open !== wasOpen) {
46
+ setWasOpen(open);
47
+ if (open) {
48
+ setDraftHidden(hiddenColumns);
49
+ }
50
+ }
44
51
 
45
52
  const isVisible = (id: string): boolean => !draftHidden.includes(id);
46
53
  const visibleCount = columns.length - draftHidden.length;
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import type { JSX } from "react";
4
- import { useEffect, useState } from "react";
4
+ import { useState } from "react";
5
5
 
6
6
  import type { AdvancedGroup } from "#core/_utils/advanced-filter";
7
7
  import { countConditions, initialTree } from "#core/_utils/advanced-filter";
@@ -77,13 +77,19 @@ export function FilterSheet({
77
77
  const [draftTree, setDraftTree] = useState<AdvancedGroup | null>(tree);
78
78
  const [draftMode, setDraftMode] = useState<FilterMode>(mode);
79
79
 
80
- useEffect(() => {
80
+ const [wasOpen, setWasOpen] = useState(open);
81
+
82
+ // Abrir recomeça os rascunhos do filtro que está valendo. O ajuste é no
83
+ // render, e não num efeito: o painel já nasce com o filtro atual, sem o
84
+ // quadro intermediário com o que foi digitado da última vez.
85
+ if (open !== wasOpen) {
86
+ setWasOpen(open);
81
87
  if (open) {
82
88
  setDraft(values);
83
89
  setDraftTree(tree);
84
90
  setDraftMode(mode);
85
91
  }
86
- }, [open, values, tree, mode]);
92
+ }
87
93
 
88
94
  const setField = (field: string, value: FilterDraftValue): void => {
89
95
  setDraft((current) => ({ ...current, [field]: value }));
@@ -43,6 +43,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }): JSX.E
43
43
  await authService.logout();
44
44
  } finally {
45
45
  setUser(null);
46
+ // Recarga de verdade, e não `router.push`: sair tem que descartar o que
47
+ // ficou em memória — perfil, permissões e o socket da sessão anterior.
48
+ // eslint-disable-next-line @next/next/no-location-assign-relative-destination
46
49
  if (typeof window !== "undefined") {window.location.href = "/login";}
47
50
  }
48
51
  }, []);
@@ -63,7 +66,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }): JSX.E
63
66
  );
64
67
 
65
68
  useEffect(() => {
66
- void refreshProfile();
69
+ void (async (): Promise<void> => {
70
+ await refreshProfile();
71
+ })();
67
72
  }, [refreshProfile]);
68
73
 
69
74
  return (
@@ -7,10 +7,12 @@ import {
7
7
  useContext,
8
8
  useEffect,
9
9
  useMemo,
10
- useState,
10
+ useSyncExternalStore,
11
11
  } from "react";
12
12
 
13
13
  import { safeStorage } from "#core/_utils/storage";
14
+ import { useIsHydrated } from "#core/hooks/use-is-hydrated";
15
+ import { useStoredValue } from "#core/hooks/use-stored-value";
14
16
 
15
17
  export const STORAGE_KEY = "color-mode";
16
18
 
@@ -35,6 +37,17 @@ function applyClass(mode: Mode): void {
35
37
  document.documentElement.classList.toggle("dark", mode === "dark");
36
38
  }
37
39
 
40
+ /** O tema do sistema é de fora do React: avisa quando o usuário o troca. */
41
+ function subscribeSystemMode(onChange: () => void): () => void {
42
+ if (typeof window === "undefined" || !window.matchMedia) {
43
+ return () => {};
44
+ }
45
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
46
+ mq.addEventListener("change", onChange);
47
+ return () => mq.removeEventListener("change", onChange);
48
+ }
49
+
50
+
38
51
  export interface ColorModeContextType {
39
52
  mode: Mode;
40
53
  preference: ColorPreference;
@@ -47,27 +60,21 @@ const ColorModeContext = createContext<ColorModeContextType | undefined>(
47
60
  );
48
61
 
49
62
  export function ColorModeProvider({ children }: { children: React.ReactNode }): JSX.Element {
50
- const [preference, setPreferenceState] =
51
- useState<ColorPreference>(DEFAULT_PREFERENCE);
52
- const [sysMode, setSysMode] = useState<Mode>("light");
53
- // Antes de ler o localStorage não se sabe o tema real. O script anti-flash do
54
- // layout já pôs a classe certa no <html>; aplicar classe antes disso só
55
- // desfaria o trabalho dele e causava piscada de claro→escuro.
56
- const [resolved, setResolved] = useState(false);
57
-
58
- useEffect(() => {
59
- const saved = safeStorage.get(STORAGE_KEY);
60
- if (isPreference(saved)) {
61
- setPreferenceState(saved);
62
- }
63
- setSysMode(systemMode());
64
- setResolved(true);
63
+ const saved = useStoredValue(STORAGE_KEY);
64
+ const preference: ColorPreference = isPreference(saved)
65
+ ? saved
66
+ : DEFAULT_PREFERENCE;
67
+
68
+ const sysMode = useSyncExternalStore<Mode>(
69
+ subscribeSystemMode,
70
+ systemMode,
71
+ () => "light",
72
+ );
65
73
 
66
- const mq = window.matchMedia("(prefers-color-scheme: dark)");
67
- const onChange = (): void => setSysMode(systemMode());
68
- mq.addEventListener("change", onChange);
69
- return () => mq.removeEventListener("change", onChange);
70
- }, []);
74
+ // Antes de hidratar não se sabe o tema real. O script anti-flash do layout já
75
+ // pôs a classe certa no <html>; aplicar classe antes disso só desfaria o
76
+ // trabalho dele e causava piscada de claro→escuro.
77
+ const resolved = useIsHydrated();
71
78
 
72
79
  const mode: Mode = preference === "system" ? sysMode : preference;
73
80
 
@@ -78,7 +85,7 @@ export function ColorModeProvider({ children }: { children: React.ReactNode }):
78
85
  }, [mode, resolved]);
79
86
 
80
87
  const setPreference = useCallback((p: ColorPreference) => {
81
- setPreferenceState(p);
88
+ // Escrever avisa quem lê a chave — inclusive este provider.
82
89
  safeStorage.set(STORAGE_KEY, p);
83
90
  }, []);
84
91
 
@@ -1,16 +1,10 @@
1
1
  "use client";
2
2
 
3
3
  import type { JSX } from "react";
4
- import {
5
- createContext,
6
- useCallback,
7
- useContext,
8
- useEffect,
9
- useMemo,
10
- useState,
11
- } from "react";
4
+ import { createContext, useCallback, useContext, useMemo } from "react";
12
5
 
13
6
  import { safeStorage } from "#core/_utils/storage";
7
+ import { useStoredValue } from "#core/hooks/use-stored-value";
14
8
  import { en } from "#core/i18n/messages/en";
15
9
  import { pt } from "#core/i18n/messages/pt";
16
10
 
@@ -88,7 +82,8 @@ export function I18nProvider({
88
82
  children,
89
83
  messages,
90
84
  }: I18nProviderProps): JSX.Element {
91
- const [locale, setLocaleState] = useState<Locale>("pt");
85
+ const saved = useStoredValue(STORAGE_KEY);
86
+ const locale: Locale = saved === "pt" || saved === "en" ? saved : "pt";
92
87
 
93
88
  // Mescla uma vez por dicionário, não a cada tradução: `t` roda em todo render
94
89
  // de toda tela que usa texto.
@@ -100,13 +95,8 @@ export function I18nProvider({
100
95
  [messages],
101
96
  );
102
97
 
103
- useEffect(() => {
104
- const saved = (safeStorage.get(STORAGE_KEY) as Locale) || null;
105
- if (saved === "pt" || saved === "en") {setLocaleState(saved);}
106
- }, []);
107
-
108
98
  const setLocale = useCallback((l: Locale) => {
109
- setLocaleState(l);
99
+ // Escrever avisa quem lê a chave — inclusive este provider.
110
100
  safeStorage.set(STORAGE_KEY, l);
111
101
  document.cookie = `${STORAGE_KEY}=${l}; path=/; max-age=31536000`;
112
102
  }, []);
@@ -68,6 +68,7 @@ export function SocketProvider({
68
68
  }): JSX.Element {
69
69
  const { user, refreshProfile } = useAuth();
70
70
  const [status, setStatus] = useState<SocketStatus>("disconnected");
71
+ const [lastUserId, setLastUserId] = useState<string | undefined>(undefined);
71
72
  const [reconnectCount, setReconnectCount] = useState(0);
72
73
  const socketRef = useRef<Socket | null>(null);
73
74
 
@@ -85,12 +86,20 @@ export function SocketProvider({
85
86
  // socket.io entraria em ciclo de retentativa.
86
87
  const userId = user?.id;
87
88
 
89
+ // Quem manda no estado é a sessão: com um usuário novo há uma conexão nova a
90
+ // abrir, e o "conectando" é consequência disso — não do efeito, que só vai
91
+ // rodar depois da pintura e deixaria a barra dizendo "desconectado" por um
92
+ // quadro.
93
+ if (userId !== lastUserId) {
94
+ setLastUserId(userId);
95
+ setStatus(userId ? "connecting" : "disconnected");
96
+ }
97
+
88
98
  useEffect(() => {
89
99
  if (!userId) {
90
100
  return;
91
101
  }
92
102
 
93
- setStatus("connecting");
94
103
  const socket = io(`${SOCKET_URL}${NAMESPACE}`, {
95
104
  withCredentials: true,
96
105
  transports: ["websocket"],
@@ -89,7 +89,9 @@ export function RecordAuditButton({
89
89
  // clica no histórico.
90
90
  useEffect(() => {
91
91
  if (open) {
92
- void fetch();
92
+ void (async (): Promise<void> => {
93
+ await fetch();
94
+ })();
93
95
  }
94
96
  }, [open, fetch]);
95
97
 
@@ -57,7 +57,9 @@ export function useAuditTrail(): UseAuditTrailResult {
57
57
  if (!list.ready) {
58
58
  return;
59
59
  }
60
- void fetch();
60
+ void (async (): Promise<void> => {
61
+ await fetch();
62
+ })();
61
63
  }, [fetch, list.ready]);
62
64
 
63
65
  return {
@@ -75,7 +75,9 @@ export function useRequestLogs(): UseRequestLogsResult {
75
75
  if (!list.ready) {
76
76
  return;
77
77
  }
78
- void fetch();
78
+ void (async (): Promise<void> => {
79
+ await fetch();
80
+ })();
79
81
  }, [fetch, list.ready]);
80
82
 
81
83
  return {
@@ -44,7 +44,9 @@ export function useNotifications(): UseNotificationsResult {
44
44
  // Carga inicial e, de novo, a cada reconexão: enquanto o socket esteve fora
45
45
  // chegaram notificações que ninguém empurrou para esta aba.
46
46
  useEffect(() => {
47
- void fetch();
47
+ void (async (): Promise<void> => {
48
+ await fetch();
49
+ })();
48
50
  }, [fetch, reconnectCount]);
49
51
 
50
52
  // Sem toast aqui: o badge do sino já é o aviso, e disparar os dois pelo mesmo
@@ -83,7 +83,9 @@ export function ProfileScreen(): JSX.Element {
83
83
  }
84
84
  };
85
85
  useEffect(() => {
86
- void loadMe();
86
+ void (async (): Promise<void> => {
87
+ await loadMe();
88
+ })();
87
89
  // eslint-disable-next-line react-hooks/exhaustive-deps
88
90
  }, []);
89
91