@goplusvn/core 0.1.54 → 0.1.56
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/PLATFORM.md +44 -0
- package/features/README.md +6 -0
- package/features/audit-logs/README.md +18 -0
- package/features/audit-logs/migrations/0001_init.sql +33 -0
- package/features/audit-logs/schema.prisma +20 -0
- package/package.json +5 -1
- package/src/audit/__tests__/audit-context.test.ts +174 -0
- package/src/audit/__tests__/prisma-audit-extension.test.ts +426 -0
- package/src/audit/audit-actor.ts +42 -0
- package/src/audit/audit-context.ts +47 -0
- package/src/audit/entity-audit.ts +71 -0
- package/src/audit/index.ts +29 -10
- package/src/audit/prisma-audit-extension.ts +572 -0
- package/src/crud/components/crud-import-dialog.tsx +23 -421
- package/src/crud/components/crud-page.tsx +7 -12
- package/src/import/__tests__/import-dialog.test.tsx +141 -0
- package/src/import/__tests__/import-engine.test.ts +235 -0
- package/src/import/import-dialog.tsx +570 -0
- package/src/import/import-engine.ts +357 -0
- package/src/import/index.ts +39 -0
- package/src/import/types.ts +73 -0
- package/src/import/use-import.ts +156 -0
- package/src/system/pages/__tests__/system-audit-page.test.tsx +140 -0
- package/src/system/pages/system-audit-page.tsx +532 -0
- package/src/ui/filters/__tests__/advanced-filter-builder.test.tsx +194 -0
- package/src/ui/filters/advanced-filter-builder.tsx +380 -0
- package/src/ui/filters/index.ts +7 -0
- package/src/ui/index.tsx +1 -0
- package/src/ui/management/audit-log-page.tsx +12 -207
- package/src/audit/audit-manager.ts +0 -139
- package/src/audit/memory-audit-logger.ts +0 -86
- package/src/audit/types.ts +0 -50
- package/src/crud/crud-filters/checkbox-filter.tsx +0 -87
- package/src/crud/crud-filters/datetime-filter.tsx +0 -82
- package/src/crud/crud-filters/filter-builder.tsx +0 -64
- package/src/crud/crud-filters/index.tsx +0 -78
- package/src/crud/crud-filters/radio-filter.tsx +0 -79
- package/src/crud/crud-filters/select-filter.tsx +0 -148
- package/src/crud/crud-filters/text-filter.tsx +0 -81
|
@@ -1,33 +1,18 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
} from "../lib/import-export-service";
|
|
17
|
-
|
|
18
|
-
import { Alert, AlertDescription } from "../../ui";
|
|
19
|
-
import { Button } from "../../ui";
|
|
20
|
-
import {
|
|
21
|
-
Dialog,
|
|
22
|
-
DialogContent,
|
|
23
|
-
DialogDescription,
|
|
24
|
-
DialogFooter,
|
|
25
|
-
DialogHeader,
|
|
26
|
-
DialogTitle,
|
|
27
|
-
DialogTrigger,
|
|
28
|
-
} from "../../ui";
|
|
29
|
-
|
|
30
|
-
interface CrudImportDialogProps {
|
|
3
|
+
import type { EntityConfig } from "../../types";
|
|
4
|
+
|
|
5
|
+
import { ImportDialog } from "../../import/import-dialog";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @deprecated Dùng `ImportDialog` của `@goerp/core/import`. Giữ lại tên và
|
|
9
|
+
* chữ ký cũ để các app đang gọi (`endpoint` thay vì `apiUrl`) không vỡ.
|
|
10
|
+
*
|
|
11
|
+
* Bản cũ có LỖI THẬT: `if (!canImport) return null` nằm TRƯỚC `useMemo`/
|
|
12
|
+
* `useCallback`, nên khi quyền đổi false → true React ném "Rendered more hooks
|
|
13
|
+
* than during the previous render". `ImportDialog` đặt cổng quyền sau mọi hook.
|
|
14
|
+
*/
|
|
15
|
+
export interface CrudImportDialogProps {
|
|
31
16
|
config: EntityConfig;
|
|
32
17
|
endpoint: string;
|
|
33
18
|
canImport?: boolean;
|
|
@@ -41,400 +26,17 @@ export function CrudImportDialog({
|
|
|
41
26
|
endpoint,
|
|
42
27
|
canImport = true,
|
|
43
28
|
onSuccess,
|
|
44
|
-
open
|
|
45
|
-
onOpenChange
|
|
29
|
+
open,
|
|
30
|
+
onOpenChange,
|
|
46
31
|
}: CrudImportDialogProps) {
|
|
47
|
-
const [internalOpen, setInternalOpen] = useState(false);
|
|
48
|
-
const [file, setFile] = useState<File | null>(null);
|
|
49
|
-
const [loading, setLoading] = useState(false);
|
|
50
|
-
const [result, setResult] = useState<ImportResult | null>(null);
|
|
51
|
-
const [format, setFormat] = useState<ImportOptions["format"]>("xlsx");
|
|
52
|
-
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
53
|
-
|
|
54
|
-
const isControlled = controlledOpen !== undefined;
|
|
55
|
-
const open = isControlled ? controlledOpen : internalOpen;
|
|
56
|
-
const setOpen = isControlled ? controlledOnOpenChange! : setInternalOpen;
|
|
57
|
-
|
|
58
|
-
if (!canImport) {
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const requiredLabels = useMemo(() => {
|
|
63
|
-
return config.fields
|
|
64
|
-
.filter((f) => !f.hideInForm && f.required)
|
|
65
|
-
.map((f) => f.label);
|
|
66
|
-
}, [config.fields]);
|
|
67
|
-
|
|
68
|
-
const handleFileSelect = useCallback((selectedFile: File) => {
|
|
69
|
-
setFile(selectedFile);
|
|
70
|
-
setResult(null);
|
|
71
|
-
|
|
72
|
-
// Detect format from file extension
|
|
73
|
-
const extension = selectedFile.name.split(".").pop()?.toLowerCase();
|
|
74
|
-
if (extension === "xlsx" || extension === "xls") {
|
|
75
|
-
setFormat("xlsx");
|
|
76
|
-
} else if (extension === "json") {
|
|
77
|
-
setFormat("json");
|
|
78
|
-
} else if (extension === "csv") {
|
|
79
|
-
setFormat("csv");
|
|
80
|
-
} else {
|
|
81
|
-
setFormat("xlsx");
|
|
82
|
-
}
|
|
83
|
-
}, []);
|
|
84
|
-
|
|
85
|
-
const clearSelectedFile = () => {
|
|
86
|
-
setFile(null);
|
|
87
|
-
setResult(null);
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
const onDrop = useCallback(
|
|
91
|
-
(acceptedFiles: File[]) => {
|
|
92
|
-
const first = acceptedFiles[0];
|
|
93
|
-
if (first) {
|
|
94
|
-
handleFileSelect(first);
|
|
95
|
-
}
|
|
96
|
-
},
|
|
97
|
-
[handleFileSelect],
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
const {
|
|
101
|
-
getRootProps,
|
|
102
|
-
getInputProps,
|
|
103
|
-
isDragActive,
|
|
104
|
-
open: openFilePicker,
|
|
105
|
-
} = useDropzone({
|
|
106
|
-
onDrop,
|
|
107
|
-
multiple: false,
|
|
108
|
-
maxFiles: 1,
|
|
109
|
-
noClick: true,
|
|
110
|
-
accept: {
|
|
111
|
-
"text/csv": [".csv"],
|
|
112
|
-
"application/json": [".json"],
|
|
113
|
-
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
|
|
114
|
-
".xlsx",
|
|
115
|
-
],
|
|
116
|
-
"application/vnd.ms-excel": [".xls"],
|
|
117
|
-
},
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
const handleDownloadTemplate = async (
|
|
121
|
-
templateFormat: ImportOptions["format"] = format,
|
|
122
|
-
) => {
|
|
123
|
-
try {
|
|
124
|
-
let blob: Blob;
|
|
125
|
-
let filename: string;
|
|
126
|
-
|
|
127
|
-
switch (templateFormat) {
|
|
128
|
-
case "csv": {
|
|
129
|
-
const csvContent = generateCSVTemplate(config);
|
|
130
|
-
blob = new Blob([csvContent], { type: "text/csv" });
|
|
131
|
-
filename = `${config.name}_template.csv`;
|
|
132
|
-
break;
|
|
133
|
-
}
|
|
134
|
-
case "json": {
|
|
135
|
-
const jsonContent = JSON.stringify(
|
|
136
|
-
generateJSONTemplate(config),
|
|
137
|
-
null,
|
|
138
|
-
2,
|
|
139
|
-
);
|
|
140
|
-
blob = new Blob([jsonContent], { type: "application/json" });
|
|
141
|
-
filename = `${config.name}_template.json`;
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
case "xlsx": {
|
|
145
|
-
blob = await generateXLSXTemplate(config);
|
|
146
|
-
filename = `${config.name}_template.xlsx`;
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
downloadFile(blob, filename);
|
|
152
|
-
} catch (error) {
|
|
153
|
-
console.error("Error generating template:", error);
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
const handleQuickDownloadTemplate = async (
|
|
158
|
-
nextFormat: ImportOptions["format"],
|
|
159
|
-
) => {
|
|
160
|
-
setFormat(nextFormat);
|
|
161
|
-
await handleDownloadTemplate(nextFormat);
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
const handleImport = async () => {
|
|
165
|
-
if (!file) return;
|
|
166
|
-
|
|
167
|
-
setLoading(true);
|
|
168
|
-
setResult(null);
|
|
169
|
-
|
|
170
|
-
try {
|
|
171
|
-
// Parse file
|
|
172
|
-
const data = await parseFile(file, format);
|
|
173
|
-
|
|
174
|
-
// Validate data
|
|
175
|
-
const validationResult = validateImportData(data, config, {
|
|
176
|
-
format,
|
|
177
|
-
skipErrors: false,
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
if (!validationResult.success && validationResult.errors.length > 0) {
|
|
181
|
-
setResult(validationResult);
|
|
182
|
-
setLoading(false);
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Parse endpoint to preserve query params (e.g., supplierId)
|
|
187
|
-
const url = new URL(endpoint, window.location.origin);
|
|
188
|
-
const importUrl = `${url.pathname}${url.search}`;
|
|
189
|
-
|
|
190
|
-
// Upload to API
|
|
191
|
-
const formData = new FormData();
|
|
192
|
-
formData.append("file", file);
|
|
193
|
-
formData.append("format", format);
|
|
194
|
-
|
|
195
|
-
const response = await fetch(importUrl, {
|
|
196
|
-
method: "POST",
|
|
197
|
-
body: formData,
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
const importResult = await response.json();
|
|
201
|
-
|
|
202
|
-
if (!response.ok) {
|
|
203
|
-
// API returned an error - show the error message if available
|
|
204
|
-
const errorMessage =
|
|
205
|
-
importResult?.errors?.[0]?.message ||
|
|
206
|
-
importResult?.error ||
|
|
207
|
-
`Import failed with status ${response.status}`;
|
|
208
|
-
throw new Error(errorMessage);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
setResult(importResult);
|
|
212
|
-
|
|
213
|
-
if (importResult.success) {
|
|
214
|
-
// Clear any existing timeout
|
|
215
|
-
if (successTimeoutRef.current) {
|
|
216
|
-
clearTimeout(successTimeoutRef.current);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
successTimeoutRef.current = setTimeout(() => {
|
|
220
|
-
setOpen(false);
|
|
221
|
-
setFile(null);
|
|
222
|
-
setResult(null);
|
|
223
|
-
onSuccess?.();
|
|
224
|
-
successTimeoutRef.current = null;
|
|
225
|
-
}, 2000);
|
|
226
|
-
}
|
|
227
|
-
} catch (error) {
|
|
228
|
-
console.error("Import error:", error);
|
|
229
|
-
setResult({
|
|
230
|
-
success: false,
|
|
231
|
-
imported: 0,
|
|
232
|
-
failed: 0,
|
|
233
|
-
errors: [
|
|
234
|
-
{
|
|
235
|
-
row: 0,
|
|
236
|
-
field: "",
|
|
237
|
-
message: error instanceof Error ? error.message : "Import failed",
|
|
238
|
-
},
|
|
239
|
-
],
|
|
240
|
-
});
|
|
241
|
-
} finally {
|
|
242
|
-
setLoading(false);
|
|
243
|
-
}
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
// Cleanup timeout when component unmounts or dialog closes
|
|
247
|
-
useEffect(() => {
|
|
248
|
-
return () => {
|
|
249
|
-
if (successTimeoutRef.current) {
|
|
250
|
-
clearTimeout(successTimeoutRef.current);
|
|
251
|
-
successTimeoutRef.current = null;
|
|
252
|
-
}
|
|
253
|
-
};
|
|
254
|
-
}, []);
|
|
255
|
-
|
|
256
32
|
return (
|
|
257
|
-
<
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
<DialogHeader>
|
|
266
|
-
<DialogTitle>Import {config.pluralLabel}</DialogTitle>
|
|
267
|
-
<DialogDescription>
|
|
268
|
-
Làm theo 2 bước: tải file mẫu, điền dữ liệu rồi chọn file để import.
|
|
269
|
-
</DialogDescription>
|
|
270
|
-
</DialogHeader>
|
|
271
|
-
|
|
272
|
-
<div className="space-y-4">
|
|
273
|
-
{/* Step 1: Template */}
|
|
274
|
-
<div className="rounded-lg border bg-muted/20 p-4 space-y-3">
|
|
275
|
-
<div className="flex items-start justify-between gap-3">
|
|
276
|
-
<div className="min-w-0">
|
|
277
|
-
<div className="flex items-center gap-2">
|
|
278
|
-
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
279
|
-
<p className="font-medium">Bước 1: Tải file mẫu</p>
|
|
280
|
-
</div>
|
|
281
|
-
<p className="text-sm text-muted-foreground mt-1">
|
|
282
|
-
Tải mẫu đúng cột, điền dữ liệu rồi quay lại chọn file để
|
|
283
|
-
import.
|
|
284
|
-
</p>
|
|
285
|
-
</div>
|
|
286
|
-
<div className="shrink-0 text-xs text-muted-foreground">
|
|
287
|
-
Định dạng:{" "}
|
|
288
|
-
<span className="font-medium uppercase">{format}</span>
|
|
289
|
-
</div>
|
|
290
|
-
</div>
|
|
291
|
-
|
|
292
|
-
<div className="flex flex-wrap gap-2">
|
|
293
|
-
<Button
|
|
294
|
-
type="button"
|
|
295
|
-
variant={format === "xlsx" ? "default" : "outline"}
|
|
296
|
-
size="sm"
|
|
297
|
-
onClick={() => handleQuickDownloadTemplate("xlsx")}
|
|
298
|
-
>
|
|
299
|
-
<Download className="mr-2 h-4 w-4" />
|
|
300
|
-
Tải mẫu XLSX
|
|
301
|
-
</Button>
|
|
302
|
-
{/* <Button
|
|
303
|
-
type="button"
|
|
304
|
-
variant={format === "csv" ? "default" : "outline"}
|
|
305
|
-
size="sm"
|
|
306
|
-
onClick={() => handleQuickDownloadTemplate("csv")}
|
|
307
|
-
>
|
|
308
|
-
<Download className="mr-2 h-4 w-4" />
|
|
309
|
-
Tải mẫu CSV
|
|
310
|
-
</Button>
|
|
311
|
-
<Button
|
|
312
|
-
type="button"
|
|
313
|
-
variant={format === "json" ? "default" : "outline"}
|
|
314
|
-
size="sm"
|
|
315
|
-
onClick={() => handleQuickDownloadTemplate("json")}
|
|
316
|
-
>
|
|
317
|
-
<Download className="mr-2 h-4 w-4" />
|
|
318
|
-
Tải mẫu JSON
|
|
319
|
-
</Button> */}
|
|
320
|
-
</div>
|
|
321
|
-
|
|
322
|
-
{requiredLabels.length > 0 && (
|
|
323
|
-
<p className="text-xs text-muted-foreground">
|
|
324
|
-
Trường bắt buộc:{" "}
|
|
325
|
-
<span className="font-medium">{requiredLabels.join(", ")}</span>
|
|
326
|
-
</p>
|
|
327
|
-
)}
|
|
328
|
-
</div>
|
|
329
|
-
|
|
330
|
-
{/* Step 2: Upload */}
|
|
331
|
-
<div className="rounded-lg border p-4 space-y-3">
|
|
332
|
-
<div className="flex items-start justify-between gap-3">
|
|
333
|
-
<div className="min-w-0">
|
|
334
|
-
<div className="flex items-center gap-2">
|
|
335
|
-
<Upload className="h-4 w-4 text-muted-foreground" />
|
|
336
|
-
<p className="font-medium">Bước 2: Chọn file để import</p>
|
|
337
|
-
</div>
|
|
338
|
-
<p className="text-sm text-muted-foreground mt-1">
|
|
339
|
-
Kéo thả file vào vùng bên dưới hoặc bấm “Chọn file”.
|
|
340
|
-
</p>
|
|
341
|
-
</div>
|
|
342
|
-
<Button
|
|
343
|
-
type="button"
|
|
344
|
-
variant="outline"
|
|
345
|
-
size="sm"
|
|
346
|
-
onClick={openFilePicker}
|
|
347
|
-
>
|
|
348
|
-
Chọn file
|
|
349
|
-
</Button>
|
|
350
|
-
</div>
|
|
351
|
-
|
|
352
|
-
{/* Omit popover to fix React 18 type mismatch */}
|
|
353
|
-
<div
|
|
354
|
-
{...(() => {
|
|
355
|
-
const { popover, ...rest } = getRootProps() as any;
|
|
356
|
-
return rest;
|
|
357
|
-
})()}
|
|
358
|
-
className={[
|
|
359
|
-
"rounded-lg border-2 border-dashed p-4 transition-colors",
|
|
360
|
-
"bg-background hover:bg-muted/30",
|
|
361
|
-
isDragActive ? "border-primary bg-muted/30" : "border-input",
|
|
362
|
-
].join(" ")}
|
|
363
|
-
>
|
|
364
|
-
<input
|
|
365
|
-
{...(() => {
|
|
366
|
-
const { popover, ...rest } = getInputProps() as any;
|
|
367
|
-
return rest;
|
|
368
|
-
})()}
|
|
369
|
-
/>
|
|
370
|
-
{file ? (
|
|
371
|
-
<div className="flex items-center justify-between gap-3">
|
|
372
|
-
<div className="min-w-0">
|
|
373
|
-
<p className="text-sm font-medium truncate">{file.name}</p>
|
|
374
|
-
<p className="text-xs text-muted-foreground">
|
|
375
|
-
Đã nhận diện định dạng:{" "}
|
|
376
|
-
<span className="font-medium uppercase">{format}</span>
|
|
377
|
-
</p>
|
|
378
|
-
</div>
|
|
379
|
-
<Button
|
|
380
|
-
type="button"
|
|
381
|
-
variant="secondary"
|
|
382
|
-
size="icon"
|
|
383
|
-
onClick={clearSelectedFile}
|
|
384
|
-
aria-label="Remove"
|
|
385
|
-
>
|
|
386
|
-
<X className="h-4 w-4" />
|
|
387
|
-
</Button>
|
|
388
|
-
</div>
|
|
389
|
-
) : (
|
|
390
|
-
<div className="py-6 text-center">
|
|
391
|
-
<p className="text-sm font-medium">
|
|
392
|
-
Kéo thả file vào đây để import
|
|
393
|
-
</p>
|
|
394
|
-
<p className="text-xs text-muted-foreground mt-1">
|
|
395
|
-
Hỗ trợ: .xlsx, .csv, .json
|
|
396
|
-
</p>
|
|
397
|
-
</div>
|
|
398
|
-
)}
|
|
399
|
-
</div>
|
|
400
|
-
</div>
|
|
401
|
-
|
|
402
|
-
{result && (
|
|
403
|
-
<Alert variant={result.success ? "default" : "destructive"}>
|
|
404
|
-
<AlertCircle className="h-4 w-4" />
|
|
405
|
-
<AlertDescription>
|
|
406
|
-
<div className="space-y-1">
|
|
407
|
-
<div>Import thành công: {result.imported}</div>
|
|
408
|
-
<div>Lỗi: {result.failed}</div>
|
|
409
|
-
{result.errors.length > 0 && (
|
|
410
|
-
<div className="mt-2 max-h-32 overflow-y-auto">
|
|
411
|
-
{result.errors.slice(0, 5).map((error, index) => (
|
|
412
|
-
<div key={index} className="text-xs">
|
|
413
|
-
Dòng {error.row}: {error.message}
|
|
414
|
-
</div>
|
|
415
|
-
))}
|
|
416
|
-
{result.errors.length > 5 && (
|
|
417
|
-
<div className="text-xs">
|
|
418
|
-
... và {result.errors.length - 5} lỗi khác
|
|
419
|
-
</div>
|
|
420
|
-
)}
|
|
421
|
-
</div>
|
|
422
|
-
)}
|
|
423
|
-
</div>
|
|
424
|
-
</AlertDescription>
|
|
425
|
-
</Alert>
|
|
426
|
-
)}
|
|
427
|
-
</div>
|
|
428
|
-
|
|
429
|
-
<DialogFooter>
|
|
430
|
-
<Button variant="outline" onClick={() => setOpen(false)}>
|
|
431
|
-
Đóng
|
|
432
|
-
</Button>
|
|
433
|
-
<Button onClick={handleImport} disabled={!file || loading}>
|
|
434
|
-
{loading ? "Đang import..." : "Import"}
|
|
435
|
-
</Button>
|
|
436
|
-
</DialogFooter>
|
|
437
|
-
</DialogContent>
|
|
438
|
-
</Dialog>
|
|
33
|
+
<ImportDialog
|
|
34
|
+
apiUrl={endpoint}
|
|
35
|
+
config={config}
|
|
36
|
+
canImport={canImport}
|
|
37
|
+
onSuccess={onSuccess}
|
|
38
|
+
open={open}
|
|
39
|
+
onOpenChange={onOpenChange}
|
|
40
|
+
/>
|
|
439
41
|
);
|
|
440
42
|
}
|
|
@@ -91,10 +91,10 @@ const CrudSheet = dynamic(
|
|
|
91
91
|
},
|
|
92
92
|
);
|
|
93
93
|
|
|
94
|
-
const
|
|
94
|
+
const ImportDialog = dynamic(
|
|
95
95
|
() =>
|
|
96
|
-
import("
|
|
97
|
-
default: m.
|
|
96
|
+
import("../../import/import-dialog").then((m) => ({
|
|
97
|
+
default: m.ImportDialog,
|
|
98
98
|
})),
|
|
99
99
|
{
|
|
100
100
|
ssr: false, // Import dialog doesn't need SSR
|
|
@@ -139,7 +139,6 @@ function CrudPageContent({
|
|
|
139
139
|
dictionary,
|
|
140
140
|
customActions,
|
|
141
141
|
}: CrudPageContentProps) {
|
|
142
|
-
|
|
143
142
|
const router = useRouter();
|
|
144
143
|
const params = useParams();
|
|
145
144
|
const lang = (params?.lang as string) || "vi";
|
|
@@ -861,9 +860,9 @@ function CrudPageContent({
|
|
|
861
860
|
<div className="flex items-center gap-2 flex-wrap sm:flex-nowrap">
|
|
862
861
|
{permissions.import && config.features?.import && (
|
|
863
862
|
<Suspense fallback={null}>
|
|
864
|
-
<
|
|
863
|
+
<ImportDialog
|
|
865
864
|
config={config}
|
|
866
|
-
|
|
865
|
+
apiUrl={(() => {
|
|
867
866
|
// Handle endpoint with query params correctly
|
|
868
867
|
const baseUrl = config.apiEndpoint.split("?")[0];
|
|
869
868
|
const queryParams = config.apiEndpoint.includes("?")
|
|
@@ -913,9 +912,7 @@ function CrudPageContent({
|
|
|
913
912
|
onEdit={handleEdit}
|
|
914
913
|
onDelete={handleDelete}
|
|
915
914
|
onCustomAction={handleCustomAction}
|
|
916
|
-
onRowClick={
|
|
917
|
-
showDetailOnRowClick ? handleRowClick : undefined
|
|
918
|
-
}
|
|
915
|
+
onRowClick={showDetailOnRowClick ? handleRowClick : undefined}
|
|
919
916
|
onTableReady={setTableInstance}
|
|
920
917
|
onEmptyStateAction={{
|
|
921
918
|
onCreate: handleCreate,
|
|
@@ -934,9 +931,7 @@ function CrudPageContent({
|
|
|
934
931
|
onEdit={handleEdit}
|
|
935
932
|
onDelete={handleDelete}
|
|
936
933
|
onCustomAction={handleCustomAction}
|
|
937
|
-
onRowClick={
|
|
938
|
-
showDetailOnRowClick ? handleRowClick : undefined
|
|
939
|
-
}
|
|
934
|
+
onRowClick={showDetailOnRowClick ? handleRowClick : undefined}
|
|
940
935
|
onEmptyStateAction={{
|
|
941
936
|
onCreate: handleCreate,
|
|
942
937
|
onClearSearch: () => setSearch(""),
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hộp thoại nhập hợp nhất. Ba điều dễ vỡ:
|
|
3
|
+
* - cổng quyền phải nằm SAU mọi hook (bản CrudImportDialog cũ đặt trước nên
|
|
4
|
+
* khi quyền đổi false → true React ném "Rendered more hooks…"),
|
|
5
|
+
* - hai hợp đồng server (harness workbook và CRUD theo EntityConfig) phải quy
|
|
6
|
+
* về một mô hình hiển thị,
|
|
7
|
+
* - 202 nghĩa là nhập nền: đóng dialog, KHÔNG hiện báo cáo "0 dòng".
|
|
8
|
+
*/
|
|
9
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
10
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
|
|
12
|
+
import { ImportDialog, normalizeImportResult } from "../import-dialog";
|
|
13
|
+
|
|
14
|
+
const toastSuccess = vi.fn();
|
|
15
|
+
vi.mock("sonner", () => ({
|
|
16
|
+
toast: {
|
|
17
|
+
success: (...args: unknown[]) => toastSuccess(...args),
|
|
18
|
+
error: vi.fn(),
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
toastSuccess.mockClear();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("normalizeImportResult", () => {
|
|
28
|
+
it("đọc được hợp đồng harness workbook", () => {
|
|
29
|
+
expect(
|
|
30
|
+
normalizeImportResult({
|
|
31
|
+
success: true,
|
|
32
|
+
totalRows: 10,
|
|
33
|
+
importedCount: 8,
|
|
34
|
+
skippedCount: 2,
|
|
35
|
+
errors: [{ row: 3, column: "Số lượng", message: "phải là số" }],
|
|
36
|
+
warnings: [{ row: 4, message: "trùng mã" }],
|
|
37
|
+
}),
|
|
38
|
+
).toEqual({
|
|
39
|
+
success: true,
|
|
40
|
+
totalRows: 10,
|
|
41
|
+
importedCount: 8,
|
|
42
|
+
skippedCount: 2,
|
|
43
|
+
errors: [{ row: 3, label: "Số lượng", message: "phải là số" }],
|
|
44
|
+
warnings: [{ row: 4, message: "trùng mã" }],
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("đọc được hợp đồng CRUD (imported/failed/field) và tự suy totalRows", () => {
|
|
49
|
+
const r = normalizeImportResult({
|
|
50
|
+
success: false,
|
|
51
|
+
imported: 3,
|
|
52
|
+
failed: 1,
|
|
53
|
+
errors: [{ row: 2, field: "email", message: "sai định dạng" }],
|
|
54
|
+
});
|
|
55
|
+
expect(r.importedCount).toBe(3);
|
|
56
|
+
expect(r.skippedCount).toBe(1);
|
|
57
|
+
expect(r.totalRows).toBe(4);
|
|
58
|
+
expect(r.errors[0].label).toBe("email");
|
|
59
|
+
expect(r.warnings).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("lỗi không kèm cột lẫn field thì nhãn để trống, không bịa", () => {
|
|
63
|
+
const r = normalizeImportResult({
|
|
64
|
+
success: false,
|
|
65
|
+
errors: [{ row: 0, message: "File rỗng" }],
|
|
66
|
+
});
|
|
67
|
+
expect(r.errors[0]).toEqual({
|
|
68
|
+
row: 0,
|
|
69
|
+
label: undefined,
|
|
70
|
+
message: "File rỗng",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("chịu được phản hồi rỗng/rác mà không ném", () => {
|
|
75
|
+
expect(normalizeImportResult(null)).toMatchObject({
|
|
76
|
+
success: false,
|
|
77
|
+
importedCount: 0,
|
|
78
|
+
errors: [],
|
|
79
|
+
});
|
|
80
|
+
expect(normalizeImportResult({ errors: "x" }).errors).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("ImportDialog", () => {
|
|
85
|
+
it("không có quyền thì không render gì, và BẬT quyền giữa chừng không vỡ hook", () => {
|
|
86
|
+
const { rerender, container } = render(
|
|
87
|
+
<ImportDialog apiUrl="/api/x/import" canImport={false} />,
|
|
88
|
+
);
|
|
89
|
+
expect(container.innerHTML).toBe("");
|
|
90
|
+
|
|
91
|
+
// Chính là ca làm vỡ bản cũ (early return đứng trước useMemo/useCallback).
|
|
92
|
+
rerender(<ImportDialog apiUrl="/api/x/import" canImport />);
|
|
93
|
+
expect(screen.getByRole("button", { name: "Import" })).toBeTruthy();
|
|
94
|
+
|
|
95
|
+
rerender(<ImportDialog apiUrl="/api/x/import" canImport={false} />);
|
|
96
|
+
expect(container.innerHTML).toBe("");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("chế độ điều khiển ngoài: mở sẵn, không render nút kích hoạt", () => {
|
|
100
|
+
render(
|
|
101
|
+
<ImportDialog apiUrl="/api/x/import" open onOpenChange={() => {}} />,
|
|
102
|
+
);
|
|
103
|
+
expect(screen.getByText("Import dữ liệu")).toBeTruthy();
|
|
104
|
+
expect(screen.getByText("Bước 1: Tải file mẫu")).toBeTruthy();
|
|
105
|
+
|
|
106
|
+
// Chỉ còn nút gửi trong footer (đang tắt vì chưa chọn file) — không có nút mở.
|
|
107
|
+
const importButtons = screen.getAllByRole("button", { name: "Import" });
|
|
108
|
+
expect(importButtons).toHaveLength(1);
|
|
109
|
+
expect((importButtons[0] as HTMLButtonElement).disabled).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("tải mẫu từ server lấy tên file theo content-disposition", async () => {
|
|
113
|
+
const createUrl = vi
|
|
114
|
+
.spyOn(URL, "createObjectURL")
|
|
115
|
+
.mockReturnValue("blob:x");
|
|
116
|
+
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
|
117
|
+
global.fetch = vi.fn(
|
|
118
|
+
async () =>
|
|
119
|
+
new Response(new Blob(["x"]), {
|
|
120
|
+
headers: {
|
|
121
|
+
"content-disposition": 'attachment; filename="mau-don-mua.xlsx"',
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
) as unknown as typeof fetch;
|
|
125
|
+
|
|
126
|
+
render(
|
|
127
|
+
<ImportDialog
|
|
128
|
+
apiUrl="/api/purchase-orders/import"
|
|
129
|
+
templateApiUrl="/api/purchase-orders/import/template"
|
|
130
|
+
open
|
|
131
|
+
onOpenChange={() => {}}
|
|
132
|
+
/>,
|
|
133
|
+
);
|
|
134
|
+
screen.getByText("Tải mẫu XLSX").click();
|
|
135
|
+
|
|
136
|
+
await waitFor(() => expect(createUrl).toHaveBeenCalled());
|
|
137
|
+
expect(String((global.fetch as any).mock.calls[0][0])).toContain(
|
|
138
|
+
"/import/template",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|