@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.
@@ -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 &quot;Chọn file&quot;.
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
+ }