@goplusvn/core 0.1.55 → 0.1.57
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/package.json +3 -2
- package/src/auth/__tests__/proxy-gate.test.ts +27 -0
- package/src/auth/proxy-gate.ts +28 -12
- 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/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/layout/command-menu.tsx +4 -4
- package/src/ui/layout/notification-dropdown.tsx +3 -3
- package/src/ui/layout/user-dropdown.tsx +3 -3
- package/src/utils/index.ts +9 -4
- 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
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
5
|
+
import { useDropzone } from "react-dropzone";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
import {
|
|
8
|
+
AlertCircle,
|
|
9
|
+
CheckCircle2,
|
|
10
|
+
Download,
|
|
11
|
+
FileText,
|
|
12
|
+
Upload,
|
|
13
|
+
X,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
|
|
16
|
+
import type { EntityConfig, ImportOptions } from "../types";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
generateXLSXTemplate,
|
|
20
|
+
parseFile,
|
|
21
|
+
validateImportData,
|
|
22
|
+
} from "../crud/lib/import-export-service";
|
|
23
|
+
import {
|
|
24
|
+
filenameFromContentDisposition,
|
|
25
|
+
triggerBlobDownload,
|
|
26
|
+
} from "../export/download-file";
|
|
27
|
+
import {
|
|
28
|
+
Alert,
|
|
29
|
+
AlertDescription,
|
|
30
|
+
Button,
|
|
31
|
+
Dialog,
|
|
32
|
+
DialogContent,
|
|
33
|
+
DialogDescription,
|
|
34
|
+
DialogFooter,
|
|
35
|
+
DialogHeader,
|
|
36
|
+
DialogTitle,
|
|
37
|
+
DialogTrigger,
|
|
38
|
+
} from "../ui";
|
|
39
|
+
|
|
40
|
+
// ─────────────────────────────────────────────────
|
|
41
|
+
// Kiểu dữ liệu
|
|
42
|
+
// ─────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Hai hợp đồng server trả về hai hình dạng khác nhau. Quy về MỘT mô hình hiển
|
|
46
|
+
* thị để thân dialog không cần biết nguồn nào:
|
|
47
|
+
* - harness workbook: { importedCount, skippedCount, totalRows, errors:[{column}], warnings }
|
|
48
|
+
* - CRUD theo EntityConfig: { imported, failed, errors:[{field}] }
|
|
49
|
+
*/
|
|
50
|
+
interface NormalizedResult {
|
|
51
|
+
success: boolean;
|
|
52
|
+
importedCount: number;
|
|
53
|
+
skippedCount: number;
|
|
54
|
+
totalRows: number;
|
|
55
|
+
errors: { row: number; label?: string; message: string }[];
|
|
56
|
+
warnings?: { row: number; message: string }[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function normalizeImportResult(raw: any): NormalizedResult {
|
|
60
|
+
const importedCount = raw?.importedCount ?? raw?.imported ?? 0;
|
|
61
|
+
const skippedCount = raw?.skippedCount ?? raw?.failed ?? 0;
|
|
62
|
+
const errors = Array.isArray(raw?.errors)
|
|
63
|
+
? raw.errors.map((e: any) => ({
|
|
64
|
+
row: e?.row ?? 0,
|
|
65
|
+
label: e?.column ?? e?.field ?? undefined,
|
|
66
|
+
message: e?.message ?? "",
|
|
67
|
+
}))
|
|
68
|
+
: [];
|
|
69
|
+
return {
|
|
70
|
+
success: Boolean(raw?.success),
|
|
71
|
+
importedCount,
|
|
72
|
+
skippedCount,
|
|
73
|
+
totalRows: raw?.totalRows ?? importedCount + skippedCount,
|
|
74
|
+
errors,
|
|
75
|
+
warnings: Array.isArray(raw?.warnings)
|
|
76
|
+
? raw.warnings.map((w: any) => ({
|
|
77
|
+
row: w?.row ?? 0,
|
|
78
|
+
message: w?.message ?? "",
|
|
79
|
+
}))
|
|
80
|
+
: undefined,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ImportDialogProps {
|
|
85
|
+
/** Mở/đóng do ngoài điều khiển. Bỏ CẢ HAI để dialog tự quản (tự render nút). */
|
|
86
|
+
open?: boolean;
|
|
87
|
+
onOpenChange?: (open: boolean) => void;
|
|
88
|
+
/** Nút mở khi chạy chế độ tự quản (mặc định là nút "Import"). */
|
|
89
|
+
trigger?: ReactNode;
|
|
90
|
+
/** Tiêu đề. Mặc định `Import {config.pluralLabel}` khi có config. */
|
|
91
|
+
title?: string;
|
|
92
|
+
description?: string;
|
|
93
|
+
/** Endpoint nhận POST multipart. */
|
|
94
|
+
apiUrl: string;
|
|
95
|
+
/**
|
|
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ừ), hoặc
|
|
98
|
+
* - `config`: dựng mẫu ngay ở client từ EntityConfig.
|
|
99
|
+
* Có `config` thì dialog còn kiểm tra file TRƯỚC khi gửi và gửi kèm `format`.
|
|
100
|
+
*/
|
|
101
|
+
templateApiUrl?: string;
|
|
102
|
+
config?: EntityConfig;
|
|
103
|
+
/** Không có quyền nhập thì không render gì. */
|
|
104
|
+
canImport?: boolean;
|
|
105
|
+
onSuccess?: () => void;
|
|
106
|
+
/**
|
|
107
|
+
* Trang theo dõi tác vụ nền, dùng cho nút "Xem tiến độ" khi server trả 202.
|
|
108
|
+
* Mặc định `/{lang}/tasks` suy từ đoạn đầu của đường dẫn hiện tại.
|
|
109
|
+
*/
|
|
110
|
+
tasksPath?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ─────────────────────────────────────────────────
|
|
114
|
+
// Component
|
|
115
|
+
// ─────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* MỘT dialog nhập file cho cả app. Hai chế độ, chung một thân:
|
|
119
|
+
* - mẫu từ server (`templateApiUrl`): chứng từ nhiều sheet (đơn mua/đơn bán).
|
|
120
|
+
* - theo config (`config`): thực thể phẳng — dựng mẫu ở client, kiểm tra
|
|
121
|
+
* trước khi gửi, tự nhận định dạng theo đuôi file.
|
|
122
|
+
*
|
|
123
|
+
* Server trả 202 nghĩa là file lớn và đã chuyển sang NHẬP NỀN: đóng dialog,
|
|
124
|
+
* báo toast kèm lối sang trang tác vụ; kết quả sẽ về qua chuông thông báo.
|
|
125
|
+
*/
|
|
126
|
+
export function ImportDialog({
|
|
127
|
+
open: controlledOpen,
|
|
128
|
+
onOpenChange: controlledOnOpenChange,
|
|
129
|
+
trigger,
|
|
130
|
+
title,
|
|
131
|
+
description,
|
|
132
|
+
apiUrl,
|
|
133
|
+
templateApiUrl,
|
|
134
|
+
config,
|
|
135
|
+
canImport = true,
|
|
136
|
+
onSuccess,
|
|
137
|
+
tasksPath,
|
|
138
|
+
}: ImportDialogProps) {
|
|
139
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
140
|
+
const [file, setFile] = useState<File | null>(null);
|
|
141
|
+
const [loading, setLoading] = useState(false);
|
|
142
|
+
const [result, setResult] = useState<NormalizedResult | null>(null);
|
|
143
|
+
const [format, setFormat] = useState<ImportOptions["format"]>("xlsx");
|
|
144
|
+
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
145
|
+
|
|
146
|
+
const isControlled = controlledOpen !== undefined;
|
|
147
|
+
const open = isControlled ? controlledOpen : internalOpen;
|
|
148
|
+
const setOpen = isControlled
|
|
149
|
+
? (controlledOnOpenChange ?? (() => {}))
|
|
150
|
+
: setInternalOpen;
|
|
151
|
+
|
|
152
|
+
const configMode = Boolean(config);
|
|
153
|
+
const resolvedTitle =
|
|
154
|
+
title ?? (config ? `Import ${config.pluralLabel}` : "Import dữ liệu");
|
|
155
|
+
|
|
156
|
+
const requiredLabels = useMemo(() => {
|
|
157
|
+
if (!config) return [];
|
|
158
|
+
return config.fields
|
|
159
|
+
.filter((f) => !f.hideInForm && f.required)
|
|
160
|
+
.map((f) => f.label);
|
|
161
|
+
}, [config]);
|
|
162
|
+
|
|
163
|
+
const handleFileSelect = useCallback(
|
|
164
|
+
(selectedFile: File) => {
|
|
165
|
+
setFile(selectedFile);
|
|
166
|
+
setResult(null);
|
|
167
|
+
if (configMode) {
|
|
168
|
+
const extension = selectedFile.name.split(".").pop()?.toLowerCase();
|
|
169
|
+
if (extension === "json") setFormat("json");
|
|
170
|
+
else if (extension === "csv") setFormat("csv");
|
|
171
|
+
else setFormat("xlsx");
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
[configMode],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const clearSelectedFile = () => {
|
|
178
|
+
setFile(null);
|
|
179
|
+
setResult(null);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const onDrop = useCallback(
|
|
183
|
+
(acceptedFiles: File[]) => {
|
|
184
|
+
const first = acceptedFiles[0];
|
|
185
|
+
if (first) handleFileSelect(first);
|
|
186
|
+
},
|
|
187
|
+
[handleFileSelect],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const {
|
|
191
|
+
getRootProps,
|
|
192
|
+
getInputProps,
|
|
193
|
+
isDragActive,
|
|
194
|
+
open: openFilePicker,
|
|
195
|
+
} = useDropzone({
|
|
196
|
+
onDrop,
|
|
197
|
+
multiple: false,
|
|
198
|
+
maxFiles: 1,
|
|
199
|
+
noClick: true,
|
|
200
|
+
accept: configMode
|
|
201
|
+
? {
|
|
202
|
+
"text/csv": [".csv"],
|
|
203
|
+
"application/json": [".json"],
|
|
204
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
|
|
205
|
+
".xlsx",
|
|
206
|
+
],
|
|
207
|
+
"application/vnd.ms-excel": [".xls"],
|
|
208
|
+
}
|
|
209
|
+
: {
|
|
210
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
|
|
211
|
+
".xlsx",
|
|
212
|
+
],
|
|
213
|
+
"application/vnd.ms-excel": [".xls"],
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const handleDownloadTemplate = async () => {
|
|
218
|
+
try {
|
|
219
|
+
if (config) {
|
|
220
|
+
const blob = await generateXLSXTemplate(config);
|
|
221
|
+
triggerBlobDownload(blob, `${config.name}_template.xlsx`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (!templateApiUrl) return;
|
|
225
|
+
const response = await fetch(templateApiUrl);
|
|
226
|
+
if (!response.ok) throw new Error("Không thể tải file mẫu");
|
|
227
|
+
const blob = await response.blob();
|
|
228
|
+
triggerBlobDownload(
|
|
229
|
+
blob,
|
|
230
|
+
filenameFromContentDisposition(
|
|
231
|
+
response.headers.get("content-disposition"),
|
|
232
|
+
"template.xlsx",
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
console.error("Error downloading template:", error);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const handleImport = async () => {
|
|
241
|
+
if (!file) return;
|
|
242
|
+
|
|
243
|
+
setLoading(true);
|
|
244
|
+
setResult(null);
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
// Chế độ config kiểm tra ở client trước, để người dùng thấy lỗi cột mà
|
|
248
|
+
// không cần tải file lên.
|
|
249
|
+
if (config) {
|
|
250
|
+
const parsed = await parseFile(file, format);
|
|
251
|
+
const validation = validateImportData(parsed, config, {
|
|
252
|
+
format,
|
|
253
|
+
skipErrors: false,
|
|
254
|
+
});
|
|
255
|
+
if (!validation.success && validation.errors.length > 0) {
|
|
256
|
+
setResult(normalizeImportResult(validation));
|
|
257
|
+
setLoading(false);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const formData = new FormData();
|
|
263
|
+
formData.append("file", file);
|
|
264
|
+
if (configMode) {
|
|
265
|
+
formData.append("format", format ?? "xlsx");
|
|
266
|
+
} else {
|
|
267
|
+
formData.append("mode", "full");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Giữ nguyên query string trên apiUrl (ví dụ ?supplierId=...).
|
|
271
|
+
const url = new URL(apiUrl, window.location.origin);
|
|
272
|
+
const postUrl = `${url.pathname}${url.search}`;
|
|
273
|
+
|
|
274
|
+
const response = await fetch(postUrl, { method: "POST", body: formData });
|
|
275
|
+
const raw = await response.json();
|
|
276
|
+
|
|
277
|
+
// 202 = server chuyển sang NHẬP NỀN (file lớn) — chuông sẽ báo kết quả,
|
|
278
|
+
// kèm link tải file lỗi từng dòng nếu có.
|
|
279
|
+
if (response.status === 202) {
|
|
280
|
+
const totalNote =
|
|
281
|
+
typeof raw?.total === "number"
|
|
282
|
+
? ` (${raw.total.toLocaleString("vi-VN")} dòng)`
|
|
283
|
+
: "";
|
|
284
|
+
toast.success(
|
|
285
|
+
`File lớn${totalNote} — đang nhập nền, sẽ có thông báo khi xong.`,
|
|
286
|
+
{
|
|
287
|
+
duration: 8000,
|
|
288
|
+
action: {
|
|
289
|
+
label: "Xem tiến độ",
|
|
290
|
+
onClick: () => {
|
|
291
|
+
const lang = window.location.pathname.split("/")[1] || "vi";
|
|
292
|
+
window.location.href = tasksPath ?? `/${lang}/tasks`;
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
);
|
|
297
|
+
setOpen(false);
|
|
298
|
+
setFile(null);
|
|
299
|
+
setResult(null);
|
|
300
|
+
setLoading(false);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!response.ok && !raw?.errors) {
|
|
305
|
+
throw new Error(raw?.error || `Import thất bại (${response.status})`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const normalized = normalizeImportResult(raw);
|
|
309
|
+
setResult(normalized);
|
|
310
|
+
|
|
311
|
+
if (normalized.success && normalized.importedCount > 0) {
|
|
312
|
+
if (successTimeoutRef.current) clearTimeout(successTimeoutRef.current);
|
|
313
|
+
successTimeoutRef.current = setTimeout(() => {
|
|
314
|
+
setOpen(false);
|
|
315
|
+
setFile(null);
|
|
316
|
+
setResult(null);
|
|
317
|
+
onSuccess?.();
|
|
318
|
+
successTimeoutRef.current = null;
|
|
319
|
+
}, 2000);
|
|
320
|
+
}
|
|
321
|
+
} catch (error) {
|
|
322
|
+
console.error("Import error:", error);
|
|
323
|
+
setResult({
|
|
324
|
+
success: false,
|
|
325
|
+
totalRows: 0,
|
|
326
|
+
importedCount: 0,
|
|
327
|
+
skippedCount: 0,
|
|
328
|
+
errors: [
|
|
329
|
+
{
|
|
330
|
+
row: 0,
|
|
331
|
+
message:
|
|
332
|
+
error instanceof Error ? error.message : "Lỗi không xác định",
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
});
|
|
336
|
+
} finally {
|
|
337
|
+
setLoading(false);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
// Dọn timeout đóng-sau-khi-thành-công khi unmount.
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
return () => {
|
|
344
|
+
if (successTimeoutRef.current) {
|
|
345
|
+
clearTimeout(successTimeoutRef.current);
|
|
346
|
+
successTimeoutRef.current = null;
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
}, []);
|
|
350
|
+
|
|
351
|
+
// Đóng dialog thì xoá trạng thái tạm.
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
if (!open) {
|
|
354
|
+
setFile(null);
|
|
355
|
+
setResult(null);
|
|
356
|
+
}
|
|
357
|
+
}, [open]);
|
|
358
|
+
|
|
359
|
+
// Cổng quyền đặt SAU mọi hook — đổi `canImport` giữa chừng không được phép
|
|
360
|
+
// làm lệch thứ tự hook (lỗi cũ của CrudImportDialog).
|
|
361
|
+
if (!canImport) return null;
|
|
362
|
+
|
|
363
|
+
const acceptHint = configMode ? ".xlsx, .csv, .json" : ".xlsx, .xls";
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
367
|
+
{!isControlled && (
|
|
368
|
+
<DialogTrigger asChild>
|
|
369
|
+
{trigger ?? (
|
|
370
|
+
<Button variant="outline" size="sm">
|
|
371
|
+
<Upload className="mr-2 h-4 w-4" />
|
|
372
|
+
Import
|
|
373
|
+
</Button>
|
|
374
|
+
)}
|
|
375
|
+
</DialogTrigger>
|
|
376
|
+
)}
|
|
377
|
+
<DialogContent className="sm:max-w-[640px]">
|
|
378
|
+
<DialogHeader>
|
|
379
|
+
<DialogTitle>{resolvedTitle}</DialogTitle>
|
|
380
|
+
<DialogDescription>
|
|
381
|
+
{description ||
|
|
382
|
+
"Làm theo 2 bước: tải file mẫu, điền dữ liệu rồi chọn file để import."}
|
|
383
|
+
</DialogDescription>
|
|
384
|
+
</DialogHeader>
|
|
385
|
+
|
|
386
|
+
<div className="space-y-4">
|
|
387
|
+
{/* Bước 1: file mẫu */}
|
|
388
|
+
<div className="space-y-3 rounded-lg border bg-muted/20 p-4">
|
|
389
|
+
<div className="flex items-start justify-between gap-3">
|
|
390
|
+
<div className="min-w-0">
|
|
391
|
+
<div className="flex items-center gap-2">
|
|
392
|
+
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
393
|
+
<p className="font-medium">Bước 1: Tải file mẫu</p>
|
|
394
|
+
</div>
|
|
395
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
396
|
+
Tải mẫu đúng cột, điền dữ liệu rồi quay lại chọn file để
|
|
397
|
+
import.
|
|
398
|
+
</p>
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
<div className="flex flex-wrap gap-2">
|
|
403
|
+
<Button
|
|
404
|
+
type="button"
|
|
405
|
+
variant="default"
|
|
406
|
+
size="sm"
|
|
407
|
+
onClick={handleDownloadTemplate}
|
|
408
|
+
>
|
|
409
|
+
<Download className="mr-2 h-4 w-4" />
|
|
410
|
+
Tải mẫu XLSX
|
|
411
|
+
</Button>
|
|
412
|
+
</div>
|
|
413
|
+
|
|
414
|
+
{requiredLabels.length > 0 && (
|
|
415
|
+
<p className="text-xs text-muted-foreground">
|
|
416
|
+
Trường bắt buộc:{" "}
|
|
417
|
+
<span className="font-medium">{requiredLabels.join(", ")}</span>
|
|
418
|
+
</p>
|
|
419
|
+
)}
|
|
420
|
+
</div>
|
|
421
|
+
|
|
422
|
+
{/* Bước 2: chọn file */}
|
|
423
|
+
<div className="space-y-3 rounded-lg border p-4">
|
|
424
|
+
<div className="flex items-start justify-between gap-3">
|
|
425
|
+
<div className="min-w-0">
|
|
426
|
+
<div className="flex items-center gap-2">
|
|
427
|
+
<Upload className="h-4 w-4 text-muted-foreground" />
|
|
428
|
+
<p className="font-medium">Bước 2: Chọn file để import</p>
|
|
429
|
+
</div>
|
|
430
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
431
|
+
Kéo thả file vào vùng bên dưới hoặc bấm "Chọn file".
|
|
432
|
+
</p>
|
|
433
|
+
</div>
|
|
434
|
+
<Button
|
|
435
|
+
type="button"
|
|
436
|
+
variant="outline"
|
|
437
|
+
size="sm"
|
|
438
|
+
onClick={openFilePicker}
|
|
439
|
+
>
|
|
440
|
+
Chọn file
|
|
441
|
+
</Button>
|
|
442
|
+
</div>
|
|
443
|
+
|
|
444
|
+
{/* Vùng kéo thả — react-dropzone trả kèm prop `popover` mà React
|
|
445
|
+
chưa biết, bỏ ra để tránh cảnh báo unknown attribute. */}
|
|
446
|
+
<div
|
|
447
|
+
{...(() => {
|
|
448
|
+
const { popover: _popover, ...rest } = getRootProps() as any;
|
|
449
|
+
return rest;
|
|
450
|
+
})()}
|
|
451
|
+
className={[
|
|
452
|
+
"rounded-lg border-2 border-dashed p-4 transition-colors",
|
|
453
|
+
"bg-background hover:bg-muted/30",
|
|
454
|
+
isDragActive ? "border-primary bg-muted/30" : "border-input",
|
|
455
|
+
].join(" ")}
|
|
456
|
+
>
|
|
457
|
+
<input
|
|
458
|
+
{...(() => {
|
|
459
|
+
const { popover: _popover, ...rest } = getInputProps() as any;
|
|
460
|
+
return rest;
|
|
461
|
+
})()}
|
|
462
|
+
/>
|
|
463
|
+
{file ? (
|
|
464
|
+
<div className="flex items-center justify-between gap-3">
|
|
465
|
+
<div className="min-w-0">
|
|
466
|
+
<p className="truncate text-sm font-medium">{file.name}</p>
|
|
467
|
+
<p className="text-xs text-muted-foreground">
|
|
468
|
+
{(file.size / 1024).toFixed(1)} KB — Định dạng:{" "}
|
|
469
|
+
<span className="font-medium uppercase">{format}</span>
|
|
470
|
+
</p>
|
|
471
|
+
</div>
|
|
472
|
+
<Button
|
|
473
|
+
type="button"
|
|
474
|
+
variant="secondary"
|
|
475
|
+
size="icon"
|
|
476
|
+
onClick={clearSelectedFile}
|
|
477
|
+
aria-label="Remove"
|
|
478
|
+
>
|
|
479
|
+
<X className="h-4 w-4" />
|
|
480
|
+
</Button>
|
|
481
|
+
</div>
|
|
482
|
+
) : (
|
|
483
|
+
<div className="py-6 text-center">
|
|
484
|
+
<p className="text-sm font-medium">
|
|
485
|
+
Kéo thả file vào đây để import
|
|
486
|
+
</p>
|
|
487
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
488
|
+
Hỗ trợ: {acceptHint}
|
|
489
|
+
</p>
|
|
490
|
+
</div>
|
|
491
|
+
)}
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
|
|
495
|
+
{/* Kết quả */}
|
|
496
|
+
{result && (
|
|
497
|
+
<Alert variant={result.success ? "default" : "destructive"}>
|
|
498
|
+
{result.success ? (
|
|
499
|
+
<CheckCircle2 className="h-4 w-4" />
|
|
500
|
+
) : (
|
|
501
|
+
<AlertCircle className="h-4 w-4" />
|
|
502
|
+
)}
|
|
503
|
+
<AlertDescription>
|
|
504
|
+
<div className="space-y-1">
|
|
505
|
+
{result.success ? (
|
|
506
|
+
<div className="font-medium text-success-text">
|
|
507
|
+
✅ Import thành công: {result.importedCount} dòng
|
|
508
|
+
</div>
|
|
509
|
+
) : (
|
|
510
|
+
<div>
|
|
511
|
+
Tổng: {result.totalRows} | Thành công:{" "}
|
|
512
|
+
{result.importedCount} | Lỗi: {result.errors.length}
|
|
513
|
+
</div>
|
|
514
|
+
)}
|
|
515
|
+
{result.errors.length > 0 && (
|
|
516
|
+
<div className="mt-2 max-h-40 space-y-1 overflow-y-auto">
|
|
517
|
+
{result.errors.slice(0, 10).map((error, index) => (
|
|
518
|
+
<div key={index} className="text-xs">
|
|
519
|
+
{error.row > 0 ? (
|
|
520
|
+
<>
|
|
521
|
+
<span className="font-mono font-medium">
|
|
522
|
+
Dòng {error.row}
|
|
523
|
+
</span>
|
|
524
|
+
{error.label && (
|
|
525
|
+
<span className="text-muted-foreground">
|
|
526
|
+
{" "}
|
|
527
|
+
[{error.label}]
|
|
528
|
+
</span>
|
|
529
|
+
)}
|
|
530
|
+
: {error.message}
|
|
531
|
+
</>
|
|
532
|
+
) : (
|
|
533
|
+
error.message
|
|
534
|
+
)}
|
|
535
|
+
</div>
|
|
536
|
+
))}
|
|
537
|
+
{result.errors.length > 10 && (
|
|
538
|
+
<div className="text-xs text-muted-foreground">
|
|
539
|
+
... và {result.errors.length - 10} lỗi khác
|
|
540
|
+
</div>
|
|
541
|
+
)}
|
|
542
|
+
</div>
|
|
543
|
+
)}
|
|
544
|
+
{result.warnings && result.warnings.length > 0 && (
|
|
545
|
+
<div className="mt-2 max-h-20 space-y-1 overflow-y-auto">
|
|
546
|
+
{result.warnings.slice(0, 5).map((w, index) => (
|
|
547
|
+
<div key={index} className="text-xs text-warning-text">
|
|
548
|
+
⚠️ Dòng {w.row}: {w.message}
|
|
549
|
+
</div>
|
|
550
|
+
))}
|
|
551
|
+
</div>
|
|
552
|
+
)}
|
|
553
|
+
</div>
|
|
554
|
+
</AlertDescription>
|
|
555
|
+
</Alert>
|
|
556
|
+
)}
|
|
557
|
+
</div>
|
|
558
|
+
|
|
559
|
+
<DialogFooter>
|
|
560
|
+
<Button variant="outline" onClick={() => setOpen(false)}>
|
|
561
|
+
Đóng
|
|
562
|
+
</Button>
|
|
563
|
+
<Button onClick={handleImport} disabled={!file || loading}>
|
|
564
|
+
{loading ? "Đang import..." : "Import"}
|
|
565
|
+
</Button>
|
|
566
|
+
</DialogFooter>
|
|
567
|
+
</DialogContent>
|
|
568
|
+
</Dialog>
|
|
569
|
+
);
|
|
570
|
+
}
|