@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,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness nhập workbook — đọc Excel, kiểm tra header, chuẩn hoá kiểu dữ liệu và
|
|
3
|
+
* dựng báo cáo lỗi. THUẦN: không React, không DB, không Next — service nhập của
|
|
4
|
+
* từng nghiệp vụ (đơn bán, đơn mua…) gọi các hàm này rồi tự lo phần ghi DB.
|
|
5
|
+
*
|
|
6
|
+
* Định dạng số/ngày mặc định theo thói quen Việt Nam ("1.234.567,89",
|
|
7
|
+
* "dd/MM/yyyy") vì đó là thứ người dùng dán từ Excel bản địa.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as XLSX from "xlsx";
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
ImportColumnDef,
|
|
14
|
+
ImportRowError,
|
|
15
|
+
ImportRowWarning,
|
|
16
|
+
ParsedRow,
|
|
17
|
+
} from "./types";
|
|
18
|
+
|
|
19
|
+
// ─────────────────────────────────────────────────
|
|
20
|
+
// 1. Đọc Excel
|
|
21
|
+
// ─────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/** Đọc buffer Excel thành object `{ tên sheet: mảng dòng }`. */
|
|
24
|
+
export function parseExcelBuffer(
|
|
25
|
+
buffer: ArrayBuffer,
|
|
26
|
+
): Record<string, ParsedRow[]> {
|
|
27
|
+
const workbook = XLSX.read(buffer, { type: "array", cellDates: true });
|
|
28
|
+
const result: Record<string, ParsedRow[]> = {};
|
|
29
|
+
|
|
30
|
+
for (const sheetName of workbook.SheetNames) {
|
|
31
|
+
const sheet = workbook.Sheets[sheetName];
|
|
32
|
+
// defval: "" để ô trống không thành undefined; raw: false để số/ngày về
|
|
33
|
+
// chuỗi, xử lý đồng nhất một chỗ trong normalizeRow.
|
|
34
|
+
const rows = XLSX.utils.sheet_to_json<ParsedRow>(sheet, {
|
|
35
|
+
defval: "",
|
|
36
|
+
raw: false,
|
|
37
|
+
});
|
|
38
|
+
result[sheetName] = rows;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─────────────────────────────────────────────────
|
|
45
|
+
// 2. Kiểm tra header
|
|
46
|
+
// ─────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/** Báo thiếu cột BẮT BUỘC. Cột không bắt buộc thiếu thì bỏ qua. */
|
|
49
|
+
export function validateHeaders(
|
|
50
|
+
actualHeaders: string[],
|
|
51
|
+
columns: ImportColumnDef[],
|
|
52
|
+
): ImportRowError[] {
|
|
53
|
+
const errors: ImportRowError[] = [];
|
|
54
|
+
const normalizedActual = new Set(
|
|
55
|
+
actualHeaders.map((h) => normalizeHeader(h)),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
for (const col of columns) {
|
|
59
|
+
if (
|
|
60
|
+
col.required &&
|
|
61
|
+
!normalizedActual.has(normalizeHeader(col.excelHeader))
|
|
62
|
+
) {
|
|
63
|
+
errors.push({
|
|
64
|
+
row: 0,
|
|
65
|
+
column: col.excelHeader,
|
|
66
|
+
value: null,
|
|
67
|
+
message: `Thiếu cột bắt buộc: "${col.excelHeader}"`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return errors;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Chuẩn hoá tên cột để so khớp (trim, thường hoá, gộp khoảng trắng). */
|
|
76
|
+
function normalizeHeader(header: string): string {
|
|
77
|
+
return header.trim().toLowerCase().replace(/\s+/g, " ");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─────────────────────────────────────────────────
|
|
81
|
+
// 3. Chuẩn hoá dòng + ép kiểu
|
|
82
|
+
// ─────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Ánh xạ header Excel → fieldKey, trim chuỗi, parse số/ngày, áp mặc định.
|
|
86
|
+
* Ô lỗi vẫn ghi giá trị an toàn vào `data` để caller không phải kiểm tra null
|
|
87
|
+
* ở mọi chỗ — cứ nhìn `errors` để quyết định bỏ dòng hay không.
|
|
88
|
+
*/
|
|
89
|
+
export function normalizeRow(
|
|
90
|
+
raw: ParsedRow,
|
|
91
|
+
columns: ImportColumnDef[],
|
|
92
|
+
rowIndex: number,
|
|
93
|
+
): { data: ParsedRow; errors: ImportRowError[]; warnings: ImportRowWarning[] } {
|
|
94
|
+
const data: ParsedRow = {};
|
|
95
|
+
const errors: ImportRowError[] = [];
|
|
96
|
+
const warnings: ImportRowWarning[] = [];
|
|
97
|
+
|
|
98
|
+
// Khoá thật trong file có thể lệch hoa/thường/khoảng trắng so với định nghĩa.
|
|
99
|
+
const rawKeyMap = new Map<string, string>();
|
|
100
|
+
for (const key of Object.keys(raw)) {
|
|
101
|
+
rawKeyMap.set(normalizeHeader(key), key);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const col of columns) {
|
|
105
|
+
const rawKey = rawKeyMap.get(normalizeHeader(col.excelHeader));
|
|
106
|
+
const value = rawKey !== undefined ? raw[rawKey] : undefined;
|
|
107
|
+
|
|
108
|
+
if (value === undefined || value === null || value === "") {
|
|
109
|
+
if (col.required) {
|
|
110
|
+
errors.push({
|
|
111
|
+
row: rowIndex,
|
|
112
|
+
column: col.excelHeader,
|
|
113
|
+
value,
|
|
114
|
+
message: `Trường "${col.excelHeader}" là bắt buộc`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
data[col.fieldKey] = col.defaultValue ?? null;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
switch (col.type) {
|
|
122
|
+
case "string":
|
|
123
|
+
data[col.fieldKey] = String(value).trim();
|
|
124
|
+
break;
|
|
125
|
+
|
|
126
|
+
case "number":
|
|
127
|
+
case "decimal": {
|
|
128
|
+
const num = parseVietnameseNumber(value);
|
|
129
|
+
if (isNaN(num)) {
|
|
130
|
+
errors.push({
|
|
131
|
+
row: rowIndex,
|
|
132
|
+
column: col.excelHeader,
|
|
133
|
+
value,
|
|
134
|
+
message: `"${col.excelHeader}" phải là số hợp lệ (nhận được: "${value}")`,
|
|
135
|
+
});
|
|
136
|
+
data[col.fieldKey] = 0;
|
|
137
|
+
} else {
|
|
138
|
+
data[col.fieldKey] = num;
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
case "date": {
|
|
144
|
+
const date = parseFlexibleDate(value);
|
|
145
|
+
if (!date) {
|
|
146
|
+
errors.push({
|
|
147
|
+
row: rowIndex,
|
|
148
|
+
column: col.excelHeader,
|
|
149
|
+
value,
|
|
150
|
+
message: `"${col.excelHeader}" phải là ngày hợp lệ (dd/MM/yyyy) (nhận được: "${value}")`,
|
|
151
|
+
});
|
|
152
|
+
data[col.fieldKey] = null;
|
|
153
|
+
} else {
|
|
154
|
+
data[col.fieldKey] = date;
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case "enum": {
|
|
160
|
+
const strVal = String(value).trim();
|
|
161
|
+
if (col.enumValues && !col.enumValues.includes(strVal)) {
|
|
162
|
+
const matched = col.enumValues.find(
|
|
163
|
+
(v) => v.toLowerCase() === strVal.toLowerCase(),
|
|
164
|
+
);
|
|
165
|
+
if (matched) {
|
|
166
|
+
data[col.fieldKey] = matched;
|
|
167
|
+
} else {
|
|
168
|
+
errors.push({
|
|
169
|
+
row: rowIndex,
|
|
170
|
+
column: col.excelHeader,
|
|
171
|
+
value: strVal,
|
|
172
|
+
message: `"${col.excelHeader}" phải là một trong: ${col.enumValues.join(", ")} (nhận được: "${strVal}")`,
|
|
173
|
+
});
|
|
174
|
+
data[col.fieldKey] = col.defaultValue ?? null;
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
data[col.fieldKey] = strVal;
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
default:
|
|
183
|
+
data[col.fieldKey] = value;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (col.validate) {
|
|
187
|
+
const err = col.validate(data[col.fieldKey], rowIndex);
|
|
188
|
+
if (err) errors.push(err);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { data, errors, warnings };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─────────────────────────────────────────────────
|
|
196
|
+
// 4. Parse ngày / số
|
|
197
|
+
// ─────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Dựng ngày địa phương và TỪ CHỐI ngày không tồn tại. `new Date(2026, 5, 31)`
|
|
201
|
+
* tự cuộn sang 01/07 — nhập liệu mà im lặng đổi ngày chứng từ thì tệ hơn nhiều
|
|
202
|
+
* so với báo lỗi để người dùng sửa ô đó.
|
|
203
|
+
*/
|
|
204
|
+
function localDateFromParts(
|
|
205
|
+
year: number,
|
|
206
|
+
month: number,
|
|
207
|
+
day: number,
|
|
208
|
+
): Date | null {
|
|
209
|
+
const d = new Date(year, month - 1, day);
|
|
210
|
+
if (
|
|
211
|
+
d.getFullYear() !== year ||
|
|
212
|
+
d.getMonth() !== month - 1 ||
|
|
213
|
+
d.getDate() !== day
|
|
214
|
+
) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
return d;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Parse ngày linh hoạt: Date (từ cellDates), "dd/MM/yyyy", "yyyy-MM-dd", hoặc
|
|
222
|
+
* số serial của Excel (mốc 1899-12-30).
|
|
223
|
+
*/
|
|
224
|
+
export function parseFlexibleDate(value: any): Date | null {
|
|
225
|
+
if (value instanceof Date) {
|
|
226
|
+
return isNaN(value.getTime()) ? null : value;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (typeof value === "number") {
|
|
230
|
+
if (!Number.isFinite(value)) return null;
|
|
231
|
+
// Cộng dồn theo UTC rồi mới đổi sang giờ địa phương: mốc 1899 ở nhiều múi
|
|
232
|
+
// giờ còn là giờ mặt trời (Asia/Ho_Chi_Minh khi đó lệch +07:06:30), nên
|
|
233
|
+
// cộng thẳng vào `new Date(1899, 11, 30)` đẩy mọi serial về 23:53:30 hôm
|
|
234
|
+
// trước — cả file lùi một ngày.
|
|
235
|
+
const utc = new Date(Date.UTC(1899, 11, 30) + value * 86400000);
|
|
236
|
+
if (isNaN(utc.getTime())) return null;
|
|
237
|
+
return new Date(
|
|
238
|
+
utc.getUTCFullYear(),
|
|
239
|
+
utc.getUTCMonth(),
|
|
240
|
+
utc.getUTCDate(),
|
|
241
|
+
utc.getUTCHours(),
|
|
242
|
+
utc.getUTCMinutes(),
|
|
243
|
+
utc.getUTCSeconds(),
|
|
244
|
+
utc.getUTCMilliseconds(),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (typeof value === "string") {
|
|
249
|
+
const trimmed = value.trim();
|
|
250
|
+
|
|
251
|
+
const ddmmyyyy = trimmed.match(/^(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{4})$/);
|
|
252
|
+
if (ddmmyyyy) {
|
|
253
|
+
const [, day, month, year] = ddmmyyyy;
|
|
254
|
+
return localDateFromParts(Number(year), Number(month), Number(day));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const yyyymmdd = trimmed.match(/^(\d{4})[/\-.](\d{1,2})[/\-.](\d{1,2})$/);
|
|
258
|
+
if (yyyymmdd) {
|
|
259
|
+
const [, year, month, day] = yyyymmdd;
|
|
260
|
+
return localDateFromParts(Number(year), Number(month), Number(day));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const parsed = new Date(trimmed);
|
|
264
|
+
return isNaN(parsed.getTime()) ? null : parsed;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Parse số, chấp nhận cách viết Việt Nam ("1.234.567,89"). */
|
|
271
|
+
export function parseVietnameseNumber(value: any): number {
|
|
272
|
+
if (typeof value === "number") return value;
|
|
273
|
+
if (typeof value !== "string") return NaN;
|
|
274
|
+
|
|
275
|
+
let cleaned = value.trim();
|
|
276
|
+
|
|
277
|
+
if (cleaned.includes(".") && cleaned.includes(",")) {
|
|
278
|
+
// "1.234.567,89" → dấu chấm là phân nhóm nghìn
|
|
279
|
+
cleaned = cleaned.replace(/\./g, "").replace(",", ".");
|
|
280
|
+
} else if (cleaned.includes(",") && !cleaned.includes(".")) {
|
|
281
|
+
// "1234,56" → dấu phẩy là phần thập phân
|
|
282
|
+
cleaned = cleaned.replace(",", ".");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return Number(cleaned);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─────────────────────────────────────────────────
|
|
289
|
+
// 5. Dựng báo cáo
|
|
290
|
+
// ─────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Gộp kết quả thành báo cáo trả về client.
|
|
294
|
+
* Luật `success`: chế độ 'full' chỉ thành công khi KHÔNG có lỗi nào; 'partial'
|
|
295
|
+
* luôn coi là thành công (đã bỏ qua dòng lỗi). Chạy thử thì thành công = sạch lỗi.
|
|
296
|
+
*/
|
|
297
|
+
export function buildValidationReport(params: {
|
|
298
|
+
totalRows: number;
|
|
299
|
+
importedCount: number;
|
|
300
|
+
skippedCount: number;
|
|
301
|
+
errors: ImportRowError[];
|
|
302
|
+
warnings: ImportRowWarning[];
|
|
303
|
+
importedIds: string[];
|
|
304
|
+
mode: "full" | "partial";
|
|
305
|
+
dryRun: boolean;
|
|
306
|
+
}): {
|
|
307
|
+
success: boolean;
|
|
308
|
+
mode: string;
|
|
309
|
+
dryRun: boolean;
|
|
310
|
+
totalRows: number;
|
|
311
|
+
importedCount: number;
|
|
312
|
+
skippedCount: number;
|
|
313
|
+
errors: ImportRowError[];
|
|
314
|
+
warnings: ImportRowWarning[];
|
|
315
|
+
importedIds: string[];
|
|
316
|
+
} {
|
|
317
|
+
const { errors, mode, dryRun } = params;
|
|
318
|
+
|
|
319
|
+
const hasErrors = errors.length > 0;
|
|
320
|
+
const success = mode === "full" ? !hasErrors : true;
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
success: dryRun ? !hasErrors : success,
|
|
324
|
+
mode,
|
|
325
|
+
dryRun,
|
|
326
|
+
totalRows: params.totalRows,
|
|
327
|
+
importedCount: dryRun ? 0 : params.importedCount,
|
|
328
|
+
skippedCount: params.skippedCount,
|
|
329
|
+
errors,
|
|
330
|
+
warnings: params.warnings,
|
|
331
|
+
importedIds: dryRun ? [] : params.importedIds,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ─────────────────────────────────────────────────
|
|
336
|
+
// 6. Tiện ích sheet
|
|
337
|
+
// ─────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
/** Lấy sheet theo tên (bỏ qua hoa/thường và khoảng trắng thừa). */
|
|
340
|
+
export function findSheet(
|
|
341
|
+
parsed: Record<string, ParsedRow[]>,
|
|
342
|
+
name: string,
|
|
343
|
+
): ParsedRow[] | null {
|
|
344
|
+
const normalizedName = name.trim().toLowerCase();
|
|
345
|
+
for (const [sheetName, rows] of Object.entries(parsed)) {
|
|
346
|
+
if (sheetName.trim().toLowerCase() === normalizedName) {
|
|
347
|
+
return rows;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Tên các cột, lấy từ dòng dữ liệu đầu tiên. */
|
|
354
|
+
export function getHeaders(rows: ParsedRow[]): string[] {
|
|
355
|
+
if (rows.length === 0) return [];
|
|
356
|
+
return Object.keys(rows[0]);
|
|
357
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness NHẬP DỮ LIỆU dùng chung.
|
|
3
|
+
*
|
|
4
|
+
* Ba tầng, dùng riêng lẻ được:
|
|
5
|
+
* - `import-engine`: đọc/kiểm/chuẩn hoá workbook (thuần, chạy ở server).
|
|
6
|
+
* - `use-import`: máy trạng thái một lần nhập (client).
|
|
7
|
+
* - `ImportDialog`: MỘT hộp thoại nhập cho cả app (mẫu từ server hoặc từ
|
|
8
|
+
* EntityConfig). Đừng dựng hộp thoại nhập riêng nữa —
|
|
9
|
+
* `CrudImportDialog` cũ chỉ còn là lớp vỏ tương thích.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
buildValidationReport,
|
|
14
|
+
findSheet,
|
|
15
|
+
getHeaders,
|
|
16
|
+
normalizeRow,
|
|
17
|
+
parseExcelBuffer,
|
|
18
|
+
parseFlexibleDate,
|
|
19
|
+
parseVietnameseNumber,
|
|
20
|
+
validateHeaders,
|
|
21
|
+
} from "./import-engine";
|
|
22
|
+
|
|
23
|
+
export { useImport, type WorkbookImportResponse } from "./use-import";
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
ImportDialog,
|
|
27
|
+
normalizeImportResult,
|
|
28
|
+
type ImportDialogProps,
|
|
29
|
+
} from "./import-dialog";
|
|
30
|
+
|
|
31
|
+
export type {
|
|
32
|
+
ImportColumnDef,
|
|
33
|
+
ImportRowError,
|
|
34
|
+
ImportRowWarning,
|
|
35
|
+
ParsedRow,
|
|
36
|
+
WorkbookImportConfig,
|
|
37
|
+
WorkbookImportOptions,
|
|
38
|
+
WorkbookImportResult,
|
|
39
|
+
} from "./types";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kiểu dữ liệu cho HARNESS NHẬP WORKBOOK — nhập file Excel nhiều sheet, có
|
|
3
|
+
* định nghĩa cột và báo lỗi tới từng dòng/từng ô.
|
|
4
|
+
*
|
|
5
|
+
* ĐỪNG NHẦM với `ImportResult`/`ImportOptions` trong `@goerp/core/types`: bộ
|
|
6
|
+
* đó phục vụ nhập PHẲNG theo EntityConfig của CRUD (csv/json/xlsx một sheet,
|
|
7
|
+
* kết quả gọn `{imported, failed}`). Bộ ở đây dành cho chứng từ có phần đầu +
|
|
8
|
+
* phần dòng (đơn bán, đơn mua), nên tiền tố `Workbook`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ImportRowError {
|
|
12
|
+
/** Số dòng trong Excel (đếm từ dòng dữ liệu, không tính header). */
|
|
13
|
+
row: number;
|
|
14
|
+
/** Tên cột. */
|
|
15
|
+
column: string;
|
|
16
|
+
/** Giá trị gây lỗi. */
|
|
17
|
+
value: any;
|
|
18
|
+
/** Thông báo cho người dùng. */
|
|
19
|
+
message: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ImportRowWarning {
|
|
23
|
+
row: number;
|
|
24
|
+
column: string;
|
|
25
|
+
message: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WorkbookImportOptions {
|
|
29
|
+
/** 'full' = có lỗi thì rollback tất cả; 'partial' = bỏ qua dòng lỗi. */
|
|
30
|
+
mode: "full" | "partial";
|
|
31
|
+
/** Chỉ kiểm tra, không ghi DB. */
|
|
32
|
+
dryRun: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface WorkbookImportResult {
|
|
36
|
+
success: boolean;
|
|
37
|
+
mode: "full" | "partial";
|
|
38
|
+
totalRows: number;
|
|
39
|
+
importedCount: number;
|
|
40
|
+
skippedCount: number;
|
|
41
|
+
errors: ImportRowError[];
|
|
42
|
+
warnings: ImportRowWarning[];
|
|
43
|
+
importedIds: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Định nghĩa một cột trong file nhập. */
|
|
47
|
+
export interface ImportColumnDef {
|
|
48
|
+
/** Tên cột hiển thị trong Excel. */
|
|
49
|
+
excelHeader: string;
|
|
50
|
+
/** Khoá trường nội bộ sau khi chuẩn hoá. */
|
|
51
|
+
fieldKey: string;
|
|
52
|
+
type: "string" | "number" | "decimal" | "date" | "enum";
|
|
53
|
+
/** Mặc định không bắt buộc — bảng cột dài thì khai `required: true` cho vài cột gọn hơn. */
|
|
54
|
+
required?: boolean;
|
|
55
|
+
/** Giá trị hợp lệ khi `type: 'enum'`. */
|
|
56
|
+
enumValues?: string[];
|
|
57
|
+
/** Giá trị mặc định khi ô trống. */
|
|
58
|
+
defaultValue?: any;
|
|
59
|
+
/** Kiểm tra riêng của nghiệp vụ. */
|
|
60
|
+
validate?: (value: any, row: number) => ImportRowError | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Một dòng đã đọc từ Excel (trước khi validate nghiệp vụ). */
|
|
64
|
+
export type ParsedRow = Record<string, any>;
|
|
65
|
+
|
|
66
|
+
/** Cấu hình một lần nhập: sheet phần đầu + sheet phần dòng. */
|
|
67
|
+
export interface WorkbookImportConfig {
|
|
68
|
+
headerColumns: ImportColumnDef[];
|
|
69
|
+
lineColumns: ImportColumnDef[];
|
|
70
|
+
headerSheetName: string;
|
|
71
|
+
lineSheetName: string;
|
|
72
|
+
maxRows: number;
|
|
73
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import type { ImportRowError, ImportRowWarning } from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Máy trạng thái cho một lần nhập file: chọn file → gửi → nhận báo cáo.
|
|
9
|
+
* Tách khỏi `ImportDialog` để màn hình nào muốn giao diện riêng vẫn dùng lại
|
|
10
|
+
* được luồng (và để test được không cần DOM).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
interface ImportState {
|
|
14
|
+
step: "idle" | "uploading" | "validating" | "importing" | "done" | "error";
|
|
15
|
+
file: File | null;
|
|
16
|
+
dryRun: boolean;
|
|
17
|
+
mode: "full" | "partial";
|
|
18
|
+
result: WorkbookImportResponse | null;
|
|
19
|
+
errorMessage: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Thân JSON server trả về cho một lần nhập workbook. */
|
|
23
|
+
export interface WorkbookImportResponse {
|
|
24
|
+
success: boolean;
|
|
25
|
+
mode: string;
|
|
26
|
+
dryRun: boolean;
|
|
27
|
+
totalRows: number;
|
|
28
|
+
importedCount: number;
|
|
29
|
+
skippedCount: number;
|
|
30
|
+
errors: ImportRowError[];
|
|
31
|
+
warnings: ImportRowWarning[];
|
|
32
|
+
importedIds: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface UseImportOptions {
|
|
36
|
+
/** Endpoint nhận POST multipart. */
|
|
37
|
+
apiUrl: string;
|
|
38
|
+
/** Gọi khi nhập thật (không phải chạy thử) và có ít nhất 1 bản ghi vào. */
|
|
39
|
+
onSuccess?: (result: WorkbookImportResponse) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useImport({ apiUrl, onSuccess }: UseImportOptions) {
|
|
43
|
+
const [state, setState] = useState<ImportState>({
|
|
44
|
+
step: "idle",
|
|
45
|
+
file: null,
|
|
46
|
+
dryRun: false,
|
|
47
|
+
mode: "full",
|
|
48
|
+
result: null,
|
|
49
|
+
errorMessage: null,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
53
|
+
|
|
54
|
+
const setFile = useCallback((file: File | null) => {
|
|
55
|
+
setState((prev) => ({
|
|
56
|
+
...prev,
|
|
57
|
+
file,
|
|
58
|
+
step: "idle",
|
|
59
|
+
result: null,
|
|
60
|
+
errorMessage: null,
|
|
61
|
+
}));
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const setDryRun = useCallback((dryRun: boolean) => {
|
|
65
|
+
setState((prev) => ({ ...prev, dryRun }));
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const setMode = useCallback((mode: "full" | "partial") => {
|
|
69
|
+
setState((prev) => ({ ...prev, mode }));
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const reset = useCallback(() => {
|
|
73
|
+
abortRef.current?.abort();
|
|
74
|
+
setState({
|
|
75
|
+
step: "idle",
|
|
76
|
+
file: null,
|
|
77
|
+
dryRun: false,
|
|
78
|
+
mode: "full",
|
|
79
|
+
result: null,
|
|
80
|
+
errorMessage: null,
|
|
81
|
+
});
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
const execute = useCallback(async () => {
|
|
85
|
+
if (!state.file) {
|
|
86
|
+
setState((prev) => ({
|
|
87
|
+
...prev,
|
|
88
|
+
errorMessage: "Vui lòng chọn file để import",
|
|
89
|
+
}));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
abortRef.current = controller;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
setState((prev) => ({ ...prev, step: "uploading", errorMessage: null }));
|
|
98
|
+
|
|
99
|
+
const formData = new FormData();
|
|
100
|
+
formData.append("file", state.file);
|
|
101
|
+
formData.append("mode", state.mode);
|
|
102
|
+
formData.append("dryRun", String(state.dryRun));
|
|
103
|
+
|
|
104
|
+
setState((prev) => ({
|
|
105
|
+
...prev,
|
|
106
|
+
step: state.dryRun ? "validating" : "importing",
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
const response = await fetch(apiUrl, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
body: formData,
|
|
112
|
+
signal: controller.signal,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const data: WorkbookImportResponse = await response.json();
|
|
116
|
+
|
|
117
|
+
// Lỗi không có thân báo cáo (401/403/500 trần) thì ném để nhánh catch lo.
|
|
118
|
+
if (!response.ok && !data.errors) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
(data as any).error || `Lỗi server (${response.status})`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
setState((prev) => ({
|
|
125
|
+
...prev,
|
|
126
|
+
step: "done",
|
|
127
|
+
result: data,
|
|
128
|
+
errorMessage: null,
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
if (data.success && data.importedCount > 0 && !data.dryRun) {
|
|
132
|
+
onSuccess?.(data);
|
|
133
|
+
}
|
|
134
|
+
} catch (error: any) {
|
|
135
|
+
if (error.name === "AbortError") return;
|
|
136
|
+
setState((prev) => ({
|
|
137
|
+
...prev,
|
|
138
|
+
step: "error",
|
|
139
|
+
errorMessage: error.message || "Lỗi không xác định",
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
}, [state.file, state.mode, state.dryRun, apiUrl, onSuccess]);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
...state,
|
|
146
|
+
setFile,
|
|
147
|
+
setDryRun,
|
|
148
|
+
setMode,
|
|
149
|
+
execute,
|
|
150
|
+
reset,
|
|
151
|
+
isLoading:
|
|
152
|
+
state.step === "uploading" ||
|
|
153
|
+
state.step === "validating" ||
|
|
154
|
+
state.step === "importing",
|
|
155
|
+
};
|
|
156
|
+
}
|