@goplusvn/core 0.1.86 → 0.1.88
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/audit/__tests__/prisma-audit-extension.test.ts +113 -0
- package/src/audit/prisma-audit-extension.ts +41 -13
- package/src/configs/status.ts +67 -0
- package/src/cron/__tests__/cron-single-runner.test.ts +260 -0
- package/src/cron/cron-schedule.ts +113 -20
- package/src/cron/db-cron-manager.ts +90 -7
- package/src/cron/simple-cron-job.ts +17 -6
- package/src/crud/__tests__/status-boolean-display.test.tsx +186 -0
- package/src/crud/components/crud-detail-dialog.tsx +21 -8
- package/src/crud/components/crud-field-renderer.tsx +20 -13
- package/src/crud/components/crud-table.tsx +13 -8
- package/src/crud/lib/coerce.ts +36 -10
- package/src/import/import-dialog.tsx +39 -5
- package/src/types/index.ts +9 -9
- package/src/ui/data-display/data-table/data-table.tsx +32 -4
- package/src/ui/layout/user-dropdown.tsx +30 -23
- package/src/ui/primitives/status-badge.tsx +14 -10
- package/src/ui/shared/status-indicator.tsx +266 -74
- package/src/utils/index.ts +87 -38
- package/src/workspace/__tests__/workspace-delegation.test.ts +29 -11
|
@@ -9,7 +9,8 @@ import type { ChangeEvent } from "react";
|
|
|
9
9
|
|
|
10
10
|
import { dataLoader } from "../lib/data-loader";
|
|
11
11
|
import { formatFieldValue } from "../lib/field-formatter";
|
|
12
|
-
import {
|
|
12
|
+
import { resolveSwitchPair } from "../lib/coerce";
|
|
13
|
+
import { cn, matchesOptionValue } from "../../utils";
|
|
13
14
|
|
|
14
15
|
import { Button } from "../../ui";
|
|
15
16
|
import { Combobox } from "../../ui/primitives/client";
|
|
@@ -477,28 +478,33 @@ export function CrudFieldRenderer({
|
|
|
477
478
|
);
|
|
478
479
|
|
|
479
480
|
case "boolean":
|
|
480
|
-
case "switch":
|
|
481
|
+
case "switch": {
|
|
482
|
+
// CẶP GIÁ TRỊ BẬT/TẮT lấy từ chính FieldConfig, không đoán:
|
|
483
|
+
//
|
|
484
|
+
// - có `options` (thứ tự BẬT trước, TẮT sau — đúng quy ước của
|
|
485
|
+
// `coerceFieldValue`) ⇒ ghi đúng giá trị cột: "active"/"inactive",
|
|
486
|
+
// "1"/"0", hay bất kỳ cặp nào app khai;
|
|
487
|
+
// - không có `options` ⇒ cột Boolean, ghi thẳng true/false.
|
|
488
|
+
//
|
|
489
|
+
// Bản cũ ghi cứng chuỗi "active"/"inactive" và so `value === "active"`,
|
|
490
|
+
// nên với cột Boolean thì bản ghi đang BẬT vẫn hiện công tắc TẮT, bấm
|
|
491
|
+
// lưu lại nhét chuỗi vào cột boolean.
|
|
492
|
+
const { on: onValue, off: offValue } = resolveSwitchPair(field);
|
|
481
493
|
return (
|
|
482
494
|
<FormField
|
|
483
495
|
control={control}
|
|
484
496
|
name={field.name}
|
|
485
497
|
render={({ field: formField }) => (
|
|
486
498
|
<FormItem>
|
|
487
|
-
{field.label &&
|
|
499
|
+
{field.label && (
|
|
500
|
+
<FormLabel className="font-semibold">{field.label}</FormLabel>
|
|
501
|
+
)}
|
|
488
502
|
<FormControl>
|
|
489
503
|
<div className="flex items-center h-10">
|
|
490
504
|
<Switch
|
|
491
|
-
checked={
|
|
492
|
-
field.type === "switch"
|
|
493
|
-
? formField.value === "active"
|
|
494
|
-
: (formField.value ?? false)
|
|
495
|
-
}
|
|
505
|
+
checked={matchesOptionValue(onValue, formField.value)}
|
|
496
506
|
onCheckedChange={(checked) => {
|
|
497
|
-
|
|
498
|
-
formField.onChange(checked ? "active" : "inactive");
|
|
499
|
-
} else {
|
|
500
|
-
formField.onChange(checked);
|
|
501
|
-
}
|
|
507
|
+
formField.onChange(checked ? onValue : offValue);
|
|
502
508
|
}}
|
|
503
509
|
disabled={isFieldDisabled}
|
|
504
510
|
/>
|
|
@@ -512,6 +518,7 @@ export function CrudFieldRenderer({
|
|
|
512
518
|
)}
|
|
513
519
|
/>
|
|
514
520
|
);
|
|
521
|
+
}
|
|
515
522
|
|
|
516
523
|
case "date":
|
|
517
524
|
case "datetime":
|
|
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
|
|
|
5
5
|
import type { ColumnDef, Table } from "@tanstack/react-table";
|
|
6
6
|
|
|
7
7
|
import { StatusBadge } from "../../ui";
|
|
8
|
+
import { matchesOptionValue } from "../../utils";
|
|
8
9
|
|
|
9
10
|
import type { CrudResponse } from "../../types";
|
|
10
11
|
|
|
@@ -194,19 +195,23 @@ export function CrudTable<TData extends Record<string, unknown>>({
|
|
|
194
195
|
|
|
195
196
|
if (field.renderCell) {
|
|
196
197
|
content = field.renderCell(value, row.original) as any;
|
|
197
|
-
} else if (field.type === "switch") {
|
|
198
|
-
|
|
198
|
+
} else if (field.type === "switch" || field.type === "boolean") {
|
|
199
199
|
if (typeof value === "boolean" && !field.options) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
200
|
+
const label = value ? "Có" : "Không";
|
|
201
|
+
// Map boolean to status colors that StatusBadge likely supports
|
|
202
|
+
const status = value ? "active" : "inactive";
|
|
203
|
+
content = <StatusBadge status={status} label={label} />;
|
|
204
204
|
} else {
|
|
205
|
+
// SO LỎNG: cột `is_active` kiểu Boolean trả `true` trong khi
|
|
206
|
+
// option khai `"active"` — so tuyệt đối thì không khớp, nhãn
|
|
207
|
+
// rỗng và chip hiện đúng chữ "true". `matchesOptionValue` gom
|
|
208
|
+
// boolean/số/chuỗi về cùng key trước khi so.
|
|
205
209
|
const option = field.options?.find((opt) => {
|
|
206
210
|
const optValue = typeof opt === "object" ? opt.value : opt;
|
|
207
|
-
return optValue
|
|
211
|
+
return matchesOptionValue(optValue, value);
|
|
208
212
|
});
|
|
209
|
-
const label =
|
|
213
|
+
const label =
|
|
214
|
+
typeof option === "object" ? option.label : option;
|
|
210
215
|
// Translate label if translator provided and label is a valid string
|
|
211
216
|
const translatedLabel =
|
|
212
217
|
getTranslation && typeof label === "string"
|
package/src/crud/lib/coerce.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FieldConfig } from "../../types";
|
|
2
|
+
import { isTruthyStatus, matchesOptionValue } from "../../configs/status";
|
|
2
3
|
|
|
3
4
|
function optionValue(option: unknown): unknown {
|
|
4
5
|
return typeof option === "object" && option !== null && "value" in option
|
|
@@ -41,6 +42,12 @@ export function coerceFieldValue(field: FieldConfig, value: unknown): unknown {
|
|
|
41
42
|
// Cột Int đứng sau select/multiselect/relation (FK autoincrement): `type`
|
|
42
43
|
// không nói lên kiểu cột nên phải khai `valueType: "int"` trong FieldConfig.
|
|
43
44
|
// Chuỗi không phải số giữ nguyên cho tầng validate/Prisma báo.
|
|
45
|
+
// Cột Boolean đứng sau một field khai cặp option chữ: form/URL chở xuống
|
|
46
|
+
// "active" / "true" / "1", ép về boolean trước khi vào Prisma.
|
|
47
|
+
if (field.valueType === "boolean") {
|
|
48
|
+
return isTruthyStatus(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
44
51
|
if (field.valueType === "int" && typeof value === "string") {
|
|
45
52
|
if (value.trim() === "") return null;
|
|
46
53
|
const num = Number(value);
|
|
@@ -59,14 +66,6 @@ export function coerceFieldValue(field: FieldConfig, value: unknown): unknown {
|
|
|
59
66
|
|
|
60
67
|
case "boolean":
|
|
61
68
|
case "switch": {
|
|
62
|
-
let isTrue: boolean;
|
|
63
|
-
if (typeof value === "string") {
|
|
64
|
-
const lv = value.toLowerCase();
|
|
65
|
-
isTrue =
|
|
66
|
-
lv === "true" || lv === "active" || value === "1" || value === "on";
|
|
67
|
-
} else {
|
|
68
|
-
isTrue = Boolean(value);
|
|
69
|
-
}
|
|
70
69
|
// switch có cặp option = cột string 2 trạng thái (active/inactive…),
|
|
71
70
|
// map về value của option chứ không phải boolean.
|
|
72
71
|
if (
|
|
@@ -74,11 +73,20 @@ export function coerceFieldValue(field: FieldConfig, value: unknown): unknown {
|
|
|
74
73
|
field.options &&
|
|
75
74
|
field.options.length >= 2
|
|
76
75
|
) {
|
|
77
|
-
|
|
76
|
+
// Giá trị đã ĐÚNG là một option rồi thì giữ nguyên — chỉ những giá
|
|
77
|
+
// trị "lạ" mới quy về cặp bật/tắt. Trước đây mọi giá trị đều bị ép
|
|
78
|
+
// qua boolean, nên cặp option nhiều hơn hai trạng thái mất dữ liệu.
|
|
79
|
+
const exact = field.options.find((opt) =>
|
|
80
|
+
matchesOptionValue(optionValue(opt), value),
|
|
81
|
+
);
|
|
82
|
+
if (exact !== undefined) return optionValue(exact);
|
|
83
|
+
return isTruthyStatus(value)
|
|
78
84
|
? optionValue(field.options[0])
|
|
79
85
|
: optionValue(field.options[1]);
|
|
80
86
|
}
|
|
81
|
-
|
|
87
|
+
// `isTruthyStatus` nhận cả "active"/"true"/"1"/"on"/"yes" và loại
|
|
88
|
+
// "inactive"/"false"/"0"/"off" — cùng bảng chuẩn hoá với phía hiển thị.
|
|
89
|
+
return typeof value === "string" ? isTruthyStatus(value) : Boolean(value);
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
case "date":
|
|
@@ -93,3 +101,21 @@ export function coerceFieldValue(field: FieldConfig, value: unknown): unknown {
|
|
|
93
101
|
return value;
|
|
94
102
|
}
|
|
95
103
|
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* CẶP GIÁ TRỊ BẬT/TẮT của một field `switch` — dùng cho công tắc ở form, và
|
|
107
|
+
* là cặp mà `coerceFieldValue` sẽ ghi xuống DB, nên hai bên không lệch nhau.
|
|
108
|
+
*
|
|
109
|
+
* Quy ước: `options[0]` là BẬT, `options[1]` là TẮT (đúng thứ tự khai trong
|
|
110
|
+
* STATUS_OPTIONS). Không khai options ⇒ cột Boolean thật ⇒ true/false.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveSwitchPair(field: FieldConfig): {
|
|
113
|
+
on: unknown;
|
|
114
|
+
off: unknown;
|
|
115
|
+
} {
|
|
116
|
+
const options = field.options;
|
|
117
|
+
if (field.valueType === "boolean" || !options || options.length < 2) {
|
|
118
|
+
return { on: true, off: false };
|
|
119
|
+
}
|
|
120
|
+
return { on: optionValue(options[0]), off: optionValue(options[1]) };
|
|
121
|
+
}
|
|
@@ -94,12 +94,22 @@ export interface ImportDialogProps {
|
|
|
94
94
|
apiUrl: string;
|
|
95
95
|
/**
|
|
96
96
|
* Nguồn file mẫu — chọn MỘT:
|
|
97
|
-
* - `templateApiUrl`: GET mẫu do server dựng (nhiều sheet, chứng từ),
|
|
98
|
-
* - `config`: dựng mẫu ngay ở client từ EntityConfig
|
|
99
|
-
*
|
|
97
|
+
* - `templateApiUrl`: GET mẫu do server dựng (nhiều sheet, chứng từ),
|
|
98
|
+
* - `config`: dựng mẫu ngay ở client từ EntityConfig, hoặc
|
|
99
|
+
* - `onDownloadTemplate`: app tự dựng (mẫu phụ thuộc cấu hình đang chạy —
|
|
100
|
+
* danh sách ca, danh sách món… — nên không khai trước bằng config được,
|
|
101
|
+
* mà app lại không tự resolve được `xlsx`; core có).
|
|
100
102
|
*/
|
|
101
103
|
templateApiUrl?: string;
|
|
102
104
|
config?: EntityConfig;
|
|
105
|
+
onDownloadTemplate?: () => void | Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Nội dung riêng của app chèn NGAY DƯỚI khối kết quả — chỗ đặt nút "Tải file
|
|
108
|
+
* lỗi". Nhận nguyên văn thân JSON server trả, vì mỗi nghiệp vụ đính kèm một
|
|
109
|
+
* kiểu chi tiết khác nhau (dòng xem trước, lý do từng dòng…) mà dialog không
|
|
110
|
+
* nên biết.
|
|
111
|
+
*/
|
|
112
|
+
renderResultExtra?: (raw: unknown) => ReactNode;
|
|
103
113
|
/** Không có quyền nhập thì không render gì. */
|
|
104
114
|
canImport?: boolean;
|
|
105
115
|
onSuccess?: () => void;
|
|
@@ -132,6 +142,8 @@ export function ImportDialog({
|
|
|
132
142
|
apiUrl,
|
|
133
143
|
templateApiUrl,
|
|
134
144
|
config,
|
|
145
|
+
onDownloadTemplate,
|
|
146
|
+
renderResultExtra,
|
|
135
147
|
canImport = true,
|
|
136
148
|
onSuccess,
|
|
137
149
|
tasksPath,
|
|
@@ -140,6 +152,7 @@ export function ImportDialog({
|
|
|
140
152
|
const [file, setFile] = useState<File | null>(null);
|
|
141
153
|
const [loading, setLoading] = useState(false);
|
|
142
154
|
const [result, setResult] = useState<NormalizedResult | null>(null);
|
|
155
|
+
const [rawResult, setRawResult] = useState<unknown>(null);
|
|
143
156
|
const [format, setFormat] = useState<ImportOptions["format"]>("xlsx");
|
|
144
157
|
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
145
158
|
|
|
@@ -164,6 +177,7 @@ export function ImportDialog({
|
|
|
164
177
|
(selectedFile: File) => {
|
|
165
178
|
setFile(selectedFile);
|
|
166
179
|
setResult(null);
|
|
180
|
+
setRawResult(null);
|
|
167
181
|
if (configMode) {
|
|
168
182
|
const extension = selectedFile.name.split(".").pop()?.toLowerCase();
|
|
169
183
|
if (extension === "json") setFormat("json");
|
|
@@ -177,6 +191,7 @@ export function ImportDialog({
|
|
|
177
191
|
const clearSelectedFile = () => {
|
|
178
192
|
setFile(null);
|
|
179
193
|
setResult(null);
|
|
194
|
+
setRawResult(null);
|
|
180
195
|
};
|
|
181
196
|
|
|
182
197
|
const onDrop = useCallback(
|
|
@@ -216,6 +231,10 @@ export function ImportDialog({
|
|
|
216
231
|
|
|
217
232
|
const handleDownloadTemplate = async () => {
|
|
218
233
|
try {
|
|
234
|
+
if (onDownloadTemplate) {
|
|
235
|
+
await onDownloadTemplate();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
219
238
|
if (config) {
|
|
220
239
|
const blob = await generateXLSXTemplate(config);
|
|
221
240
|
triggerBlobDownload(blob, `${config.name}_template.xlsx`);
|
|
@@ -242,6 +261,7 @@ export function ImportDialog({
|
|
|
242
261
|
|
|
243
262
|
setLoading(true);
|
|
244
263
|
setResult(null);
|
|
264
|
+
setRawResult(null);
|
|
245
265
|
|
|
246
266
|
try {
|
|
247
267
|
// Chế độ config kiểm tra ở client trước, để người dùng thấy lỗi cột mà
|
|
@@ -297,6 +317,7 @@ export function ImportDialog({
|
|
|
297
317
|
setOpen(false);
|
|
298
318
|
setFile(null);
|
|
299
319
|
setResult(null);
|
|
320
|
+
setRawResult(null);
|
|
300
321
|
setLoading(false);
|
|
301
322
|
return;
|
|
302
323
|
}
|
|
@@ -307,6 +328,7 @@ export function ImportDialog({
|
|
|
307
328
|
|
|
308
329
|
const normalized = normalizeImportResult(raw);
|
|
309
330
|
setResult(normalized);
|
|
331
|
+
setRawResult(raw);
|
|
310
332
|
|
|
311
333
|
if (normalized.success && normalized.importedCount > 0) {
|
|
312
334
|
if (successTimeoutRef.current) clearTimeout(successTimeoutRef.current);
|
|
@@ -314,6 +336,7 @@ export function ImportDialog({
|
|
|
314
336
|
setOpen(false);
|
|
315
337
|
setFile(null);
|
|
316
338
|
setResult(null);
|
|
339
|
+
setRawResult(null);
|
|
317
340
|
onSuccess?.();
|
|
318
341
|
successTimeoutRef.current = null;
|
|
319
342
|
}, 2000);
|
|
@@ -353,6 +376,7 @@ export function ImportDialog({
|
|
|
353
376
|
if (!open) {
|
|
354
377
|
setFile(null);
|
|
355
378
|
setResult(null);
|
|
379
|
+
setRawResult(null);
|
|
356
380
|
}
|
|
357
381
|
}, [open]);
|
|
358
382
|
|
|
@@ -554,13 +578,23 @@ export function ImportDialog({
|
|
|
554
578
|
</AlertDescription>
|
|
555
579
|
</Alert>
|
|
556
580
|
)}
|
|
581
|
+
|
|
582
|
+
{result && renderResultExtra?.(rawResult)}
|
|
557
583
|
</div>
|
|
558
584
|
|
|
559
585
|
<DialogFooter className="flex flex-row sm:justify-end gap-2 space-x-0 pt-2">
|
|
560
|
-
<Button
|
|
586
|
+
<Button
|
|
587
|
+
variant="outline"
|
|
588
|
+
className="flex-1 sm:flex-none"
|
|
589
|
+
onClick={() => setOpen(false)}
|
|
590
|
+
>
|
|
561
591
|
Đóng
|
|
562
592
|
</Button>
|
|
563
|
-
<Button
|
|
593
|
+
<Button
|
|
594
|
+
className="flex-1 sm:flex-none"
|
|
595
|
+
onClick={handleImport}
|
|
596
|
+
disabled={!file || loading}
|
|
597
|
+
>
|
|
564
598
|
{loading ? "Đang import..." : "Import"}
|
|
565
599
|
</Button>
|
|
566
600
|
</DialogFooter>
|
package/src/types/index.ts
CHANGED
|
@@ -224,14 +224,12 @@ export interface NavigationRootItemBasicType {
|
|
|
224
224
|
action?: string;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
export interface NavigationRootItemWithHrefType
|
|
228
|
-
extends NavigationRootItemBasicType {
|
|
227
|
+
export interface NavigationRootItemWithHrefType extends NavigationRootItemBasicType {
|
|
229
228
|
href: string;
|
|
230
229
|
items?: never;
|
|
231
230
|
}
|
|
232
231
|
|
|
233
|
-
export interface NavigationRootItemWithItemsType
|
|
234
|
-
extends NavigationRootItemBasicType {
|
|
232
|
+
export interface NavigationRootItemWithItemsType extends NavigationRootItemBasicType {
|
|
235
233
|
items: (
|
|
236
234
|
| NavigationNestedItemWithHrefType
|
|
237
235
|
| NavigationNestedItemWithItemsType
|
|
@@ -247,14 +245,12 @@ export interface NavigationNestedItemBasicType {
|
|
|
247
245
|
iconName?: DynamicIconNameType;
|
|
248
246
|
}
|
|
249
247
|
|
|
250
|
-
export interface NavigationNestedItemWithHrefType
|
|
251
|
-
extends NavigationNestedItemBasicType {
|
|
248
|
+
export interface NavigationNestedItemWithHrefType extends NavigationNestedItemBasicType {
|
|
252
249
|
href: string;
|
|
253
250
|
items?: never;
|
|
254
251
|
}
|
|
255
252
|
|
|
256
|
-
export interface NavigationNestedItemWithItemsType
|
|
257
|
-
extends NavigationNestedItemBasicType {
|
|
253
|
+
export interface NavigationNestedItemWithItemsType extends NavigationNestedItemBasicType {
|
|
258
254
|
items: (
|
|
259
255
|
| NavigationNestedItemWithHrefType
|
|
260
256
|
| NavigationNestedItemWithItemsType
|
|
@@ -460,8 +456,12 @@ export interface FieldConfig {
|
|
|
460
456
|
* Int autoincrement hiện qua select/multiselect/relation: form và URL chở
|
|
461
457
|
* chuỗi "3", khai `valueType: "int"` để coercion tập trung (coerceFieldValue)
|
|
462
458
|
* ép Number trước khi vào Prisma. Default: "string" (giữ nguyên).
|
|
459
|
+
*
|
|
460
|
+
* `"boolean"` cho trường hợp ngược lại: field khai `type: "switch"` kèm cặp
|
|
461
|
+
* option chữ ("Hoạt động"/"Tạm ngưng") nhưng CỘT trong DB là Boolean — khai
|
|
462
|
+
* ra thì lưu xuống `true/false` chứ không nhét chuỗi "active" vào cột bool.
|
|
463
463
|
*/
|
|
464
|
-
valueType?: "string" | "int";
|
|
464
|
+
valueType?: "string" | "int" | "boolean";
|
|
465
465
|
required?: boolean;
|
|
466
466
|
defaultValue?: unknown;
|
|
467
467
|
placeholder?: string;
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getFilteredRowModel,
|
|
9
9
|
getPaginationRowModel,
|
|
10
10
|
getSortedRowModel,
|
|
11
|
+
getExpandedRowModel,
|
|
11
12
|
useReactTable,
|
|
12
13
|
} from "@tanstack/react-table";
|
|
13
14
|
import type {
|
|
@@ -138,6 +139,16 @@ export interface DataTableProps<TData> {
|
|
|
138
139
|
*/
|
|
139
140
|
className?: string;
|
|
140
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Determine if a row can be expanded
|
|
144
|
+
*/
|
|
145
|
+
getRowCanExpand?: (row: Row<TData>) => boolean;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Render custom sub-component (expanded row content)
|
|
149
|
+
*/
|
|
150
|
+
renderSubComponent?: (props: { row: Row<TData> }) => ReactNode;
|
|
151
|
+
|
|
141
152
|
/**
|
|
142
153
|
* Height configuration
|
|
143
154
|
* @default "auto"
|
|
@@ -190,6 +201,8 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
190
201
|
onTableReady,
|
|
191
202
|
onRowClick,
|
|
192
203
|
className,
|
|
204
|
+
getRowCanExpand,
|
|
205
|
+
renderSubComponent,
|
|
193
206
|
height = "auto",
|
|
194
207
|
headerClassName,
|
|
195
208
|
tableClassName,
|
|
@@ -360,6 +373,8 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
360
373
|
onSortingChange: handleSortingChange,
|
|
361
374
|
getSortedRowModel: getSortedRowModel(),
|
|
362
375
|
getFilteredRowModel: getFilteredRowModel(),
|
|
376
|
+
getExpandedRowModel: getExpandedRowModel(),
|
|
377
|
+
getRowCanExpand,
|
|
363
378
|
state: {
|
|
364
379
|
sorting: tanstackSorting,
|
|
365
380
|
// CHỈ đưa key `pagination` vào state khi controlled: `pagination: undefined`
|
|
@@ -471,6 +486,7 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
471
486
|
visibleCellsCount={row.getVisibleCells().length}
|
|
472
487
|
onRowClick={onRowClick}
|
|
473
488
|
cellClassName={cellClassName}
|
|
489
|
+
renderSubComponent={renderSubComponent}
|
|
474
490
|
/>
|
|
475
491
|
) : (
|
|
476
492
|
<MemoizedTableRow
|
|
@@ -481,6 +497,7 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
481
497
|
onRowClick={onRowClick}
|
|
482
498
|
cellClassName={cellClassName}
|
|
483
499
|
memoKey={rowMemo ? rowMemo(row.original) : undefined}
|
|
500
|
+
renderSubComponent={renderSubComponent}
|
|
484
501
|
/>
|
|
485
502
|
),
|
|
486
503
|
)
|
|
@@ -538,6 +555,7 @@ interface MemoizedTableRowProps<TData> {
|
|
|
538
555
|
cellClassName?: string;
|
|
539
556
|
/** Key từ `rowMemo(row.original)` — comparator so bằng Object.is. */
|
|
540
557
|
memoKey?: unknown;
|
|
558
|
+
renderSubComponent?: (props: { row: Row<TData> }) => ReactNode;
|
|
541
559
|
}
|
|
542
560
|
|
|
543
561
|
// Thân hàng KHÔNG memo — dùng trực tiếp khi rowMemo={false}, và là inner của
|
|
@@ -545,11 +563,14 @@ interface MemoizedTableRowProps<TData> {
|
|
|
545
563
|
function DataTableRowInner<TData>({
|
|
546
564
|
row,
|
|
547
565
|
isSelected,
|
|
566
|
+
visibleCellsCount,
|
|
548
567
|
onRowClick,
|
|
549
568
|
cellClassName,
|
|
569
|
+
renderSubComponent,
|
|
550
570
|
}: MemoizedTableRowProps<TData>) {
|
|
551
571
|
return (
|
|
552
|
-
|
|
572
|
+
<>
|
|
573
|
+
<TableRow
|
|
553
574
|
data-state={isSelected && "selected"}
|
|
554
575
|
className={`transition-colors duration-100 even:bg-muted/30 hover:bg-accent/50 dark:hover:bg-slate-800/40 data-[state=selected]:bg-primary/5 border-b border-border/40 dark:border-slate-800 relative hover:border-l-[3px] hover:border-l-primary ${
|
|
555
576
|
onRowClick ? "cursor-pointer" : ""
|
|
@@ -557,9 +578,6 @@ function DataTableRowInner<TData>({
|
|
|
557
578
|
onClick={
|
|
558
579
|
onRowClick
|
|
559
580
|
? (e) => {
|
|
560
|
-
// Don't trigger row click for interactive controls inside the row
|
|
561
|
-
// (selection checkbox, action menu trigger, links). The actions
|
|
562
|
-
// dropdown content is portaled, so only its trigger lives here.
|
|
563
581
|
const target = e.target as HTMLElement;
|
|
564
582
|
if (
|
|
565
583
|
target.closest(
|
|
@@ -587,6 +605,14 @@ function DataTableRowInner<TData>({
|
|
|
587
605
|
</TableCell>
|
|
588
606
|
))}
|
|
589
607
|
</TableRow>
|
|
608
|
+
{row.getIsExpanded() && renderSubComponent && (
|
|
609
|
+
<TableRow className="bg-muted/10 hover:bg-muted/10 border-b border-border/40">
|
|
610
|
+
<TableCell colSpan={visibleCellsCount} className="p-0">
|
|
611
|
+
{renderSubComponent({ row })}
|
|
612
|
+
</TableCell>
|
|
613
|
+
</TableRow>
|
|
614
|
+
)}
|
|
615
|
+
</>
|
|
590
616
|
);
|
|
591
617
|
}
|
|
592
618
|
|
|
@@ -602,6 +628,8 @@ const MemoizedTableRow = memo(
|
|
|
602
628
|
prevProps.row.original !== nextProps.row.original ||
|
|
603
629
|
// cellClassName đổi phải re-render (trước đây bị bỏ sót — đổi class không ăn)
|
|
604
630
|
prevProps.cellClassName !== nextProps.cellClassName ||
|
|
631
|
+
// Kiểm tra trạng thái expand
|
|
632
|
+
prevProps.row.getIsExpanded() !== nextProps.row.getIsExpanded() ||
|
|
605
633
|
// Key từ rowMemo(row.original): state ngoài row data mà cell phụ thuộc
|
|
606
634
|
!Object.is(prevProps.memoKey, nextProps.memoKey)
|
|
607
635
|
) {
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { useAuthBridge, useSafeAuthSession } from "../auth/auth-bridge";
|
|
5
|
-
import { LogOut, User, UserCog
|
|
6
|
-
} from "lucide-react";
|
|
5
|
+
import { LogOut, User, UserCog } from "lucide-react";
|
|
7
6
|
|
|
7
|
+
import { useMounted } from "../../hooks";
|
|
8
8
|
import type { DictionaryType } from "../../hooks";
|
|
9
9
|
import type { LocaleType } from "../../types";
|
|
10
10
|
|
|
@@ -16,7 +16,6 @@ import { Button } from "../primitives/button";
|
|
|
16
16
|
import { useTabContentCache } from "./tab-content-cache";
|
|
17
17
|
import { useTabNavigation } from "./tab-navigation-provider";
|
|
18
18
|
|
|
19
|
-
|
|
20
19
|
import {
|
|
21
20
|
DropdownMenu,
|
|
22
21
|
DropdownMenuContent,
|
|
@@ -78,28 +77,36 @@ export function UserDropdown({
|
|
|
78
77
|
}
|
|
79
78
|
};
|
|
80
79
|
|
|
80
|
+
const mounted = useMounted();
|
|
81
|
+
|
|
82
|
+
const triggerButton = (
|
|
83
|
+
<Button
|
|
84
|
+
variant="outline"
|
|
85
|
+
size="icon"
|
|
86
|
+
className="rounded-full bg-primary border-primary hover:bg-primary/90 focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
87
|
+
aria-label="User"
|
|
88
|
+
title="Profile"
|
|
89
|
+
>
|
|
90
|
+
<Avatar className="size-6">
|
|
91
|
+
<AvatarImage
|
|
92
|
+
src={user?.avatar || undefined}
|
|
93
|
+
alt={user?.name || ""}
|
|
94
|
+
className="object-cover"
|
|
95
|
+
/>
|
|
96
|
+
<AvatarFallback className="bg-primary text-white text-[10px] font-bold uppercase">
|
|
97
|
+
{user?.name && getInitials(user.name)}
|
|
98
|
+
</AvatarFallback>
|
|
99
|
+
</Avatar>
|
|
100
|
+
</Button>
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
if (!mounted) {
|
|
104
|
+
return triggerButton;
|
|
105
|
+
}
|
|
106
|
+
|
|
81
107
|
return (
|
|
82
108
|
<DropdownMenu>
|
|
83
|
-
<DropdownMenuTrigger asChild>
|
|
84
|
-
<Button
|
|
85
|
-
variant="outline"
|
|
86
|
-
size="icon"
|
|
87
|
-
className="rounded-full bg-primary border-primary hover:bg-primary/90 focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
88
|
-
aria-label="User"
|
|
89
|
-
title="Profile"
|
|
90
|
-
>
|
|
91
|
-
<Avatar className="size-6">
|
|
92
|
-
<AvatarImage
|
|
93
|
-
src={user?.avatar || undefined}
|
|
94
|
-
alt={user?.name || ""}
|
|
95
|
-
className="object-cover"
|
|
96
|
-
/>
|
|
97
|
-
<AvatarFallback className="bg-primary text-white text-[10px] font-bold uppercase">
|
|
98
|
-
{user?.name && getInitials(user.name)}
|
|
99
|
-
</AvatarFallback>
|
|
100
|
-
</Avatar>
|
|
101
|
-
</Button>
|
|
102
|
-
</DropdownMenuTrigger>
|
|
109
|
+
<DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>
|
|
103
110
|
|
|
104
111
|
<DropdownMenuContent
|
|
105
112
|
className="w-56 p-1 bg-background/80 backdrop-blur-xl border border-border/40 shadow-xl rounded-xl animate-in fade-in zoom-in-95 duration-200"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Badge } from "./badge";
|
|
2
|
-
import { STATUS_COLORS,
|
|
2
|
+
import { STATUS_COLORS, normalizeStatusValue } from "../../utils";
|
|
3
|
+
import { getStatusMeta } from "../shared/status-indicator";
|
|
3
4
|
import { cn } from "../../utils";
|
|
4
5
|
|
|
5
6
|
interface StatusBadgeProps {
|
|
@@ -13,8 +14,11 @@ export function StatusBadge({
|
|
|
13
14
|
className,
|
|
14
15
|
label: customLabel,
|
|
15
16
|
}: StatusBadgeProps) {
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
// Giá trị vào đây không chỉ là chuỗi: cột Boolean gửi `true`/`false`, cột
|
|
18
|
+
// smallint gửi 1/0. Tra bảng màu bằng chuỗi thô thì `"true"` không có trong
|
|
19
|
+
// STATUS_COLORS ⇒ chip xám, chữ hiện "true". Chuẩn hoá trước rồi mới tra.
|
|
20
|
+
const statusKey = normalizeStatusValue(status);
|
|
21
|
+
const variant = (statusKey && STATUS_COLORS[statusKey]) || "secondary";
|
|
18
22
|
|
|
19
23
|
// Custom styles for success (green) and warning (yellow) since they might not be in default badge variants
|
|
20
24
|
let badgeClass = "";
|
|
@@ -26,13 +30,13 @@ export function StatusBadge({
|
|
|
26
30
|
"bg-yellow-500 hover:bg-yellow-600 text-white border-transparent";
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
// Không có nhãn riêng thì lấy nhãn TIẾNG VIỆT của taxonomy chung
|
|
34
|
+
// (`getStatusMeta`) — trước đây chỗ này trả về chữ "Active", và với giá trị
|
|
35
|
+
// boolean thì rơi thẳng ra "true"/"false" trên màn hình.
|
|
36
|
+
const meta = statusKey ? getStatusMeta(statusKey) : null;
|
|
37
|
+
const label =
|
|
38
|
+
customLabel ??
|
|
39
|
+
(meta?.matched ? meta.label : statusKey ? String(status) : "");
|
|
36
40
|
|
|
37
41
|
return (
|
|
38
42
|
<Badge
|