rl-core-front 0.14.4 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.14.4",
3
+ "version": "0.15.0",
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",
@@ -122,6 +122,60 @@ export interface paths {
122
122
  patch?: never;
123
123
  trace?: never;
124
124
  };
125
+ "/auth/device/login": {
126
+ parameters: {
127
+ query?: never;
128
+ header?: never;
129
+ path?: never;
130
+ cookie?: never;
131
+ };
132
+ get?: never;
133
+ put?: never;
134
+ /**
135
+ * Login pelo aparelho confiável: sem senha e sem 2FA
136
+ * @description O app apresenta o vínculo criado num login completo, depois de destravá-lo com a biometria. O vínculo rotaciona a cada uso.
137
+ */
138
+ post: operations["AuthController_deviceLogin"];
139
+ delete?: never;
140
+ options?: never;
141
+ head?: never;
142
+ patch?: never;
143
+ trace?: never;
144
+ };
145
+ "/auth/devices": {
146
+ parameters: {
147
+ query?: never;
148
+ header?: never;
149
+ path?: never;
150
+ cookie?: never;
151
+ };
152
+ /** Aparelhos confiáveis da conta */
153
+ get: operations["AuthController_devices"];
154
+ put?: never;
155
+ post?: never;
156
+ delete?: never;
157
+ options?: never;
158
+ head?: never;
159
+ patch?: never;
160
+ trace?: never;
161
+ };
162
+ "/auth/devices/{family}": {
163
+ parameters: {
164
+ query?: never;
165
+ header?: never;
166
+ path?: never;
167
+ cookie?: never;
168
+ };
169
+ get?: never;
170
+ put?: never;
171
+ post?: never;
172
+ /** Esquece um aparelho: o próximo acesso dele exige senha + 2FA */
173
+ delete: operations["AuthController_forgetDevice"];
174
+ options?: never;
175
+ head?: never;
176
+ patch?: never;
177
+ trace?: never;
178
+ };
125
179
  "/auth/profile": {
126
180
  parameters: {
127
181
  query?: never;
@@ -784,6 +838,38 @@ export interface paths {
784
838
  patch: operations["NotificationsController_markAsRead"];
785
839
  trace?: never;
786
840
  };
841
+ "/exports/{handler}": {
842
+ parameters: {
843
+ query?: never;
844
+ header?: never;
845
+ path?: never;
846
+ cookie?: never;
847
+ };
848
+ get?: never;
849
+ put?: never;
850
+ post: operations["ExportController_request"];
851
+ delete?: never;
852
+ options?: never;
853
+ head?: never;
854
+ patch?: never;
855
+ trace?: never;
856
+ };
857
+ "/exports/{jobId}": {
858
+ parameters: {
859
+ query?: never;
860
+ header?: never;
861
+ path?: never;
862
+ cookie?: never;
863
+ };
864
+ get: operations["ExportController_download"];
865
+ put?: never;
866
+ post?: never;
867
+ delete?: never;
868
+ options?: never;
869
+ head?: never;
870
+ patch?: never;
871
+ trace?: never;
872
+ };
787
873
  "/jobs/{id}": {
788
874
  parameters: {
789
875
  query?: never;
@@ -920,11 +1006,45 @@ export interface components {
920
1006
  accessToken?: string;
921
1007
  /** @description Apenas para client=mobile */
922
1008
  refreshToken?: string;
1009
+ /** @description Vínculo do aparelho (client=mobile). Guardar no Keychain: é o que abre os próximos acessos sem senha e sem 2FA */
1010
+ deviceToken?: string;
923
1011
  };
924
1012
  RefreshTokenCommand: {
925
1013
  /** @description Refresh token do cliente mobile. No web vem pelo cookie. */
926
1014
  refreshToken?: string;
927
1015
  };
1016
+ LogoutCommand: {
1017
+ /** @description Refresh token do cliente mobile. No web vem pelo cookie. */
1018
+ refreshToken?: string;
1019
+ /** @description Enviar apenas para esquecer o aparelho junto com a saída */
1020
+ deviceToken?: string;
1021
+ };
1022
+ DeviceLoginCommand: {
1023
+ /** @description Vínculo do aparelho, guardado no Keychain */
1024
+ deviceToken: string;
1025
+ };
1026
+ DeviceLoginResponse: {
1027
+ user: components["schemas"]["SessionUserResponse"];
1028
+ accessToken: string;
1029
+ refreshToken: string;
1030
+ /** @description O vínculo rotaciona a cada uso: guarde este */
1031
+ deviceToken: string;
1032
+ };
1033
+ TrustedDeviceResponse: {
1034
+ /** Format: uuid */
1035
+ id: string;
1036
+ /**
1037
+ * Format: uuid
1038
+ * @description Identifica o aparelho ao longo das rotações — é o que se revoga
1039
+ */
1040
+ family: string;
1041
+ /** Format: date-time */
1042
+ createdAt: string;
1043
+ /** Format: date-time */
1044
+ expiresAt: string;
1045
+ ipAddress?: Record<string, never> | null;
1046
+ userAgent?: Record<string, never> | null;
1047
+ };
928
1048
  RegenerateBackupCommand: {
929
1049
  /** @description Código TOTP atual (6 dígitos) */
930
1050
  code: string;
@@ -969,7 +1089,7 @@ export interface components {
969
1089
  UpdateProfileCommand: {
970
1090
  name?: string;
971
1091
  lastName?: string;
972
- phone?: string;
1092
+ phone?: Record<string, never> | null;
973
1093
  };
974
1094
  ChangePasswordCommand: {
975
1095
  currentPassword: string;
@@ -1357,6 +1477,29 @@ export interface components {
1357
1477
  /** @description Dado extra. `route` é usado pelo cliente para navegar. */
1358
1478
  payload?: Record<string, never>;
1359
1479
  };
1480
+ RequestExportCommand: {
1481
+ /**
1482
+ * @description Filtro dinâmico, no mesmo formato da listagem
1483
+ * @example {"createdAt":{"$BETWEEN":["2026-01-01","2026-06-30"]}}
1484
+ */
1485
+ filter?: string;
1486
+ /**
1487
+ * @description Busca livre, a mesma da toolbar da listagem
1488
+ * @example marvel
1489
+ */
1490
+ search?: string;
1491
+ /** @description Campo de ordenação do catálogo */
1492
+ sortBy?: string;
1493
+ /** @enum {string} */
1494
+ sortDir?: "ASC" | "DESC";
1495
+ };
1496
+ ExportAcceptedResponse: {
1497
+ /** @example 184 */
1498
+ jobId: string;
1499
+ /** @example funkos */
1500
+ name: string;
1501
+ };
1502
+ StreamableFile: Record<string, never>;
1360
1503
  JobProgressResponse: {
1361
1504
  /** @example 120 */
1362
1505
  processed: number;
@@ -1416,6 +1559,13 @@ export interface components {
1416
1559
  state: string;
1417
1560
  /** Format: uuid */
1418
1561
  ownerId: string | null;
1562
+ /**
1563
+ * @example {
1564
+ * "handler": "funkos",
1565
+ * "fileName": "funkos_2026-09-04.xlsx"
1566
+ * }
1567
+ */
1568
+ payload: Record<string, never> | null;
1419
1569
  /** @example 2 */
1420
1570
  attemptsMade: number;
1421
1571
  /** @example 69 */
@@ -1572,9 +1722,70 @@ export interface operations {
1572
1722
  };
1573
1723
  requestBody: {
1574
1724
  content: {
1575
- "application/json": components["schemas"]["RefreshTokenCommand"];
1725
+ "application/json": components["schemas"]["LogoutCommand"];
1726
+ };
1727
+ };
1728
+ responses: {
1729
+ 200: {
1730
+ headers: {
1731
+ [name: string]: unknown;
1732
+ };
1733
+ content?: never;
1576
1734
  };
1577
1735
  };
1736
+ };
1737
+ AuthController_deviceLogin: {
1738
+ parameters: {
1739
+ query?: never;
1740
+ header?: never;
1741
+ path?: never;
1742
+ cookie?: never;
1743
+ };
1744
+ requestBody: {
1745
+ content: {
1746
+ "application/json": components["schemas"]["DeviceLoginCommand"];
1747
+ };
1748
+ };
1749
+ responses: {
1750
+ 200: {
1751
+ headers: {
1752
+ [name: string]: unknown;
1753
+ };
1754
+ content: {
1755
+ "application/json": components["schemas"]["DeviceLoginResponse"];
1756
+ };
1757
+ };
1758
+ };
1759
+ };
1760
+ AuthController_devices: {
1761
+ parameters: {
1762
+ query?: never;
1763
+ header?: never;
1764
+ path?: never;
1765
+ cookie?: never;
1766
+ };
1767
+ requestBody?: never;
1768
+ responses: {
1769
+ 200: {
1770
+ headers: {
1771
+ [name: string]: unknown;
1772
+ };
1773
+ content: {
1774
+ "application/json": components["schemas"]["TrustedDeviceResponse"][];
1775
+ };
1776
+ };
1777
+ };
1778
+ };
1779
+ AuthController_forgetDevice: {
1780
+ parameters: {
1781
+ query?: never;
1782
+ header?: never;
1783
+ path: {
1784
+ family: string;
1785
+ };
1786
+ cookie?: never;
1787
+ };
1788
+ requestBody?: never;
1578
1789
  responses: {
1579
1790
  200: {
1580
1791
  headers: {
@@ -2966,6 +3177,67 @@ export interface operations {
2966
3177
  };
2967
3178
  };
2968
3179
  };
3180
+ ExportController_request: {
3181
+ parameters: {
3182
+ query?: never;
3183
+ header?: never;
3184
+ path: {
3185
+ handler: string;
3186
+ };
3187
+ cookie?: never;
3188
+ };
3189
+ requestBody: {
3190
+ content: {
3191
+ "application/json": components["schemas"]["RequestExportCommand"];
3192
+ };
3193
+ };
3194
+ responses: {
3195
+ 202: {
3196
+ headers: {
3197
+ [name: string]: unknown;
3198
+ };
3199
+ content: {
3200
+ "application/json": components["schemas"]["ExportAcceptedResponse"];
3201
+ };
3202
+ };
3203
+ };
3204
+ };
3205
+ ExportController_download: {
3206
+ parameters: {
3207
+ query?: never;
3208
+ header?: never;
3209
+ path: {
3210
+ jobId: string;
3211
+ };
3212
+ cookie?: never;
3213
+ };
3214
+ requestBody?: never;
3215
+ responses: {
3216
+ /** @description A planilha */
3217
+ 200: {
3218
+ headers: {
3219
+ [name: string]: unknown;
3220
+ };
3221
+ content: {
3222
+ "application/json": components["schemas"]["StreamableFile"];
3223
+ };
3224
+ };
3225
+ /** @description Ainda em geração */
3226
+ 409: {
3227
+ headers: {
3228
+ [name: string]: unknown;
3229
+ };
3230
+ content?: never;
3231
+ };
3232
+ /** @description O arquivo expirou */
3233
+ 410: {
3234
+ headers: {
3235
+ [name: string]: unknown;
3236
+ };
3237
+ content?: never;
3238
+ };
3239
+ };
3240
+ };
2969
3241
  JobStatusController_status: {
2970
3242
  parameters: {
2971
3243
  query?: never;
@@ -2989,7 +3261,9 @@ export interface operations {
2989
3261
  };
2990
3262
  QueueAdminController_counts: {
2991
3263
  parameters: {
2992
- query?: never;
3264
+ query?: {
3265
+ queue?: "jobs" | "exports";
3266
+ };
2993
3267
  header?: never;
2994
3268
  path?: never;
2995
3269
  cookie?: never;
@@ -3012,6 +3286,9 @@ export interface operations {
3012
3286
  state: "waiting" | "active" | "completed" | "failed" | "delayed";
3013
3287
  page: string;
3014
3288
  limit: string;
3289
+ sortBy?: "name" | "attemptsMade" | "percent" | "createdAt" | "failedReason";
3290
+ sortDir?: "ASC" | "DESC";
3291
+ queue?: "jobs" | "exports";
3015
3292
  };
3016
3293
  header?: never;
3017
3294
  path?: never;
@@ -3031,7 +3308,9 @@ export interface operations {
3031
3308
  };
3032
3309
  QueueAdminController_retry: {
3033
3310
  parameters: {
3034
- query?: never;
3311
+ query?: {
3312
+ queue?: "jobs" | "exports";
3313
+ };
3035
3314
  header?: never;
3036
3315
  path: {
3037
3316
  id: string;
@@ -3050,7 +3329,9 @@ export interface operations {
3050
3329
  };
3051
3330
  QueueAdminController_remove: {
3052
3331
  parameters: {
3053
- query?: never;
3332
+ query?: {
3333
+ queue?: "jobs" | "exports";
3334
+ };
3054
3335
  header?: never;
3055
3336
  path: {
3056
3337
  id: string;
@@ -4,6 +4,7 @@ import {
4
4
  ChevronDown,
5
5
  ChevronRight,
6
6
  History,
7
+ Keyboard,
7
8
  LayoutDashboard,
8
9
  ListChecks,
9
10
  LogOut,
@@ -11,6 +12,7 @@ import {
11
12
  ScrollText,
12
13
  Settings,
13
14
  ShieldCheck,
15
+ SlidersHorizontal,
14
16
  User,
15
17
  Users,
16
18
  } from "lucide-react";
@@ -143,6 +145,28 @@ const PROFILE: NavItem = {
143
145
  icon: User,
144
146
  perm: null,
145
147
  };
148
+ /** Sem permissão: atalho é do teclado, não do papel. */
149
+ const SHORTCUTS: NavItem = {
150
+ key: "nav.shortcuts",
151
+ href: "/atalhos",
152
+ icon: Keyboard,
153
+ perm: null,
154
+ };
155
+
156
+ /**
157
+ * O que é do usuário, e não do sistema: o perfil dele e as teclas.
158
+ *
159
+ * Fica no rodapé do menu e sem permissão nenhuma — nenhum dos dois opera coisa
160
+ * alguma, e esconder "como uso o teclado" de alguém não protege nada. Separado
161
+ * de "Administração", que é o grupo de quem opera o sistema dos outros.
162
+ */
163
+ const SETTINGS_CHILDREN: NavItem[] = [PROFILE, SHORTCUTS];
164
+
165
+ const SETTINGS_GROUP: NavGroup = {
166
+ key: "nav.settings",
167
+ icon: SlidersHorizontal,
168
+ children: SETTINGS_CHILDREN,
169
+ };
146
170
  const ADMIN_CHILDREN: NavItem[] = [
147
171
  {
148
172
  key: "nav.users",
@@ -269,6 +293,7 @@ export function AppShell({
269
293
  const known: NavItem[] = [
270
294
  DASHBOARD,
271
295
  PROFILE,
296
+ SHORTCUTS,
272
297
  ...ADMIN_CHILDREN,
273
298
  // Achata pai e filhos: a rota de um filho precisa ser barrada como
274
299
  // qualquer outra, e o pai de grupo não tem rota para checar.
@@ -444,7 +469,7 @@ export function AppShell({
444
469
  )}
445
470
 
446
471
  <div className="mt-1">
447
- <NavLink item={PROFILE} mini={mini} />
472
+ <NavGroupBlock group={SETTINGS_GROUP} mini={mini} />
448
473
  </div>
449
474
  </div>
450
475
  );
@@ -0,0 +1,64 @@
1
+ "use client";
2
+
3
+ import { Plus } from "lucide-react";
4
+ import * as React from "react";
5
+
6
+ import { Button, type ButtonProps } from "#core/components/ui/button";
7
+ import { useI18n } from "#core/contexts";
8
+ import {
9
+ CREATE_SHORTCUT_KEYS,
10
+ CREATE_SHORTCUT_LABEL,
11
+ useShortcut,
12
+ } from "#core/hooks/use-shortcut";
13
+
14
+ export interface CreateButtonProps extends Omit<ButtonProps, "children"> {
15
+ /** O que o botão diz. Vem traduzido da tela: cada uma cria uma coisa. */
16
+ label: string;
17
+ /** O que abrir. É a mesma função do clique e do atalho — não há dois caminhos. */
18
+ onCreate: () => void;
19
+ /** Some com o ícone, para quem quer só o texto. */
20
+ hideIcon?: boolean;
21
+ }
22
+
23
+ /**
24
+ * O botão de cadastrar de qualquer listagem — e, junto dele, o atalho.
25
+ *
26
+ * Existe para o atalho não depender de alguém lembrar: quem põe o botão ganha
27
+ * o teclado no mesmo gesto, e tela nova nasce com o comportamento em vez de
28
+ * herdá-lo por disciplina. É o par do `Esc` que o modal já entende — um fecha,
29
+ * o outro abre.
30
+ *
31
+ * O atalho vive **enquanto o botão existe**. Como toda tela já esconde o botão
32
+ * de quem não pode criar, esconder o botão desliga o atalho sozinho: não há
33
+ * uma segunda checagem de permissão para sair do lugar.
34
+ *
35
+ * ```tsx
36
+ * {canCreate && (
37
+ * <CreateButton size="sm" label={t("funkos.newItem")} onCreate={openCreate} />
38
+ * )}
39
+ * ```
40
+ */
41
+ const CreateButton = React.forwardRef<HTMLButtonElement, CreateButtonProps>(
42
+ ({ label, onCreate, hideIcon = false, title, ...props }, ref) => {
43
+ const { t } = useI18n();
44
+
45
+ useShortcut(CREATE_SHORTCUT_KEYS, onCreate, { double: true });
46
+
47
+ return (
48
+ <Button
49
+ ref={ref}
50
+ onClick={onCreate}
51
+ // Atalho que ninguém descobre é atalho que não existe: o título é onde
52
+ // ele se apresenta. Quem passar o seu próprio continua mandando.
53
+ title={title ?? t("common.shortcutHint", { keys: CREATE_SHORTCUT_LABEL })}
54
+ {...props}
55
+ >
56
+ {!hideIcon && <Plus className="h-4 w-4" />}
57
+ {label}
58
+ </Button>
59
+ );
60
+ },
61
+ );
62
+ CreateButton.displayName = "CreateButton";
63
+
64
+ export { CreateButton };
@@ -26,9 +26,10 @@ import {
26
26
  ChevronLeft,
27
27
  ChevronRight,
28
28
  Filter,
29
+ GripVertical,
29
30
  SlidersHorizontal,
30
31
  } from "lucide-react";
31
- import type { JSX } from "react";
32
+ import type { JSX, PointerEvent as ReactPointerEvent } from "react";
32
33
  import { useMemo, useState } from "react";
33
34
 
34
35
  import { countConditions } from "#core/_utils/advanced-filter";
@@ -53,6 +54,7 @@ import {
53
54
  import { useI18n } from "#core/contexts";
54
55
  import { useColumnVisibility } from "#core/hooks/use-column-visibility";
55
56
  import type { FiltersState } from "#core/hooks/use-filters";
57
+ import { cn } from "#core/lib/utils";
56
58
 
57
59
  export const dataTableFeatures = tableFeatures({
58
60
  columnVisibilityFeature,
@@ -113,6 +115,19 @@ interface DataTableProps<T extends RowData> {
113
115
  * indistinguível de bug, e manda o usuário procurar defeito onde não há.
114
116
  */
115
117
  emptyMessage?: string;
118
+ /**
119
+ * Arrastar a linha pela alça para reordenar a listagem.
120
+ *
121
+ * A tabela avisa **onde soltou** — a linha `activeId` sobre a linha `overId`
122
+ * — e não mexe em `data`: quem guarda a ordem é o servidor, e a lista volta
123
+ * de lá. Mandar a ordem inteira faria duas abas abertas sobrescreverem uma à
124
+ * outra com o estado velho de quem salvou por último.
125
+ *
126
+ * Exige `getRowId` — é dele que saem os dois ids. A alça fica inerte
127
+ * enquanto uma coluna estiver ordenando: ali a posição na tela não é a ordem
128
+ * gravada, e arrastar escreveria uma sequência que ninguém viu.
129
+ */
130
+ onRowReorder?: (activeId: string, overId: string) => void;
116
131
  }
117
132
 
118
133
  /** Extras que uma coluna pode declarar em `meta`. */
@@ -165,10 +180,14 @@ export function DataTable<T extends RowData>({
165
180
  filters,
166
181
  hiddenFilterFields,
167
182
  emptyMessage,
183
+ onRowReorder,
168
184
  }: DataTableProps<T>): JSX.Element {
169
185
  const { t } = useI18n();
170
186
  const [colsOpen, setColsOpen] = useState(false);
171
187
  const [filtersOpen, setFiltersOpen] = useState(false);
188
+ /** A linha na mão e a linha embaixo do dedo, enquanto o arrasto dura. */
189
+ const [draggedRow, setDraggedRow] = useState<string | null>(null);
190
+ const [targetRow, setTargetRow] = useState<string | null>(null);
172
191
 
173
192
  const { hiddenColumns, saveHiddenColumns } = useColumnVisibility(storageKey);
174
193
  const columnVisibility = useMemo<ColumnVisibilityState>(() => {
@@ -232,7 +251,58 @@ export function DataTable<T extends RowData>({
232
251
  : table.getFilteredRowModel().rows.length;
233
252
  const { pageIndex, pageSize } = table.state.pagination;
234
253
  const pageCount = table.getPageCount();
235
- const colSpan = table.getVisibleLeafColumns().length || 1;
254
+ const reorderable = Boolean(onRowReorder && getRowId);
255
+ /*
256
+ Ordenando por uma coluna, a sequência da tela não é a que está gravada:
257
+ soltar a linha "em cima da terceira" escreveria uma ordem que ninguém viu.
258
+ A coluna da alça continua no lugar para a tabela não mudar de largura — só
259
+ o botão fica inerte, e o `title` diz o motivo.
260
+ */
261
+ const canReorder = reorderable && (table.state.sorting ?? []).length === 0;
262
+ const colSpan =
263
+ (table.getVisibleLeafColumns().length || 1) + (reorderable ? 1 : 0);
264
+
265
+ const startRowDrag =
266
+ (rowId: string) =>
267
+ (event: ReactPointerEvent<HTMLButtonElement>): void => {
268
+ // A captura mantém os eventos na alça mesmo com o dedo já longe dela;
269
+ // sem ela, o arrasto morre no primeiro pixel fora do botão.
270
+ event.currentTarget.setPointerCapture(event.pointerId);
271
+ setDraggedRow(rowId);
272
+ setTargetRow(rowId);
273
+ };
274
+
275
+ /**
276
+ * Que linha está embaixo do ponteiro.
277
+ *
278
+ * Pelo ponto e não pelo alvo do evento: com a captura ativa, todo `move`
279
+ * chega na alça de origem, e é o `elementFromPoint` que sabe onde o dedo
280
+ * está de verdade.
281
+ */
282
+ const moveRowDrag = (event: ReactPointerEvent<HTMLButtonElement>): void => {
283
+ if (!draggedRow) {
284
+ return;
285
+ }
286
+
287
+ const under = document
288
+ .elementFromPoint(event.clientX, event.clientY)
289
+ ?.closest("tr[data-row-id]");
290
+ const id = under?.getAttribute("data-row-id");
291
+
292
+ if (id) {
293
+ setTargetRow(id);
294
+ }
295
+ };
296
+
297
+ /** Soltou: avisa quem chamou e devolve a tabela ao estado parado. */
298
+ const endRowDrag = (): void => {
299
+ if (draggedRow && targetRow && draggedRow !== targetRow) {
300
+ onRowReorder?.(draggedRow, targetRow);
301
+ }
302
+
303
+ setDraggedRow(null);
304
+ setTargetRow(null);
305
+ };
236
306
 
237
307
  const filtersEnabled = Boolean(filters);
238
308
  const activeFilters = !filters
@@ -301,6 +371,11 @@ export function DataTable<T extends RowData>({
301
371
  <TableHeader>
302
372
  {table.getHeaderGroups().map((hg) => (
303
373
  <TableRow key={hg.id}>
374
+ {reorderable && (
375
+ <TableHead className="w-px">
376
+ <span className="sr-only">{t("table.reorder")}</span>
377
+ </TableHead>
378
+ )}
304
379
  {hg.headers.map((header) => {
305
380
  const canSort = header.column.getCanSort();
306
381
  const sorted = header.column.getIsSorted();
@@ -361,7 +436,49 @@ export function DataTable<T extends RowData>({
361
436
  )}
362
437
  {!loading &&
363
438
  table.getRowModel().rows.map((row) => (
364
- <TableRow key={row.id}>
439
+ <TableRow
440
+ key={row.id}
441
+ data-row-id={row.id}
442
+ className={cn(
443
+ // A linha na mão fica esmaecida no lugar de origem e a de
444
+ // destino se acende: são as duas pontas do gesto, e sem a
445
+ // segunda não dá para saber onde a linha vai cair.
446
+ draggedRow === row.id && "opacity-40",
447
+ draggedRow && targetRow === row.id && draggedRow !== row.id
448
+ ? "bg-accent"
449
+ : undefined,
450
+ )}
451
+ >
452
+ {reorderable && (
453
+ <TableCell className="w-px pr-0">
454
+ <button
455
+ type="button"
456
+ aria-label={t("table.reorder")}
457
+ title={
458
+ canReorder
459
+ ? t("table.reorder")
460
+ : t("table.reorderSorted")
461
+ }
462
+ disabled={!canReorder}
463
+ // `touch-none` é o que impede a página de rolar embaixo
464
+ // do dedo enquanto a linha viaja.
465
+ className={cn(
466
+ "flex touch-none items-center text-muted-foreground",
467
+ canReorder
468
+ ? "cursor-grab active:cursor-grabbing hover:text-foreground"
469
+ : "cursor-not-allowed opacity-30",
470
+ )}
471
+ onPointerDown={
472
+ canReorder ? startRowDrag(row.id) : undefined
473
+ }
474
+ onPointerMove={canReorder ? moveRowDrag : undefined}
475
+ onPointerUp={canReorder ? endRowDrag : undefined}
476
+ onPointerCancel={canReorder ? endRowDrag : undefined}
477
+ >
478
+ <GripVertical className="h-4 w-4" />
479
+ </button>
480
+ </TableCell>
481
+ )}
365
482
  {row.getVisibleCells().map((cell) => (
366
483
  <TableCell
367
484
  key={cell.id}