rl-core-front 0.19.2 → 0.19.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.19.2",
3
+ "version": "0.19.5",
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",
@@ -1581,6 +1581,11 @@ export interface components {
1581
1581
  * @example /usuarios
1582
1582
  */
1583
1583
  route?: string | null;
1584
+ /**
1585
+ * @description Módulo de origem, que agrupa o sino. Nulo quando veio do próprio sistema.
1586
+ * @example Contas
1587
+ */
1588
+ source?: string | null;
1584
1589
  read: boolean;
1585
1590
  /** Format: date-time */
1586
1591
  createdAt: string;
@@ -1605,7 +1610,7 @@ export interface components {
1605
1610
  body: string;
1606
1611
  type: components["schemas"]["NotificationType"];
1607
1612
  targets: components["schemas"]["NotificationTargetCommand"][];
1608
- /** @description Dado extra. `route` é usado pelo cliente para navegar. */
1613
+ /** @description Dado extra. `route` é usado pelo cliente para navegar; `source` é o módulo de origem, que agrupa o sino. */
1609
1614
  payload?: Record<string, never>;
1610
1615
  };
1611
1616
  RequestExportCommand: {
@@ -25,6 +25,63 @@ export function formatDateTime(value: string | Date, locale: string): string {
25
25
  }).format(date);
26
26
  }
27
27
 
28
+ const MINUTE_MS = 60_000;
29
+ const HOUR_MS = 60 * MINUTE_MS;
30
+
31
+ const sameDay = (a: Date, b: Date): boolean =>
32
+ a.getFullYear() === b.getFullYear() &&
33
+ a.getMonth() === b.getMonth() &&
34
+ a.getDate() === b.getDate();
35
+
36
+ /**
37
+ * Há quanto tempo, do jeito que uma caixa de entrada mostra: "agora",
38
+ * "há 6 min", "há 2 h" no mesmo dia, "ontem, 18:02" e, antes disso, só a data
39
+ * curta — o que importa num aviso de três dias atrás é *qual* dia, não a hora.
40
+ *
41
+ * `now` é parâmetro para o teste fixar o relógio; em uso normal fica o padrão.
42
+ */
43
+ export function formatRelativeTime(
44
+ value: string | Date,
45
+ locale: string,
46
+ now: Date = new Date(),
47
+ ): string {
48
+ const date = value instanceof Date ? value : new Date(value);
49
+ if (Number.isNaN(date.getTime())) {
50
+ return String(value);
51
+ }
52
+
53
+ const relative = new Intl.RelativeTimeFormat(locale, {
54
+ numeric: "auto",
55
+ style: "short",
56
+ });
57
+ const elapsed = now.getTime() - date.getTime();
58
+
59
+ if (elapsed < MINUTE_MS) {
60
+ return relative.format(0, "second");
61
+ }
62
+ if (elapsed < HOUR_MS) {
63
+ return relative.format(-Math.floor(elapsed / MINUTE_MS), "minute");
64
+ }
65
+ if (sameDay(date, now)) {
66
+ return relative.format(-Math.floor(elapsed / HOUR_MS), "hour");
67
+ }
68
+
69
+ const yesterday = new Date(now);
70
+ yesterday.setDate(now.getDate() - 1);
71
+ if (sameDay(date, yesterday)) {
72
+ const time = new Intl.DateTimeFormat(locale, { timeStyle: "short" }).format(
73
+ date,
74
+ );
75
+ return `${relative.format(-1, "day")}, ${time}`;
76
+ }
77
+
78
+ return new Intl.DateTimeFormat(locale, {
79
+ day: "2-digit",
80
+ month: "2-digit",
81
+ ...(date.getFullYear() !== now.getFullYear() && { year: "numeric" }),
82
+ }).format(date);
83
+ }
84
+
28
85
  /** Aplica máscara de telefone BR enquanto digita: (00) 00000-0000 */
29
86
  export function maskPhoneBR(value: string): string {
30
87
  const d = value.replace(/\D/g, "").slice(0, 11);
@@ -0,0 +1,25 @@
1
+ /**
2
+ * O texto pronto para comparar: minúsculo e sem acento.
3
+ *
4
+ * `"Óleo"` vira `"oleo"` — é o que faz digitar `oleo` achar "Óleo do motor"
5
+ * num select. O banco já compara assim (collation *accent-insensitive*); no
6
+ * navegador, `includes` compara byte a byte, e o acento separava o que a
7
+ * pessoa digitou do que estava cadastrado.
8
+ *
9
+ * `NFD` separa a letra do acento, e a faixa `\p{Diacritic}` é o acento —
10
+ * removê-la deixa a letra. Vale para qualquer alfabeto latino, não só o
11
+ * nosso.
12
+ */
13
+ export const normalizeText = (text: string): string =>
14
+ text
15
+ .normalize("NFD")
16
+ .replace(/\p{Diacritic}/gu, "")
17
+ .toLowerCase();
18
+
19
+ /** `text` contém `term`, ignorando acento e caixa. Termo vazio casa com tudo. */
20
+ export const matchesText = (text: string, term: string): boolean =>
21
+ normalizeText(text).includes(normalizeText(term.trim()));
22
+
23
+ /** Os dois são o mesmo nome, ignorando acento, caixa e espaços nas pontas. */
24
+ export const sameText = (a: string, b: string): boolean =>
25
+ normalizeText(a.trim()) === normalizeText(b.trim());
@@ -4,6 +4,7 @@ import { Check, ChevronDown, Plus, X } from "lucide-react";
4
4
  import type { JSX } from "react";
5
5
  import { useEffect, useId, useMemo, useRef, useState } from "react";
6
6
 
7
+ import { matchesText, sameText } from "#core/_utils/text";
7
8
  import { Badge } from "#core/components/ui/badge";
8
9
  import { Button } from "#core/components/ui/button";
9
10
  import {
@@ -46,8 +47,17 @@ export interface ComboboxProps {
46
47
  * sabe se isso vira POST ou só um valor no formulário.
47
48
  */
48
49
  creatable?: boolean;
49
- /** Chamado com o texto digitado, já sem espaços nas pontas. */
50
- onCreate?: (text: string) => void;
50
+ /**
51
+ * Chamado com o texto digitado, já sem espaços nas pontas.
52
+ *
53
+ * Pode devolver o id do que criou — direto ou em promessa. Quando devolve,
54
+ * o item novo fica selecionado (no múltiplo, marcado junto aos outros):
55
+ * quem digitou "Kronos" e mandou criar quer a despesa na Kronos, não a
56
+ * lista fechada e o campo vazio. Quem não devolve nada, como o campo que
57
+ * só guarda o texto no formulário, continua como antes. Erro é de quem
58
+ * cria — o toast é dele; aqui só não se seleciona nada.
59
+ */
60
+ onCreate?: (text: string) => CreateResult | Promise<CreateResult>;
51
61
  /** Texto da linha de criação. Ausente, `Criar "{text}"`. */
52
62
  createLabel?: (text: string) => string;
53
63
  /**
@@ -64,6 +74,9 @@ export interface ComboboxProps {
64
74
  idleCloseMs?: number;
65
75
  }
66
76
 
77
+ /** O que o `onCreate` devolve: o id do item criado, ou nada. */
78
+ export type CreateResult = string | null | undefined | void;
79
+
67
80
  /** Id da linha de criação na lista apontável — nenhuma opção real o usa. */
68
81
  const CREATE_ID = "\u0000create";
69
82
 
@@ -160,23 +173,24 @@ export function Combobox({
160
173
  [options, value],
161
174
  );
162
175
 
176
+ /*
177
+ Sem acento e sem caixa, dos dois lados: digitar "oleo" acha "Óleo do
178
+ motor". É como o banco já compara — e o select, que filtra no navegador,
179
+ não podia ser mais exigente que ele.
180
+ */
163
181
  const termo = search.trim();
164
182
  const filtradas = useMemo(
165
- () =>
166
- options.filter((option) =>
167
- option.name.toLowerCase().includes(termo.toLowerCase()),
168
- ),
183
+ () => options.filter((option) => matchesText(option.name, termo)),
169
184
  [options, termo],
170
185
  );
171
186
 
172
187
  /**
173
- * A linha "Criar" só quando há texto e nenhuma opção tem exatamente esse
174
- * nome — com "Kronos" na lista, digitar "kronos" deve escolher, não criar.
188
+ * A linha "Criar" só quando há texto e nenhuma opção tem esse nome — com
189
+ * "Kronos" na lista, digitar "kronos" deve escolher, não criar; e com
190
+ * "Óleo", digitar "oleo" também.
175
191
  */
176
192
  const podeCriar =
177
- creatable &&
178
- termo !== "" &&
179
- !options.some((option) => option.name.toLowerCase() === termo.toLowerCase());
193
+ creatable && termo !== "" && !options.some((option) => sameText(option.name, termo));
180
194
 
181
195
  /** O que se aponta pelo teclado: as opções filtradas e, no fim, a de criar. */
182
196
  const visiveis = useMemo(
@@ -184,6 +198,26 @@ export function Combobox({
184
198
  [filtradas, podeCriar, termo],
185
199
  );
186
200
 
201
+ /*
202
+ * O valor do momento em que a criação termina, e não o do clique: no
203
+ * múltiplo, quem marcou outro item enquanto o POST corria não pode perder
204
+ * a marca quando o novo chega.
205
+ */
206
+ const valorAtual = useRef(value);
207
+ useEffect(() => {
208
+ valorAtual.current = value;
209
+ }, [value]);
210
+
211
+ const criar = (texto: string): void => {
212
+ Promise.resolve(onCreate?.(texto))
213
+ .then((novo) => {
214
+ if (novo) {
215
+ onChange(multiple ? [...valorAtual.current, novo] : [novo]);
216
+ }
217
+ })
218
+ .catch(() => undefined);
219
+ };
220
+
187
221
  /** A posição do apontado; sem ele — ou fora da busca — é a do primeiro. */
188
222
  const ativo = Math.max(
189
223
  visiveis.findIndex((option) => option.id === apontado),
@@ -192,7 +226,7 @@ export function Combobox({
192
226
 
193
227
  const alternar = (id: string): void => {
194
228
  if (id === CREATE_ID) {
195
- onCreate?.(termo);
229
+ criar(termo);
196
230
  setSearch("");
197
231
  if (!multiple) {
198
232
  setOpen(false);
@@ -32,6 +32,7 @@ export * from "./password-requirements";
32
32
  export * from "./period-nav";
33
33
  export * from "./popover";
34
34
  export * from "./row-actions";
35
+ export * from "./searchable-select";
35
36
  export * from "./segmented-control";
36
37
  export * from "./select";
37
38
  export * from "./separator";
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ import type { JSX } from "react";
4
+
5
+ import { Combobox, type CreateResult } from "#core/components/ui/combobox";
6
+ import type { SelectOption } from "#core/components/ui/simple-select";
7
+
8
+ export interface SearchableSelectProps {
9
+ value: string;
10
+ onChange: (value: string) => void;
11
+ /** O mesmo formato do `SimpleSelect` — a troca entre os dois é o nome. */
12
+ options: SelectOption[];
13
+ placeholder?: string;
14
+ searchPlaceholder?: string;
15
+ /** Mostra o X que devolve o campo ao vazio. Só onde o vazio é resposta. */
16
+ clearable?: boolean;
17
+ disabled?: boolean;
18
+ /**
19
+ * Deixa criar o que foi digitado quando nenhuma opção tem esse nome.
20
+ *
21
+ * É a flag por campo: o select de categoria da despesa liga, o filtro do
22
+ * extrato não. Quem cria é o `onCreate`, que devolve o id do registro
23
+ * novo — e o campo fica com ele selecionado, como se a pessoa tivesse
24
+ * escolhido da lista.
25
+ */
26
+ creatable?: boolean;
27
+ onCreate?: (text: string) => CreateResult | Promise<CreateResult>;
28
+ /** Texto da linha de criação. Ausente, `Criar "{text}"`. */
29
+ createLabel?: (text: string) => string;
30
+ }
31
+
32
+ /**
33
+ * Escolha de **um** valor, com busca — o `Combobox` no formato de select.
34
+ *
35
+ * O `Combobox` fala em lista (`string[]`), porque também serve à escolha
36
+ * múltipla; aqui dentro fica a conversão que todo campo de um valor faria
37
+ * igual, e a tela continua passando `value`/`onChange` de texto como já
38
+ * fazia com o `SimpleSelect`.
39
+ *
40
+ * Por que não o `SimpleSelect` nesses campos: com uma dúzia de categorias
41
+ * cadastradas, achar "Combustível" numa lista sem busca é rolar até
42
+ * encontrar, e o teclado do Radix só salta para a opção — não filtra.
43
+ */
44
+ export const SearchableSelect = ({
45
+ value,
46
+ onChange,
47
+ options,
48
+ placeholder,
49
+ searchPlaceholder,
50
+ clearable,
51
+ disabled,
52
+ creatable,
53
+ onCreate,
54
+ createLabel,
55
+ }: SearchableSelectProps): JSX.Element => (
56
+ <Combobox
57
+ options={options.map((option) => ({ id: option.value, name: option.label }))}
58
+ // Campo vazio é lista vazia: `[""]` seria uma opção de id em branco.
59
+ value={value ? [value] : []}
60
+ onChange={(ids) => onChange(ids[0] ?? "")}
61
+ placeholder={placeholder}
62
+ searchPlaceholder={searchPlaceholder}
63
+ clearable={clearable}
64
+ disabled={disabled}
65
+ creatable={creatable}
66
+ onCreate={onCreate}
67
+ createLabel={createLabel}
68
+ />
69
+ );
@@ -3,22 +3,35 @@
3
3
  import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
4
4
  import type { JSX } from "react";
5
5
 
6
- import { formatDateTime } from "#core/_utils/format";
6
+ import { formatDateTime, formatRelativeTime } from "#core/_utils/format";
7
7
  import { useI18n } from "#core/contexts";
8
8
  import { NotificationType } from "#core/features/notifications/enums/notification-type.enum";
9
9
  import type { UserNotification } from "#core/features/notifications/services/notifications.service";
10
10
  import { cn } from "#core/lib/utils";
11
11
 
12
- const ICONS: Record<NotificationType, JSX.Element> = {
13
- [NotificationType.INFO]: <Info className="h-4 w-4 text-sky-500" />,
14
- [NotificationType.SUCCESS]: (
15
- <CheckCircle2 className="h-4 w-4 text-emerald-500" />
16
- ),
17
- [NotificationType.WARNING]: (
18
- <AlertTriangle className="h-4 w-4 text-amber-500" />
19
- ),
20
- [NotificationType.ERROR]: <XCircle className="h-4 w-4 text-destructive" />,
21
- };
12
+ /**
13
+ * Ícone e filete lateral saem da mesma cor: a severidade se lê no filete
14
+ * antes de ler o ícone, e as duas pistas discordando confundiriam.
15
+ */
16
+ const SEVERITY: Record<NotificationType, { icon: JSX.Element; rail: string }> =
17
+ {
18
+ [NotificationType.INFO]: {
19
+ icon: <Info className="h-4 w-4 text-sky-500" />,
20
+ rail: "border-l-sky-500",
21
+ },
22
+ [NotificationType.SUCCESS]: {
23
+ icon: <CheckCircle2 className="h-4 w-4 text-emerald-500" />,
24
+ rail: "border-l-emerald-500",
25
+ },
26
+ [NotificationType.WARNING]: {
27
+ icon: <AlertTriangle className="h-4 w-4 text-amber-500" />,
28
+ rail: "border-l-amber-500",
29
+ },
30
+ [NotificationType.ERROR]: {
31
+ icon: <XCircle className="h-4 w-4 text-destructive" />,
32
+ rail: "border-l-destructive",
33
+ },
34
+ };
22
35
 
23
36
  export interface NotificationItemProps {
24
37
  notification: UserNotification;
@@ -27,12 +40,18 @@ export interface NotificationItemProps {
27
40
  onDismiss: (id: string) => void;
28
41
  }
29
42
 
43
+ /**
44
+ * Uma linha compacta: título e hora na mesma linha, corpo em uma só. O sino é
45
+ * um popover, e cada linha a mais é uma notificação a menos visível sem rolar;
46
+ * o texto inteiro fica no `title` para o hover.
47
+ */
30
48
  export function NotificationItem({
31
49
  notification,
32
50
  onSelect,
33
51
  onDismiss,
34
52
  }: NotificationItemProps): JSX.Element {
35
53
  const { locale, t } = useI18n();
54
+ const severity = SEVERITY[notification.type];
36
55
 
37
56
  return (
38
57
  /*
@@ -42,38 +61,44 @@ export function NotificationItem({
42
61
  */
43
62
  <div
44
63
  className={cn(
45
- "group/notice relative border-b border-border transition-colors hover:bg-accent",
46
- !notification.read && "bg-accent/40",
64
+ "group/notice relative border-l-[3px] border-l-transparent transition-colors",
65
+ // O filete só na não lida: é ele que diz "isto ainda pede atenção".
66
+ !notification.read && cn("bg-accent/35", severity.rail),
47
67
  )}
48
68
  >
49
69
  <button
50
70
  type="button"
51
71
  onClick={() => onSelect(notification)}
52
- className="flex w-full gap-3 px-4 py-3 pr-10 text-left"
72
+ className="flex w-full items-start gap-2.5 py-2 pl-3 pr-9 text-left hover:bg-accent"
53
73
  >
54
- <span className="mt-0.5 shrink-0">{ICONS[notification.type]}</span>
74
+ <span className="mt-0.5 shrink-0">{severity.icon}</span>
55
75
 
56
76
  <span className="min-w-0 flex-1">
57
- <span className="flex items-center gap-2">
77
+ <span className="flex items-baseline gap-2">
78
+ <span
79
+ title={notification.title}
80
+ className={cn(
81
+ "min-w-0 flex-1 truncate text-sm",
82
+ !notification.read && "font-semibold",
83
+ )}
84
+ >
85
+ {notification.title}
86
+ </span>
87
+ <time
88
+ dateTime={notification.createdAt}
89
+ title={formatDateTime(notification.createdAt, locale)}
90
+ className="shrink-0 text-[11px] tabular-nums text-muted-foreground"
91
+ >
92
+ {formatRelativeTime(notification.createdAt, locale)}
93
+ </time>
94
+ </span>
95
+
58
96
  <span
59
- className={cn(
60
- "truncate text-sm",
61
- notification.read ? "text-foreground" : "font-semibold",
62
- )}
97
+ title={notification.body}
98
+ className="block truncate text-xs text-muted-foreground"
63
99
  >
64
- {notification.title}
100
+ {notification.body}
65
101
  </span>
66
- {!notification.read && (
67
- <span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
68
- )}
69
- </span>
70
-
71
- <span className="mt-0.5 block text-xs text-muted-foreground">
72
- {notification.body}
73
- </span>
74
- <span className="mt-1 block text-[11px] text-muted-foreground">
75
- {formatDateTime(notification.createdAt, locale)}
76
- </span>
77
102
  </span>
78
103
  </button>
79
104
 
@@ -84,7 +109,7 @@ export function NotificationItem({
84
109
  onClick={() => onDismiss(notification.id)}
85
110
  aria-label={t("notifications.dismiss")}
86
111
  title={t("notifications.dismiss")}
87
- className="absolute right-2 top-3 hidden rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground group-hover/notice:block"
112
+ className="absolute right-2 top-2 hidden rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground group-hover/notice:block group-focus-within/notice:block"
88
113
  >
89
114
  <X className="h-3.5 w-3.5" />
90
115
  </button>
@@ -20,9 +20,44 @@ import { cn } from "#core/lib/utils";
20
20
 
21
21
  type Tab = "all" | "unread" | "read";
22
22
 
23
+ const TABS: readonly Tab[] = ["all", "unread", "read"];
24
+
23
25
  /** Acima disso o número não cabe no badge sem empurrar o layout. */
24
26
  const BADGE_LIMIT = 99;
25
27
 
28
+ interface NotificationGroup {
29
+ source: string;
30
+ items: UserNotification[];
31
+ unread: number;
32
+ }
33
+
34
+ /**
35
+ * Agrupa pela origem (`source`), na ordem em que cada uma aparece na lista —
36
+ * como a lista vem da mais nova para a mais velha, o grupo com a notificação
37
+ * mais recente fica no topo. O sino não conhece os módulos do produto, então
38
+ * não existe ordem fixa para impor; quem não declarou origem cai no grupo
39
+ * padrão ("Sistema").
40
+ */
41
+ export function groupBySource(
42
+ items: UserNotification[],
43
+ defaultSource: string,
44
+ ): NotificationGroup[] {
45
+ const groups = new Map<string, NotificationGroup>();
46
+
47
+ for (const item of items) {
48
+ const source = item.source ?? defaultSource;
49
+ const group = groups.get(source) ?? { source, items: [], unread: 0 };
50
+
51
+ group.items.push(item);
52
+ if (!item.read) {
53
+ group.unread += 1;
54
+ }
55
+ groups.set(source, group);
56
+ }
57
+
58
+ return [...groups.values()];
59
+ }
60
+
26
61
  export function NotificationsCenter(): JSX.Element {
27
62
  const { t } = useI18n();
28
63
  const router = useRouter();
@@ -30,6 +65,9 @@ export function NotificationsCenter(): JSX.Element {
30
65
  useNotifications();
31
66
  const [tab, setTab] = useState<Tab>("all");
32
67
 
68
+ const read = items.length - unread;
69
+ const counts: Record<Tab, number> = { all: items.length, unread, read };
70
+
33
71
  const visible =
34
72
  tab === "unread"
35
73
  ? items.filter((i) => !i.read)
@@ -37,7 +75,7 @@ export function NotificationsCenter(): JSX.Element {
37
75
  ? items.filter((i) => i.read)
38
76
  : items;
39
77
 
40
- const lidas = items.filter((i) => i.read).length;
78
+ const groups = groupBySource(visible, t("notifications.sourceDefault"));
41
79
 
42
80
  const onSelect = (notification: UserNotification): void => {
43
81
  if (!notification.read) {
@@ -68,24 +106,8 @@ export function NotificationsCenter(): JSX.Element {
68
106
  </DropdownMenuTrigger>
69
107
 
70
108
  <DropdownMenuContent align="end" className="w-96 p-0">
71
- <div className="flex items-center justify-between border-b border-border px-4 py-2">
72
- <div className="flex gap-3">
73
- {(["all", "unread", "read"] as const).map((value) => (
74
- <button
75
- key={value}
76
- type="button"
77
- onClick={() => setTab(value)}
78
- className={cn(
79
- "border-b-2 pb-1 text-sm transition-colors",
80
- tab === value
81
- ? "border-primary font-medium text-foreground"
82
- : "border-transparent text-muted-foreground hover:text-foreground",
83
- )}
84
- >
85
- {t(`notifications.${value}`)}
86
- </button>
87
- ))}
88
- </div>
109
+ <div className="flex items-center justify-between px-4 pt-3">
110
+ <h2 className="text-sm font-semibold">{t("notifications.title")}</h2>
89
111
 
90
112
  {/*
91
113
  A ação do cabeçalho segue a aba. Em "Lidas" o que se quer é
@@ -96,7 +118,7 @@ export function NotificationsCenter(): JSX.Element {
96
118
  <button
97
119
  type="button"
98
120
  onClick={() => void dismissRead()}
99
- disabled={lidas === 0}
121
+ disabled={read === 0}
100
122
  className="text-xs font-medium text-primary hover:underline disabled:cursor-not-allowed disabled:text-muted-foreground disabled:no-underline"
101
123
  >
102
124
  {t("notifications.clearRead")}
@@ -113,6 +135,39 @@ export function NotificationsCenter(): JSX.Element {
113
135
  )}
114
136
  </div>
115
137
 
138
+ <div
139
+ role="tablist"
140
+ className="flex gap-1 border-b border-border px-3 pt-2"
141
+ >
142
+ {TABS.map((value) => (
143
+ <button
144
+ key={value}
145
+ type="button"
146
+ role="tab"
147
+ aria-selected={tab === value}
148
+ onClick={() => setTab(value)}
149
+ className={cn(
150
+ "-mb-px inline-flex items-center gap-1.5 border-b-2 px-1.5 pb-2 pt-1 text-sm transition-colors",
151
+ tab === value
152
+ ? "border-primary font-medium text-foreground"
153
+ : "border-transparent text-muted-foreground hover:text-foreground",
154
+ )}
155
+ >
156
+ {t(`notifications.${value}`)}
157
+ <span
158
+ className={cn(
159
+ "grid h-[18px] min-w-[18px] place-items-center rounded-full px-1.5 text-[10.5px] font-semibold",
160
+ tab === value
161
+ ? "bg-primary/15 text-primary"
162
+ : "bg-muted text-muted-foreground",
163
+ )}
164
+ >
165
+ {counts[value]}
166
+ </span>
167
+ </button>
168
+ ))}
169
+ </div>
170
+
116
171
  <div className="max-h-96 overflow-y-auto">
117
172
  {loading && items.length === 0 && (
118
173
  <div className="flex justify-center py-8">
@@ -126,13 +181,39 @@ export function NotificationsCenter(): JSX.Element {
126
181
  </p>
127
182
  )}
128
183
 
129
- {visible.map((notification) => (
130
- <NotificationItem
131
- key={notification.id}
132
- notification={notification}
133
- onSelect={onSelect}
134
- onDismiss={(id) => void dismiss(id)}
135
- />
184
+ {groups.map((group) => (
185
+ <section key={group.source} aria-label={group.source}>
186
+ <header className="flex items-baseline justify-between px-3 pb-1 pt-2.5">
187
+ <span className="text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
188
+ {group.source}
189
+ </span>
190
+ <span className="text-[11px] tabular-nums text-muted-foreground">
191
+ {group.unread > 0 && (
192
+ <>
193
+ <span className="font-semibold text-primary">
194
+ {group.unread}
195
+ </span>{" "}
196
+ {t(
197
+ group.unread === 1
198
+ ? "notifications.unreadOne"
199
+ : "notifications.unreadMany",
200
+ )}
201
+ {" · "}
202
+ </>
203
+ )}
204
+ {group.items.length}
205
+ </span>
206
+ </header>
207
+
208
+ {group.items.map((notification) => (
209
+ <NotificationItem
210
+ key={notification.id}
211
+ notification={notification}
212
+ onSelect={onSelect}
213
+ onDismiss={(id) => void dismiss(id)}
214
+ />
215
+ ))}
216
+ </section>
136
217
  ))}
137
218
  </div>
138
219
  </DropdownMenuContent>
@@ -272,6 +272,9 @@ export const en: Messages = {
272
272
  dismiss: "Remove from list",
273
273
  markAllRead: "Mark all as read",
274
274
  empty: "No notifications",
275
+ sourceDefault: "System",
276
+ unreadOne: "unread",
277
+ unreadMany: "unread",
275
278
  },
276
279
  calculator: {
277
280
  open: "Open the calculator",
@@ -275,6 +275,9 @@ export const pt = {
275
275
  dismiss: "Tirar da lista",
276
276
  markAllRead: "Marcar todas como lidas",
277
277
  empty: "Nenhuma notificação",
278
+ sourceDefault: "Sistema",
279
+ unreadOne: "não lida",
280
+ unreadMany: "não lidas",
278
281
  },
279
282
  calculator: {
280
283
  open: "Abrir a calculadora",
package/src/index.ts CHANGED
@@ -64,10 +64,12 @@ export { anyOrTeam } from "#core/_utils/permission";
64
64
  export {
65
65
  formatCurrencyBR,
66
66
  formatMoneyBR,
67
+ formatRelativeTime,
67
68
  maskCurrencyBR,
68
69
  maskPhoneBR,
69
70
  parseCurrencyBR,
70
71
  } from "#core/_utils/format";
72
+ export { matchesText, normalizeText, sameText } from "#core/_utils/text";
71
73
 
72
74
  // ---------------------------------------------------------------------------
73
75
  // Hooks de listagem, requisição e filtro