@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.
@@ -91,10 +91,10 @@ const CrudSheet = dynamic(
91
91
  },
92
92
  );
93
93
 
94
- const CrudImportDialog = dynamic(
94
+ const ImportDialog = dynamic(
95
95
  () =>
96
- import("./crud-import-dialog").then((m) => ({
97
- default: m.CrudImportDialog,
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
- <CrudImportDialog
863
+ <ImportDialog
865
864
  config={config}
866
- endpoint={(() => {
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
+ });
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Harness nhập workbook. Tập trung vào chỗ dữ liệu thật hay làm vỡ: người dùng
3
+ * dán từ Excel bản địa nên số là "1.234.567,89" và ngày là "31/07/2026", còn
4
+ * tên cột thì lệch hoa/thường và thừa khoảng trắng.
5
+ */
6
+ import * as XLSX from "xlsx";
7
+ import { describe, expect, it } from "vitest";
8
+
9
+ import {
10
+ buildValidationReport,
11
+ findSheet,
12
+ getHeaders,
13
+ normalizeRow,
14
+ parseExcelBuffer,
15
+ parseFlexibleDate,
16
+ parseVietnameseNumber,
17
+ validateHeaders,
18
+ } from "../import-engine";
19
+ import type { ImportColumnDef } from "../types";
20
+
21
+ const COLUMNS: ImportColumnDef[] = [
22
+ { excelHeader: "Mã hàng", fieldKey: "code", type: "string", required: true },
23
+ { excelHeader: "Số lượng", fieldKey: "qty", type: "number", required: true },
24
+ { excelHeader: "Ngày", fieldKey: "date", type: "date" },
25
+ {
26
+ excelHeader: "Loại",
27
+ fieldKey: "kind",
28
+ type: "enum",
29
+ enumValues: ["gold", "silver"],
30
+ defaultValue: "gold",
31
+ },
32
+ ];
33
+
34
+ describe("parseVietnameseNumber", () => {
35
+ it("đọc được cách viết Việt Nam và cách viết Anh", () => {
36
+ expect(parseVietnameseNumber("1.234.567,89")).toBe(1234567.89);
37
+ expect(parseVietnameseNumber("1234,56")).toBe(1234.56);
38
+ expect(parseVietnameseNumber("1234.56")).toBe(1234.56);
39
+ expect(parseVietnameseNumber(" 42 ")).toBe(42);
40
+ expect(parseVietnameseNumber(42)).toBe(42);
41
+ });
42
+
43
+ it("trả NaN cho thứ không phải số — caller phân biệt được với 0", () => {
44
+ expect(parseVietnameseNumber("abc")).toBeNaN();
45
+ expect(parseVietnameseNumber(null)).toBeNaN();
46
+ });
47
+ });
48
+
49
+ describe("parseFlexibleDate", () => {
50
+ it("đọc dd/MM/yyyy theo lối Việt Nam, KHÔNG phải MM/dd", () => {
51
+ const d = parseFlexibleDate("03/07/2026")!;
52
+ expect(d.getDate()).toBe(3);
53
+ expect(d.getMonth()).toBe(6); // tháng 7
54
+ expect(d.getFullYear()).toBe(2026);
55
+ });
56
+
57
+ it("đọc yyyy-MM-dd và số serial của Excel", () => {
58
+ const iso = parseFlexibleDate("2026-07-31")!;
59
+ expect(iso.getMonth()).toBe(6);
60
+ expect(iso.getDate()).toBe(31);
61
+ // 45000 = 2023-03-15 tính từ mốc 1899-12-30
62
+ const serial = parseFlexibleDate(45000)!;
63
+ expect(serial.getFullYear()).toBe(2023);
64
+ expect(serial.getMonth()).toBe(2);
65
+ expect(serial.getDate()).toBe(15);
66
+ });
67
+
68
+ it("trả null cho rác thay vì Invalid Date", () => {
69
+ expect(parseFlexibleDate("hôm qua")).toBeNull();
70
+ expect(parseFlexibleDate(new Date("x"))).toBeNull();
71
+ expect(parseFlexibleDate({})).toBeNull();
72
+ });
73
+
74
+ it("từ chối ngày không tồn tại thay vì cuộn sang tháng sau", () => {
75
+ // 31/06 và 29/02/2026 là lỗi gõ; cuộn ngầm sẽ đổi ngày chứng từ.
76
+ expect(parseFlexibleDate("31/06/2026")).toBeNull();
77
+ expect(parseFlexibleDate("29/02/2026")).toBeNull();
78
+ expect(parseFlexibleDate("32/13/2026")).toBeNull();
79
+ expect(parseFlexibleDate("2026-06-31")).toBeNull();
80
+ expect(parseFlexibleDate("29/02/2024")).not.toBeNull(); // năm nhuận thì hợp lệ
81
+ });
82
+ });
83
+
84
+ describe("validateHeaders", () => {
85
+ it("bỏ qua lệch hoa/thường và khoảng trắng thừa", () => {
86
+ expect(validateHeaders([" mã HÀNG ", "Số lượng"], COLUMNS)).toEqual([]);
87
+ });
88
+
89
+ it("chỉ báo thiếu cột BẮT BUỘC", () => {
90
+ const errors = validateHeaders(["Mã hàng"], COLUMNS);
91
+ expect(errors).toHaveLength(1);
92
+ expect(errors[0].column).toBe("Số lượng");
93
+ });
94
+ });
95
+
96
+ describe("normalizeRow", () => {
97
+ it("ánh xạ header lệch chuẩn, ép kiểu và trim", () => {
98
+ const { data, errors } = normalizeRow(
99
+ { " MÃ hàng ": " SP01 ", "Số lượng": "1.500,5", Ngày: "31/07/2026" },
100
+ COLUMNS,
101
+ 1,
102
+ );
103
+ expect(errors).toEqual([]);
104
+ expect(data.code).toBe("SP01");
105
+ expect(data.qty).toBe(1500.5);
106
+ expect((data.date as Date).getDate()).toBe(31);
107
+ });
108
+
109
+ it("ô trống bắt buộc thì báo lỗi, ô trống tuỳ chọn thì lấy mặc định", () => {
110
+ const { data, errors } = normalizeRow({ "Mã hàng": "SP01" }, COLUMNS, 2);
111
+ expect(errors.map((e) => e.column)).toEqual(["Số lượng"]);
112
+ expect(data.qty).toBeNull();
113
+ expect(data.kind).toBe("gold"); // defaultValue
114
+ expect(data.date).toBeNull();
115
+ });
116
+
117
+ it("số/ngày sai vẫn ghi giá trị an toàn để caller không phải kiểm null khắp nơi", () => {
118
+ const { data, errors } = normalizeRow(
119
+ { "Mã hàng": "SP01", "Số lượng": "abc", Ngày: "32/13/2026" },
120
+ COLUMNS,
121
+ 3,
122
+ );
123
+ expect(errors).toHaveLength(2);
124
+ expect(data.qty).toBe(0);
125
+ expect(data.date).toBeNull();
126
+ });
127
+
128
+ it("enum khớp không phân biệt hoa/thường, trả về đúng giá trị chuẩn", () => {
129
+ expect(
130
+ normalizeRow(
131
+ { "Mã hàng": "A", "Số lượng": "1", Loại: "SILVER" },
132
+ COLUMNS,
133
+ 4,
134
+ ).data.kind,
135
+ ).toBe("silver");
136
+
137
+ const bad = normalizeRow(
138
+ { "Mã hàng": "A", "Số lượng": "1", Loại: "bạch kim" },
139
+ COLUMNS,
140
+ 5,
141
+ );
142
+ expect(bad.errors).toHaveLength(1);
143
+ expect(bad.data.kind).toBe("gold"); // rơi về mặc định
144
+ });
145
+
146
+ it("chạy validate riêng của cột", () => {
147
+ const cols: ImportColumnDef[] = [
148
+ {
149
+ excelHeader: "Số lượng",
150
+ fieldKey: "qty",
151
+ type: "number",
152
+ validate: (v, row) =>
153
+ (v as number) > 0
154
+ ? null
155
+ : { row, column: "Số lượng", value: v, message: "Phải dương" },
156
+ },
157
+ ];
158
+ expect(normalizeRow({ "Số lượng": "-3" }, cols, 7).errors[0].message).toBe(
159
+ "Phải dương",
160
+ );
161
+ });
162
+ });
163
+
164
+ describe("buildValidationReport", () => {
165
+ const base = {
166
+ totalRows: 10,
167
+ importedCount: 8,
168
+ skippedCount: 2,
169
+ warnings: [],
170
+ importedIds: ["a", "b"],
171
+ dryRun: false,
172
+ };
173
+ const oneError = [{ row: 1, column: "x", value: null, message: "sai" }];
174
+
175
+ it("'full' hỏng vì một lỗi, 'partial' thì không", () => {
176
+ expect(
177
+ buildValidationReport({ ...base, mode: "full", errors: oneError })
178
+ .success,
179
+ ).toBe(false);
180
+ expect(
181
+ buildValidationReport({ ...base, mode: "partial", errors: oneError })
182
+ .success,
183
+ ).toBe(true);
184
+ });
185
+
186
+ it("chạy thử không được khoe đã nhập gì", () => {
187
+ const r = buildValidationReport({
188
+ ...base,
189
+ mode: "partial",
190
+ errors: [],
191
+ dryRun: true,
192
+ });
193
+ expect(r.success).toBe(true);
194
+ expect(r.importedCount).toBe(0);
195
+ expect(r.importedIds).toEqual([]);
196
+ });
197
+
198
+ it("chạy thử có lỗi thì KHÔNG thành công, kể cả chế độ partial", () => {
199
+ expect(
200
+ buildValidationReport({
201
+ ...base,
202
+ mode: "partial",
203
+ errors: oneError,
204
+ dryRun: true,
205
+ }).success,
206
+ ).toBe(false);
207
+ });
208
+ });
209
+
210
+ describe("parseExcelBuffer + tiện ích sheet", () => {
211
+ function workbookBuffer(): ArrayBuffer {
212
+ const wb = XLSX.utils.book_new();
213
+ const ws = XLSX.utils.aoa_to_sheet([
214
+ ["Mã hàng", "Số lượng"],
215
+ ["SP01", 3],
216
+ ["SP02", ""],
217
+ ]);
218
+ XLSX.utils.book_append_sheet(wb, ws, "Dữ liệu");
219
+ return XLSX.write(wb, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
220
+ }
221
+
222
+ it("đọc buffer thật thành dòng, ô trống là chuỗi rỗng chứ không undefined", () => {
223
+ const parsed = parseExcelBuffer(workbookBuffer());
224
+ const rows = findSheet(parsed, " dữ LIỆU ")!;
225
+ expect(rows).toHaveLength(2);
226
+ expect(rows[0]["Mã hàng"]).toBe("SP01");
227
+ expect(rows[1]["Số lượng"]).toBe("");
228
+ expect(getHeaders(rows)).toEqual(["Mã hàng", "Số lượng"]);
229
+ });
230
+
231
+ it("findSheet trả null khi không có sheet; getHeaders chịu được mảng rỗng", () => {
232
+ expect(findSheet(parseExcelBuffer(workbookBuffer()), "Sheet2")).toBeNull();
233
+ expect(getHeaders([])).toEqual([]);
234
+ });
235
+ });