rl-core-front 0.14.3 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/_services/api/schema.d.ts +286 -5
- package/src/components/app-shell.tsx +26 -1
- package/src/components/ui/create-button.tsx +64 -0
- package/src/components/ui/data-table.tsx +120 -3
- package/src/components/ui/dialog.tsx +27 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/input.tsx +73 -16
- package/src/features/queues/hooks/use-queue-jobs.ts +17 -4
- package/src/features/queues/queues-screen.tsx +56 -6
- package/src/features/queues/services/queues.service.ts +25 -6
- package/src/features/shortcuts/components/shortcut-card.tsx +97 -0
- package/src/features/shortcuts/shortcuts-screen.tsx +96 -0
- package/src/features/users/users-screen.tsx +12 -6
- package/src/hooks/use-shortcut.ts +267 -0
- package/src/i18n/messages/en.ts +40 -0
- package/src/i18n/messages/pt.ts +41 -0
- package/src/index.ts +10 -0
package/package.json
CHANGED
|
@@ -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"]["
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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
|
-
<
|
|
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
|
|
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
|
|
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}
|