rl-core-front 0.15.2 → 0.15.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.15.2",
3
+ "version": "0.15.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",
@@ -66,16 +66,19 @@ export const today = (): Date => {
66
66
  };
67
67
 
68
68
  /**
69
- * Grade do mês: 6 semanas × 7 dias, começando na segunda-feira.
69
+ * Grade do mês: 6 semanas × 7 dias, começando no domingo.
70
+ *
71
+ * Domingo na primeira coluna porque é como o calendário é lido aqui — o de
72
+ * parede, o do celular e o do banco começam nele, e uma grade que começa na
73
+ * segunda faz quem procura o dia 12 contar coluna por coluna.
70
74
  *
71
75
  * Seis semanas fixas de propósito — com 5 ou 6 conforme o mês, o painel muda de
72
76
  * altura ao navegar e o calendário "pula" na tela.
73
77
  */
74
78
  export const monthGrid = (month: Date): Date[] => {
75
79
  const first = dayOf(month.getFullYear(), month.getMonth(), 1);
76
- // getDay(): 0 = domingo. Queremos segunda como primeira coluna.
77
- const offset = (first.getDay() + 6) % 7;
78
- const start = addDays(first, -offset);
80
+ // getDay(): 0 = domingo, que é a primeira coluna.
81
+ const start = addDays(first, -first.getDay());
79
82
  return Array.from({ length: 42 }, (_, index) => addDays(start, index));
80
83
  };
81
84
 
@@ -85,12 +88,12 @@ export const monthLabel = (month: Date, locale: string): string =>
85
88
  month,
86
89
  );
87
90
 
88
- /** Iniciais dos dias da semana, de segunda a domingo, no locale. */
91
+ /** Iniciais dos dias da semana, de domingo a sábado, no locale. */
89
92
  export const weekdayLabels = (locale: string): string[] => {
90
93
  const formatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
91
- // 2026-06-01 é uma segunda-feira — âncora só para varrer a semana.
94
+ // Dia 0 de junho é 31/05/2026, um domingo — âncora só para varrer a semana.
92
95
  return Array.from({ length: 7 }, (_, index) =>
93
- formatter.format(dayOf(2026, 5, 1 + index)).replace(".", ""),
96
+ formatter.format(dayOf(2026, 5, index)).replace(".", ""),
94
97
  );
95
98
  };
96
99
 
@@ -101,3 +104,204 @@ export const formatDay = (value: string, locale: string): string => {
101
104
  ? new Intl.DateTimeFormat(locale, { dateStyle: "short" }).format(date)
102
105
  : value;
103
106
  };
107
+
108
+ /**
109
+ * A ordem em que o locale escreve dia, mês e ano.
110
+ *
111
+ * Sai do próprio `Intl` e não de uma tabela: `pt-BR` escreve 11/08/2026 e
112
+ * `en-US`, 08/11/2026 — a mesma sequência de dígitos significa dias
113
+ * diferentes, e é ela que o campo digitado precisa respeitar.
114
+ */
115
+ export const dayFieldOrder = (locale: string): ("day" | "month" | "year")[] =>
116
+ new Intl.DateTimeFormat(locale, { dateStyle: "short" })
117
+ .formatToParts(dayOf(2026, 7, 11))
118
+ .map((part) => part.type)
119
+ .filter((type): type is "day" | "month" | "year" =>
120
+ type === "day" || type === "month" || type === "year",
121
+ );
122
+
123
+ /** `dd/mm/aaaa` no locale — o que o campo mostra enquanto está vazio. */
124
+ export const dayPlaceholder = (locale: string): string => {
125
+ const letra = { day: "dd", month: "mm", year: locale.startsWith("pt") ? "aaaa" : "yyyy" };
126
+
127
+ return dayFieldOrder(locale)
128
+ .map((field) => letra[field])
129
+ .join("/");
130
+ };
131
+
132
+ /** Quantos dígitos cabem em cada campo da data, e o maior valor de cada um. */
133
+ const DAY_FIELD_SIZES: Record<"day" | "month" | "year", number> = {
134
+ day: 2,
135
+ month: 2,
136
+ year: 4,
137
+ };
138
+ const DAY_FIELD_MAX: Record<"day" | "month" | "year", number> = {
139
+ day: 31,
140
+ month: 12,
141
+ // O ano não tem teto que ajude enquanto se digita: 2 é o começo de 2026.
142
+ year: Infinity,
143
+ };
144
+
145
+ /** O campo formado e quantos dígitos ele consumiu — o resto vai para o próximo. */
146
+ interface FittedField {
147
+ value: string;
148
+ used: number;
149
+ }
150
+
151
+ /**
152
+ * O campo enquanto ele é digitado, dentro do que existe no calendário.
153
+ *
154
+ * Três regras, todas para o campo nunca mostrar o que não existe:
155
+ *
156
+ * - dígito que não pode **começar** valor nenhum fecha o campo com zero na
157
+ * frente — no mês, `3` só poderia virar 30 a 39, então já vira `03`;
158
+ * - dígito que estoura o par não é engolido, transborda: `3` e `2` no dia
159
+ * viram `3/2`, que é o que quem digitou `3` e `2` queria dizer;
160
+ * - `00` não é dia nem mês.
161
+ *
162
+ * O zero na frente só entra quando se está **digitando**. Apagando, ele
163
+ * devolveria na hora o dígito recém-tirado, e o campo ficaria impossível de
164
+ * corrigir.
165
+ */
166
+ const fitDayField = (
167
+ digits: string,
168
+ max: number,
169
+ size: number,
170
+ autoZero: boolean,
171
+ ): FittedField => {
172
+ const primeiro = digits.slice(0, 1);
173
+
174
+ if (autoZero && primeiro !== "" && Number(primeiro) * 10 > max) {
175
+ return { value: `0${primeiro}`, used: 1 };
176
+ }
177
+
178
+ const cheio = digits.slice(0, size);
179
+
180
+ // `00` não é dia nem mês, e o segundo zero não serve para o campo seguinte:
181
+ // some, em vez de virar um mês zero logo ali.
182
+ if (cheio.length === size && Number(cheio) === 0) {
183
+ return { value: cheio.slice(0, size - 1), used: size };
184
+ }
185
+
186
+ if (cheio.length === size && Number(cheio) > max) {
187
+ return { value: cheio.slice(0, size - 1), used: size - 1 };
188
+ }
189
+
190
+ return { value: cheio, used: cheio.length };
191
+ };
192
+
193
+ /**
194
+ * Põe as barras enquanto se digita: `1108` vira `11/08`.
195
+ *
196
+ * Campo a campo, e não recontando os dígitos do começo: apagar o `3` de
197
+ * `31/08/2026` deixa `1/08/2026`, e não `10/82/026` — o que sobra de um campo
198
+ * cheio transborda para o seguinte, mas nada é puxado de volta.
199
+ *
200
+ * O locale entra porque é ele que diz qual campo é o dia e qual é o mês, e os
201
+ * dois têm tetos diferentes: `13` é dia e não é mês. O texto anterior entra
202
+ * para separar quem digita de quem apaga — ver `fitDayField`.
203
+ */
204
+ export const maskDay = (raw: string, locale: string, previous = ""): string => {
205
+ const order = dayFieldOrder(locale);
206
+ const partes = raw.replace(/[^\d/]/g, "").split("/").slice(0, order.length);
207
+ const apagando = raw.length < previous.length;
208
+ const saida: string[] = [];
209
+ let resto = "";
210
+
211
+ for (let posicao = 0; posicao < order.length; posicao += 1) {
212
+ const campo = order[posicao];
213
+ const tamanho = DAY_FIELD_SIZES[campo];
214
+ const digits = `${resto}${partes[posicao] ?? ""}`.replace(/\D/g, "");
215
+ const { value, used } = fitDayField(
216
+ digits,
217
+ DAY_FIELD_MAX[campo],
218
+ tamanho,
219
+ !apagando,
220
+ );
221
+
222
+ saida.push(value);
223
+
224
+ /*
225
+ O que sobra só empurra os campos seguintes quando eles ainda estão
226
+ vazios.
227
+
228
+ Digitando do zero, empurrar é o que faz `1108` virar `11/08`. Mas
229
+ corrigindo o dia de uma data já escrita, o dígito recusado empurrava o
230
+ resto inteiro: trocar o `31` de `31/02/2056` por `32` devolvia
231
+ `3/02/0220`, com mês e ano remontados a partir do que sobrou. Aqui ele
232
+ simplesmente não entra — a data continua a que estava, com o campo em
233
+ correção mais curto.
234
+
235
+ A exceção é o excedente de tamanho: quem digitou mais dígitos do que o
236
+ campo comporta (colando, ou apagando uma barra) quis mesmo dizer o campo
237
+ seguinte.
238
+ */
239
+ const temConteudoDepois = partes
240
+ .slice(posicao + 1)
241
+ .some((parte) => parte !== "");
242
+ const sobraPorTamanho = digits.length > tamanho;
243
+
244
+ resto = temConteudoDepois && !sobraPorTamanho ? "" : digits.slice(used);
245
+
246
+ // Sem transbordo e sem mais nada digitado, a data acaba aqui: acrescentar
247
+ // um campo vazio escreveria a barra que ninguém pediu.
248
+ if (resto === "" && partes.length <= posicao + 1) {
249
+ break;
250
+ }
251
+ }
252
+
253
+ return saida.join("/");
254
+ };
255
+
256
+ /**
257
+ * O que foi digitado vira `"yyyy-MM-dd"`, ou `null` enquanto não for uma data.
258
+ *
259
+ * Lê os campos separados pelas barras, na ordem do locale: `1/8/2026` é uma
260
+ * data tão boa quanto `01/08/2026`, e exigir o zero à esquerda faria o campo
261
+ * recusar o que ele mesmo acabou de mostrar.
262
+ *
263
+ * O ano pode faltar: `1108` é 11 de agosto do ano de referência — quase todo
264
+ * lançamento é do ano corrente, e obrigar a digitar `2026` toda vez é o tipo
265
+ * de exigência que faz o campo ser abandonado pelo calendário. Ano começado
266
+ * (`202`) ainda não é ano, e não vale como digitado.
267
+ *
268
+ * A conferência é de existência, não de formato: `31/02` casa com a máscara e
269
+ * não existe, e o `Date` do JavaScript o transformaria em 03/03 em silêncio.
270
+ */
271
+ export const parseTypedDay = (
272
+ text: string,
273
+ locale: string,
274
+ fallbackYear: number,
275
+ ): string | null => {
276
+ const campos = maskDay(text, locale).split("/");
277
+
278
+ if (campos.length < 2) {
279
+ return null;
280
+ }
281
+
282
+ const order = dayFieldOrder(locale);
283
+ const campoDe = (field: "day" | "month" | "year"): string =>
284
+ campos[order.indexOf(field)] ?? "";
285
+
286
+ const anoDigitado = campoDe("year");
287
+
288
+ if (anoDigitado !== "" && anoDigitado.length < 4) {
289
+ return null;
290
+ }
291
+
292
+ const day = Number(campoDe("day"));
293
+ const month = Number(campoDe("month"));
294
+ const year = anoDigitado === "" ? fallbackYear : Number(anoDigitado);
295
+
296
+ if (!day || !month) {
297
+ return null;
298
+ }
299
+
300
+ const date = dayOf(year, month - 1, day);
301
+ const existe =
302
+ date.getFullYear() === year &&
303
+ date.getMonth() === month - 1 &&
304
+ date.getDate() === day;
305
+
306
+ return existe ? toIsoDay(date) : null;
307
+ };
@@ -79,6 +79,14 @@ interface DataTableProps<T extends RowData> {
79
79
  loading?: boolean;
80
80
  /** Ações à esquerda da toolbar (ex.: botão "Novo"). */
81
81
  toolbar?: React.ReactNode;
82
+ /**
83
+ * Ações à direita, antes de "Filtros" e "Colunas".
84
+ *
85
+ * É onde mora o que age sobre **a listagem inteira** — exportar, por
86
+ * exemplo. À esquerda ficam as ações de criar; misturar as duas coisas na
87
+ * mesma ponta faz o botão de exportar parecer mais um "novo".
88
+ */
89
+ toolbarEnd?: React.ReactNode;
82
90
  pageSizeOptions?: number[];
83
91
  /** Paginação no servidor: informe rowCount, pagination e onPaginationChange. */
84
92
  manualPagination?: boolean;
@@ -115,6 +123,18 @@ interface DataTableProps<T extends RowData> {
115
123
  * indistinguível de bug, e manda o usuário procurar defeito onde não há.
116
124
  */
117
125
  emptyMessage?: string;
126
+ /**
127
+ * Esconde o rodapé de contagem, paginação e "por página".
128
+ *
129
+ * Para a listagem que já vem inteira numa resposta — um cadastro de vinte
130
+ * linhas não precisa de página. Sem o rodapé a tabela mostra **todas** as
131
+ * linhas, senão esconder a navegação esconderia registros.
132
+ *
133
+ * Ignorado quando a paginação é do servidor (`manualPagination`): ali a
134
+ * tabela só tem em mãos a página atual, e tirar o rodapé faria as outras
135
+ * sumirem sem aviso.
136
+ */
137
+ hideFooter?: boolean;
118
138
  /**
119
139
  * Arrastar a linha pela alça para reordenar a listagem.
120
140
  *
@@ -172,6 +192,7 @@ export function DataTable<T extends RowData>({
172
192
  storageKey,
173
193
  loading,
174
194
  toolbar,
195
+ toolbarEnd,
175
196
  pageSizeOptions = [10, 20, 50],
176
197
  manualPagination,
177
198
  rowCount,
@@ -184,6 +205,7 @@ export function DataTable<T extends RowData>({
184
205
  filters,
185
206
  hiddenFilterFields,
186
207
  emptyMessage,
208
+ hideFooter = false,
187
209
  onRowReorder,
188
210
  }: DataTableProps<T>): JSX.Element {
189
211
  const { t } = useI18n();
@@ -202,6 +224,14 @@ export function DataTable<T extends RowData>({
202
224
  */
203
225
  const [dragOffset, setDragOffset] = useState(0);
204
226
  const dragStartY = useRef(0);
227
+ /*
228
+ A altura da linha na mão, medida no momento em que ela é pega.
229
+
230
+ É o tanto que as linhas do caminho se afastam para abrir o buraco onde ela
231
+ vai cair. Medida e não fixa: a altura de uma linha depende do conteúdo, e um
232
+ número chutado abriria um vão maior ou menor que a linha que vem.
233
+ */
234
+ const [dragHeight, setDragHeight] = useState(0);
205
235
 
206
236
  const { hiddenColumns, saveHiddenColumns } = useColumnVisibility(storageKey);
207
237
  const columnVisibility = useMemo<ColumnVisibilityState>(() => {
@@ -220,6 +250,16 @@ export function DataTable<T extends RowData>({
220
250
  },
221
251
  );
222
252
 
253
+ /*
254
+ Rodapé escondido é lista inteira na tela: sem a navegação, um `pageSize` de
255
+ 10 esconderia da décima primeira linha em diante sem nada dizer. Não vale
256
+ para a paginação do servidor, onde a tabela só tem a página atual em mãos.
257
+ */
258
+ const semRodape = hideFooter && !manualPagination;
259
+ const paginacaoLocal = semRodape
260
+ ? { pageIndex: 0, pageSize: Math.max(data.length, 1) }
261
+ : internalPagination;
262
+
223
263
  const table = useTable({
224
264
  features: dataTableFeatures,
225
265
  data,
@@ -228,7 +268,7 @@ export function DataTable<T extends RowData>({
228
268
  state: {
229
269
  columnVisibility,
230
270
  sorting: manualSorting ? sorting : internalSorting,
231
- pagination: manualPagination ? pagination : internalPagination,
271
+ pagination: manualPagination ? pagination : paginacaoLocal,
232
272
  },
233
273
  onColumnVisibilityChange: (updater) => {
234
274
  const next =
@@ -282,6 +322,9 @@ export function DataTable<T extends RowData>({
282
322
  // A captura mantém os eventos na alça mesmo com o dedo já longe dela;
283
323
  // sem ela, o arrasto morre no primeiro pixel fora do botão.
284
324
  event.currentTarget.setPointerCapture(event.pointerId);
325
+ setDragHeight(
326
+ event.currentTarget.closest("tr")?.getBoundingClientRect().height ?? 0,
327
+ );
285
328
  dragStartY.current = event.clientY;
286
329
  setDragOffset(0);
287
330
  setDraggedRow(rowId);
@@ -289,35 +332,36 @@ export function DataTable<T extends RowData>({
289
332
  };
290
333
 
291
334
  /**
292
- * Que linha está embaixo do ponteiro.
335
+ * Onde a linha está agora: quantas linhas ela já subiu ou desceu.
293
336
  *
294
- * Pelo ponto e não pelo alvo do evento: com a captura ativa, todo `move`
295
- * chega na alça de origem, e é o `elementFromPoint` que sabe onde o dedo
296
- * está de verdade.
337
+ * Conta pelo deslocamento, e não por quem está embaixo do ponteiro. Quem
338
+ * está embaixo do ponteiro é a **própria linha arrastada** ela viaja com o
339
+ * dedo —, e perguntar ao ponto devolvia origem e destino iguais: o arrasto
340
+ * terminava sem gravar nada. Além disso as linhas paradas se movem para
341
+ * abrir o vão, então o que o ponto responde muda durante o gesto e a
342
+ * escolha do destino ficaria oscilando entre duas linhas.
297
343
  */
298
344
  const moveRowDrag = (event: ReactPointerEvent<HTMLButtonElement>): void => {
299
- if (!draggedRow) {
345
+ if (!draggedRow || dragHeight === 0) {
300
346
  return;
301
347
  }
302
348
 
303
- setDragOffset(event.clientY - dragStartY.current);
349
+ const offset = event.clientY - dragStartY.current;
350
+ setDragOffset(offset);
304
351
 
305
- const under = document
306
- .elementFromPoint(event.clientX, event.clientY)
307
- ?.closest("tr[data-row-id]");
308
- const id = under?.getAttribute("data-row-id");
352
+ const rows = table.getRowModel().rows;
353
+ const from = rows.findIndex((row) => row.id === draggedRow);
354
+ // Meia linha de deslocamento já troca de lugar: é o ponto em que a linha
355
+ // arrastada cobre mais a vizinha do que o próprio lugar de origem.
356
+ const destino = Math.min(
357
+ Math.max(from + Math.round(offset / dragHeight), 0),
358
+ rows.length - 1,
359
+ );
309
360
 
310
- if (id) {
311
- setTargetRow(id);
312
- }
361
+ setTargetRow(rows[destino]?.id ?? draggedRow);
313
362
  };
314
363
 
315
- /**
316
- * Onde a linha saiu e onde ela está agora, na página desenhada.
317
- *
318
- * Os dois só para saber de que lado a guia de destino aparece — descendo,
319
- * embaixo do alvo; subindo, em cima dele.
320
- */
364
+ /** Onde a linha saiu e onde ela está agora, na página desenhada. */
321
365
  const draggedIndex = table
322
366
  .getRowModel()
323
367
  .rows.findIndex((row) => row.id === draggedRow);
@@ -325,6 +369,30 @@ export function DataTable<T extends RowData>({
325
369
  .getRowModel()
326
370
  .rows.findIndex((row) => row.id === targetRow);
327
371
 
372
+ /**
373
+ * Quanto uma linha parada se desloca para abrir o buraco.
374
+ *
375
+ * As linhas entre a origem e o destino sobem (ou descem) a altura de uma
376
+ * linha, e o vão aparece exatamente onde a linha vai cair. É isso que
377
+ * responde "para onde isto está indo" durante o gesto — um traço de destino
378
+ * diz o mesmo em teoria, mas exige ler a tabela para achá-lo.
379
+ */
380
+ const rowShift = (index: number): number => {
381
+ if (draggedRow === null || draggedIndex < 0 || targetIndex < 0) {
382
+ return 0;
383
+ }
384
+
385
+ if (index > draggedIndex && index <= targetIndex) {
386
+ return -dragHeight;
387
+ }
388
+
389
+ if (index >= targetIndex && index < draggedIndex) {
390
+ return dragHeight;
391
+ }
392
+
393
+ return 0;
394
+ };
395
+
328
396
  /** Soltou: avisa quem chamou e devolve a tabela ao estado parado. */
329
397
  const endRowDrag = (): void => {
330
398
  if (draggedRow && targetRow && draggedRow !== targetRow) {
@@ -368,6 +436,7 @@ export function DataTable<T extends RowData>({
368
436
  {toolbar}
369
437
  </div>
370
438
  <div className="flex flex-wrap items-center gap-2">
439
+ {toolbarEnd}
371
440
  {/* Filtro antes de colunas: filtrar é a ação do dia a dia, escolher
372
441
  coluna se faz uma vez e a tela guarda. */}
373
442
  {filtersEnabled && (
@@ -467,17 +536,9 @@ export function DataTable<T extends RowData>({
467
536
  </TableRow>
468
537
  )}
469
538
  {!loading &&
470
- table.getRowModel().rows.map((row) => {
539
+ table.getRowModel().rows.map((row, index) => {
471
540
  const dragging = draggedRow === row.id;
472
- // A guia aparece do lado para onde a linha está indo: descendo,
473
- // embaixo do alvo; subindo, em cima dele. É ela que responde
474
- // "onde isto vai cair" antes de soltar.
475
- const guide =
476
- draggedRow !== null && targetRow === row.id && !dragging
477
- ? targetIndex > draggedIndex
478
- ? "border-b-2 border-b-primary"
479
- : "border-t-2 border-t-primary"
480
- : undefined;
541
+ const shift = dragging ? 0 : rowShift(index);
481
542
 
482
543
  return (
483
544
  <TableRow
@@ -487,14 +548,30 @@ export function DataTable<T extends RowData>({
487
548
  // A linha viaja com o dedo, destacada do resto da tabela —
488
549
  // é o que diz que o arrasto pegou, e qual linha pegou.
489
550
  dragging &&
490
- "relative z-20 bg-card shadow-lg ring-1 ring-primary/50 [&>td]:opacity-90",
551
+ "relative z-20 bg-card shadow-xl ring-1 ring-primary/50",
491
552
  draggedRow !== null && "select-none",
492
- guide,
553
+ /*
554
+ A transição existe só enquanto o arrasto dura, e só nas
555
+ linhas paradas: na que segue o dedo ela viraria atraso,
556
+ e depois de soltar ela animaria a volta ao zero por
557
+ cima da lista já reordenada — dois movimentos ao mesmo
558
+ tempo, que é o tremor que se vê ao largar a linha.
559
+ */
560
+ draggedRow !== null &&
561
+ !dragging &&
562
+ "transition-transform duration-200",
493
563
  )}
494
564
  style={
495
565
  dragging
496
- ? { transform: `translateY(${dragOffset}px)` }
497
- : undefined
566
+ ? {
567
+ // O `scale` é o "levantou da mesa": a linha na mão
568
+ // fica maior que as paradas, como o card que sai
569
+ // da coluna no quadro.
570
+ transform: `translateY(${dragOffset}px) scale(1.01)`,
571
+ }
572
+ : shift !== 0
573
+ ? { transform: `translateY(${shift}px)` }
574
+ : undefined
498
575
  }
499
576
  >
500
577
  {reorderable && (
@@ -550,73 +627,75 @@ export function DataTable<T extends RowData>({
550
627
  uma vez fica na ponta, e a navegação, que se usa o tempo todo, no
551
628
  centro. Em tela estreita eles empilham em vez de espremer.
552
629
  */}
553
- <div className="flex flex-col items-center gap-3 border-t border-border px-4 py-3 text-sm text-muted-foreground sm:flex-row sm:justify-between">
554
- <span className="sm:flex-1">
555
- {total} {t("table.results")}
556
- </span>
557
-
558
- <div className="flex items-center gap-1">
559
- <Button
560
- variant="ghost"
561
- size="icon"
562
- aria-label={t("table.previousPage")}
563
- disabled={!table.getCanPreviousPage()}
564
- onClick={() => table.previousPage()}
565
- >
566
- <ChevronLeft className="h-4 w-4" />
567
- </Button>
568
-
569
- {pageRange(pageIndex, pageCount).map((item, posicao) =>
570
- item === "gap" ? (
571
- // O buraco não é botão: `key` pela posição porque duas elipses
572
- // são indistinguíveis entre si.
573
- <span
574
- key={`gap-${posicao}`}
575
- className="px-1 text-muted-foreground"
576
- >
577
-
578
- </span>
579
- ) : (
580
- <Button
581
- key={item}
582
- variant={item === pageIndex + 1 ? "default" : "ghost"}
583
- size="icon"
584
- // Quadrado de 40px como base, mas cresce com o número: a
585
- // página 128 não pode vazar do botão.
586
- className="w-auto min-w-10 px-2 tabular-nums"
587
- aria-current={item === pageIndex + 1 ? "page" : undefined}
588
- onClick={() => table.setPageIndex(item - 1)}
589
- >
590
- {item}
591
- </Button>
592
- ),
593
- )}
594
-
595
- <Button
596
- variant="ghost"
597
- size="icon"
598
- aria-label={t("table.nextPage")}
599
- disabled={!table.getCanNextPage()}
600
- onClick={() => table.nextPage()}
601
- >
602
- <ChevronRight className="h-4 w-4" />
603
- </Button>
604
- </div>
630
+ {!semRodape && (
631
+ <div className="flex flex-col items-center gap-3 border-t border-border px-4 py-3 text-sm text-muted-foreground sm:flex-row sm:justify-between">
632
+ <span className="sm:flex-1">
633
+ {total} {t("table.results")}
634
+ </span>
635
+
636
+ <div className="flex items-center gap-1">
637
+ <Button
638
+ variant="ghost"
639
+ size="icon"
640
+ aria-label={t("table.previousPage")}
641
+ disabled={!table.getCanPreviousPage()}
642
+ onClick={() => table.previousPage()}
643
+ >
644
+ <ChevronLeft className="h-4 w-4" />
645
+ </Button>
646
+
647
+ {pageRange(pageIndex, pageCount).map((item, posicao) =>
648
+ item === "gap" ? (
649
+ // O buraco não é botão: `key` pela posição porque duas elipses
650
+ // são indistinguíveis entre si.
651
+ <span
652
+ key={`gap-${posicao}`}
653
+ className="px-1 text-muted-foreground"
654
+ >
655
+
656
+ </span>
657
+ ) : (
658
+ <Button
659
+ key={item}
660
+ variant={item === pageIndex + 1 ? "default" : "ghost"}
661
+ size="icon"
662
+ // Quadrado de 40px como base, mas cresce com o número: a
663
+ // página 128 não pode vazar do botão.
664
+ className="w-auto min-w-10 px-2 tabular-nums"
665
+ aria-current={item === pageIndex + 1 ? "page" : undefined}
666
+ onClick={() => table.setPageIndex(item - 1)}
667
+ >
668
+ {item}
669
+ </Button>
670
+ ),
671
+ )}
605
672
 
606
- <div className="sm:flex-1 sm:text-right">
607
- <select
608
- value={pageSize}
609
- onChange={(e) => table.setPageSize(Number(e.target.value))}
610
- className="h-9 rounded-md border border-input bg-transparent px-2 text-sm"
611
- >
612
- {pageSizeOptions.map((n) => (
613
- <option key={n} value={n}>
614
- {n} {t("table.perPage")}
615
- </option>
616
- ))}
617
- </select>
673
+ <Button
674
+ variant="ghost"
675
+ size="icon"
676
+ aria-label={t("table.nextPage")}
677
+ disabled={!table.getCanNextPage()}
678
+ onClick={() => table.nextPage()}
679
+ >
680
+ <ChevronRight className="h-4 w-4" />
681
+ </Button>
682
+ </div>
683
+
684
+ <div className="sm:flex-1 sm:text-right">
685
+ <select
686
+ value={pageSize}
687
+ onChange={(e) => table.setPageSize(Number(e.target.value))}
688
+ className="h-9 rounded-md border border-input bg-transparent px-2 text-sm"
689
+ >
690
+ {pageSizeOptions.map((n) => (
691
+ <option key={n} value={n}>
692
+ {n} {t("table.perPage")}
693
+ </option>
694
+ ))}
695
+ </select>
696
+ </div>
618
697
  </div>
619
- </div>
698
+ )}
620
699
  </Card>
621
700
 
622
701
  {storageKey && (
@@ -6,12 +6,15 @@ import { useState } from "react";
6
6
 
7
7
  import {
8
8
  addMonths,
9
+ dayPlaceholder,
9
10
  formatDay,
10
11
  fromIsoDay,
11
12
  isSameDay,
12
13
  isSameMonth,
14
+ maskDay,
13
15
  monthGrid,
14
16
  monthLabel,
17
+ parseTypedDay,
15
18
  today,
16
19
  toIsoDay,
17
20
  weekdayLabels,
@@ -19,6 +22,7 @@ import {
19
22
  import { Button } from "#core/components/ui/button";
20
23
  import {
21
24
  Popover,
25
+ PopoverAnchor,
22
26
  PopoverContent,
23
27
  PopoverTrigger,
24
28
  } from "#core/components/ui/popover";
@@ -46,6 +50,10 @@ export interface DatePickerProps {
46
50
  * O valor continua sendo a string ISO do dia, e não `Date`: é o que o backend
47
51
  * grava (`char(10)`) e o que evita a viagem por fuso que transforma dia 1º às
48
52
  * 00:00 locais no dia 30 em UTC.
53
+ *
54
+ * O campo é **digitável**: `1108` já é 11 de agosto, e quem sabe a data não
55
+ * precisa navegar meses no calendário para dizê-la. O calendário continua ali,
56
+ * no ícone — é o caminho de quem procura o dia, não de quem já o conhece.
49
57
  */
50
58
  export function DatePicker({
51
59
  value,
@@ -56,15 +64,55 @@ export function DatePicker({
56
64
  }: DatePickerProps): JSX.Element {
57
65
  const { t, locale } = useI18n();
58
66
  const [open, setOpen] = useState(false);
67
+ /*
68
+ O que está sendo digitado, enquanto ainda não é uma data.
69
+
70
+ Sem este rascunho o campo teria de apagar cada tecla que não fecha um dia
71
+ válido — digitar `1` já voltaria para o valor gravado, e não haveria como
72
+ chegar ao `11`.
73
+ */
74
+ const [typed, setTyped] = useState<string | null>(null);
59
75
 
60
76
  const selected = fromIsoDay(value);
61
77
  const [month, setMonth] = useState<Date>(() => selected ?? today());
62
78
 
63
79
  const pickDay = (day: Date): void => {
64
80
  onChange(toIsoDay(day));
81
+ setTyped(null);
65
82
  setOpen(false);
66
83
  };
67
84
 
85
+ /**
86
+ * Cada tecla: a máscara põe as barras, e a data sai assim que existir.
87
+ *
88
+ * O ano em branco é o do dia que está no campo — ou o de hoje, quando não há
89
+ * nenhum. Campo esvaziado limpa o valor: apagar é uma escolha, não um
90
+ * rascunho pela metade.
91
+ */
92
+ const digitar = (raw: string): void => {
93
+ // O texto que estava lá antes: é ele que diz se a tecla foi um dígito novo
94
+ // ou um apagar — e o campo se comporta diferente nos dois casos.
95
+ const texto = maskDay(raw, locale, typed ?? (value ? formatDay(value, locale) : ""));
96
+ setTyped(texto);
97
+
98
+ if (texto === "") {
99
+ onChange(undefined);
100
+
101
+ return;
102
+ }
103
+
104
+ const iso = parseTypedDay(
105
+ texto,
106
+ locale,
107
+ (selected ?? today()).getFullYear(),
108
+ );
109
+
110
+ if (iso) {
111
+ onChange(iso);
112
+ setMonth(fromIsoDay(iso) ?? month);
113
+ }
114
+ };
115
+
68
116
  const dayClass = (day: Date): string => {
69
117
  if (selected && isSameDay(day, selected)) {
70
118
  return "bg-primary text-primary-foreground font-semibold";
@@ -77,16 +125,41 @@ export function DatePicker({
77
125
 
78
126
  return (
79
127
  <Popover open={open} onOpenChange={setOpen}>
80
- <PopoverTrigger
81
- disabled={disabled}
82
- aria-label={label}
83
- className="flex h-10 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
84
- >
85
- <span className={cn("truncate", !value && "text-muted-foreground")}>
86
- {value ? formatDay(value, locale) : (placeholder ?? t("filters.selectDate"))}
87
- </span>
88
- <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
89
- </PopoverTrigger>
128
+ {/*
129
+ A âncora é a linha inteira, e não o ícone: presa ao botão, o painel
130
+ abriria alinhado a ele e com a largura dele — um calendário de 16px.
131
+ */}
132
+ <PopoverAnchor asChild>
133
+ <div
134
+ className={cn(
135
+ "flex h-10 w-full items-center rounded-md border border-input bg-transparent shadow-sm transition-colors hover:border-ring focus-within:ring-2 focus-within:ring-ring",
136
+ disabled && "cursor-not-allowed opacity-50",
137
+ )}
138
+ >
139
+ <input
140
+ aria-label={label}
141
+ value={typed ?? (value ? formatDay(value, locale) : "")}
142
+ disabled={disabled}
143
+ // Só dígitos, e o teclado do celular abre no numérico: a máscara é
144
+ // quem escreve as barras.
145
+ inputMode="numeric"
146
+ placeholder={placeholder ?? dayPlaceholder(locale)}
147
+ onChange={(event) => digitar(event.target.value)}
148
+ // Sai do campo e o rascunho some: `11/0` não é data, e deixá-lo
149
+ // escrito faria o campo mostrar uma coisa e o formulário guardar
150
+ // outra.
151
+ onBlur={() => setTyped(null)}
152
+ className="h-full w-full min-w-0 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
153
+ />
154
+ <PopoverTrigger
155
+ disabled={disabled}
156
+ aria-label={t("filters.openCalendar")}
157
+ className="flex h-full shrink-0 items-center px-3 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed"
158
+ >
159
+ <Calendar className="h-4 w-4" />
160
+ </PopoverTrigger>
161
+ </div>
162
+ </PopoverAnchor>
90
163
 
91
164
  <PopoverContent
92
165
  // Largura do campo, e não fixa: o painel é a continuação dele, e
@@ -160,6 +233,7 @@ export function DatePicker({
160
233
  size="sm"
161
234
  onClick={() => {
162
235
  onChange(undefined);
236
+ setTyped(null);
163
237
  setOpen(false);
164
238
  }}
165
239
  >
@@ -4,6 +4,7 @@ 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 { Button, type ButtonProps } from "#core/components/ui/button";
7
8
  import {
8
9
  EXPAND_SHORTCUT_KEY,
9
10
  EXPAND_SHORTCUT_LABEL,
@@ -11,6 +12,25 @@ import {
11
12
  } from "#core/hooks/use-shortcut";
12
13
  import { cn } from "#core/lib/utils";
13
14
 
15
+ /**
16
+ * Como pedir para fechar o modal **passando pela guarda**.
17
+ *
18
+ * O `Esc`, o clique fora e o `X` passam pelo `DialogContent`, que pergunta
19
+ * antes de descartar o que foi preenchido. O botão "Cancelar" mora na tela e
20
+ * chamava o `onClose` dela direto — mesma saída, sem a pergunta. Este contexto
21
+ * é o caminho que faltava: quem pede daqui recebe a mesma confirmação.
22
+ */
23
+ const DialogCloseRequestContext = React.createContext<() => void>(() => {});
24
+
25
+ /**
26
+ * Pede o fechamento do modal em que o componente está.
27
+ *
28
+ * Fora de um `DialogContent` não faz nada — nunca quebra, só não tem o que
29
+ * fechar.
30
+ */
31
+ const useDialogCloseRequest = (): (() => void) =>
32
+ React.useContext(DialogCloseRequestContext);
33
+
14
34
  const Dialog = DialogPrimitive.Root;
15
35
  const DialogTrigger = DialogPrimitive.Trigger;
16
36
  const DialogPortal = DialogPrimitive.Portal;
@@ -248,6 +268,22 @@ const DialogContent = React.forwardRef<
248
268
  setConfirmandoSaida(true);
249
269
  };
250
270
 
271
+ /**
272
+ * A saída pedida de dentro do corpo — é o que o "Cancelar" usa.
273
+ *
274
+ * Sem evento para segurar: aqui o fechamento nem começa, ou é a pergunta que
275
+ * aparece, ou é o `Close` escondido que fecha de uma vez.
276
+ */
277
+ const pedirSaida = React.useCallback((): void => {
278
+ if (unsavedChanges) {
279
+ setConfirmandoSaida(true);
280
+
281
+ return;
282
+ }
283
+
284
+ fecharRef.current?.click();
285
+ }, [unsavedChanges]);
286
+
251
287
  // O cabeçalho sai da lista para dividir a primeira linha com os botões; o
252
288
  // resto segue na ordem em que o chamador escreveu.
253
289
  const items = React.Children.toArray(children);
@@ -338,7 +374,9 @@ const DialogContent = React.forwardRef<
338
374
  </DialogPrimitive.Close>
339
375
  </div>
340
376
  </div>
341
- {body}
377
+ <DialogCloseRequestContext.Provider value={pedirSaida}>
378
+ {body}
379
+ </DialogCloseRequestContext.Provider>
342
380
 
343
381
  <DialogPrimitive.Close ref={fecharRef} className="hidden" tabIndex={-1} />
344
382
  </DialogPrimitive.Content>
@@ -467,8 +505,49 @@ const DialogDescription = React.forwardRef<
467
505
  ));
468
506
  DialogDescription.displayName = DialogPrimitive.Description.displayName;
469
507
 
508
+ /**
509
+ * O "Cancelar" do rodapé — a mesma saída do `Esc`, com a mesma pergunta.
510
+ *
511
+ * Existe porque o botão que a tela escrevia à mão chamava o `onClose` dela
512
+ * direto: fechava sem perguntar nada, e o formulário preenchido ia junto.
513
+ * Aqui o fechamento é pedido ao modal, que decide se pergunta antes.
514
+ *
515
+ * O texto vem de fora — este arquivo não depende do provider de i18n, e passar
516
+ * a depender quebraria todo uso fora dele.
517
+ *
518
+ * ```tsx
519
+ * <DialogFooter>
520
+ * <DialogCancel>{t("common.cancel")}</DialogCancel>
521
+ * <Button type="submit">{t("common.save")}</Button>
522
+ * </DialogFooter>
523
+ * ```
524
+ */
525
+ const DialogCancel = React.forwardRef<HTMLButtonElement, ButtonProps>(
526
+ ({ variant = "outline", type = "button", onClick, ...props }, ref) => {
527
+ const pedirSaida = useDialogCloseRequest();
528
+
529
+ return (
530
+ <Button
531
+ ref={ref}
532
+ type={type}
533
+ variant={variant}
534
+ onClick={(event) => {
535
+ onClick?.(event);
536
+
537
+ if (!event.defaultPrevented) {
538
+ pedirSaida();
539
+ }
540
+ }}
541
+ {...props}
542
+ />
543
+ );
544
+ },
545
+ );
546
+ DialogCancel.displayName = "DialogCancel";
547
+
470
548
  export {
471
549
  Dialog,
550
+ DialogCancel,
472
551
  DialogClose,
473
552
  DialogContent,
474
553
  DialogDescription,
@@ -479,4 +558,5 @@ export {
479
558
  DialogPortal,
480
559
  DialogTitle,
481
560
  DialogTrigger,
561
+ useDialogCloseRequest,
482
562
  };
@@ -183,6 +183,7 @@ export const en: Messages = {
183
183
  toChip: "until",
184
184
  selectPeriod: "Select a period",
185
185
  selectDate: "Select a date",
186
+ openCalendar: "Open calendar",
186
187
  today: "Today",
187
188
  lastDays: "{days} days",
188
189
  previousMonth: "Previous month",
@@ -186,6 +186,7 @@ export const pt = {
186
186
  toChip: "até",
187
187
  selectPeriod: "Selecione o período",
188
188
  selectDate: "Selecione a data",
189
+ openCalendar: "Abrir calendário",
189
190
  today: "Hoje",
190
191
  lastDays: "{days} dias",
191
192
  previousMonth: "Mês anterior",