@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
package/PLATFORM.md
CHANGED
|
@@ -117,6 +117,50 @@ import { toCsv } from "@goerp/core/export/to-csv" // server CSV routes
|
|
|
117
117
|
return new Response(toCsv(rows, columns), { headers: { ... } })
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
+
## Import
|
|
121
|
+
|
|
122
|
+
One dialog for both server contracts — a workbook harness (`templateApiUrl`) or
|
|
123
|
+
an EntityConfig-driven CRUD import (`config`). A `202` reply means the server
|
|
124
|
+
queued a background task; the dialog closes instead of reporting "0 rows".
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
import { ImportDialog } from "@goerp/core/import"
|
|
128
|
+
|
|
129
|
+
<ImportDialog
|
|
130
|
+
apiUrl={`/api/purchase-orders/import`}
|
|
131
|
+
templateApiUrl={`/api/purchase-orders/import/template`}
|
|
132
|
+
tasksPath={`/${lang}/tasks`} // where the 202 toast links to
|
|
133
|
+
canImport={can("purchase-orders", "create")}
|
|
134
|
+
/>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The parsing half is pure and server-side — domain import services keep the DB
|
|
138
|
+
work and call these:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { parseExcelBuffer, validateHeaders, normalizeRow } from "@goerp/core/import"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`parseFlexibleDate` refuses impossible dates (`31/06`) instead of rolling them
|
|
145
|
+
into the next month, and decodes Excel serials in UTC — local-time arithmetic is
|
|
146
|
+
off by a day in zones with pre-1911 LMT offsets (Asia/Ho_Chi_Minh among them).
|
|
147
|
+
|
|
148
|
+
Note: `@goerp/core/import` types carry a `Workbook` prefix
|
|
149
|
+
(`WorkbookImportResult`, …) so they don't collide with the flat CRUD
|
|
150
|
+
`ImportResult`/`ImportOptions` in `@goerp/core/types`.
|
|
151
|
+
|
|
152
|
+
## Advanced filters
|
|
153
|
+
|
|
154
|
+
The client half of the CRUD filter-tree DSL — nested AND/OR groups that
|
|
155
|
+
serialize to the same `FilterTreeNode` the server query builder consumes.
|
|
156
|
+
|
|
157
|
+
```tsx
|
|
158
|
+
import { AdvancedFilterBuilder } from "@goerp/core/ui/filters/advanced-filter-builder"
|
|
159
|
+
|
|
160
|
+
<AdvancedFilterBuilder fields={FIELDS} value={tree} onChange={setTree} />
|
|
161
|
+
// tzOffset defaults to "+07:00": a date leaf means that zone's whole day
|
|
162
|
+
```
|
|
163
|
+
|
|
120
164
|
## Print
|
|
121
165
|
|
|
122
166
|
Core owns *how* to print; the app supplies *what* via `renderContent`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goplusvn/core",
|
|
3
3
|
"description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.57",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"./notification/ui": "./src/notification/ui/index.ts",
|
|
48
48
|
"./tasks": "./src/tasks/index.ts",
|
|
49
49
|
"./tasks/ui": "./src/tasks/ui/task-list-client.tsx",
|
|
50
|
+
"./import": "./src/import/index.ts",
|
|
50
51
|
"./auth/proxy-gate": "./src/auth/proxy-gate.ts",
|
|
51
52
|
"./rbac/route-handlers": "./src/rbac/route-handlers.ts",
|
|
52
53
|
"./rbac/permissions-version": "./src/rbac/permissions-version.ts",
|
|
@@ -74,6 +75,7 @@
|
|
|
74
75
|
"./ui/primitives/sidebar": "./src/ui/primitives/sidebar.tsx",
|
|
75
76
|
"./ui/primitives/select": "./src/ui/primitives/select.tsx",
|
|
76
77
|
"./ui/forms/multi-select": "./src/ui/forms/multi-select.tsx",
|
|
78
|
+
"./ui/filters/advanced-filter-builder": "./src/ui/filters/advanced-filter-builder.tsx",
|
|
77
79
|
"./ui/data-display/collapsible": "./src/ui/data-display/collapsible.tsx",
|
|
78
80
|
"./system/services/settings-service": "./src/system/services/settings-service.ts",
|
|
79
81
|
"./system/services/system-category-service": "./src/system/services/system-category-service.ts",
|
|
@@ -117,7 +119,6 @@
|
|
|
117
119
|
"eslint-plugin-react-hooks": "^7.0.1",
|
|
118
120
|
"globals": "16.5.0",
|
|
119
121
|
"jsdom": "^27.2.0",
|
|
120
|
-
"next-auth": "4.24.11",
|
|
121
122
|
"tsup": "^8.5.1",
|
|
122
123
|
"typescript": "^5.7.3",
|
|
123
124
|
"typescript-eslint": "^8.50.1",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
// Đọc theo cwd chứ không theo import.meta.url: môi trường test là jsdom nên
|
|
7
|
+
// import.meta.url là URL http, fileURLToPath ném "URL must be of scheme file".
|
|
8
|
+
const source = readFileSync(resolve(process.cwd(), "src/auth/proxy-gate.ts"), "utf8")
|
|
9
|
+
// Bỏ chú thích: phần header có ví dụ dùng, trong đó cũng có chữ `import`.
|
|
10
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
11
|
+
.replace(/^\s*\/\/.*$/gm, "");
|
|
12
|
+
|
|
13
|
+
describe("proxy-gate không kéo theo thư viện auth nào", () => {
|
|
14
|
+
// Bẫy đã trả giá: proxy-gate từng `await import("next-auth/jwt")` trong nhánh
|
|
15
|
+
// mặc định. Bundler phân giải TĨNH cả dynamic import, nên mọi app Better Auth
|
|
16
|
+
// (không cài next-auth) đều gãy middleware bằng "Module not found" — mà trong
|
|
17
|
+
// workspace này thì không lộ, vì next-auth nằm ở devDependencies của core.
|
|
18
|
+
// Middleware chạy ở Edge: đừng thêm import nào không phải next/server.
|
|
19
|
+
it("chỉ import next/server", () => {
|
|
20
|
+
const specifiers = [
|
|
21
|
+
...source.matchAll(/(?:^|\s)import\s+(?:type\s+)?[^"']*from\s*["']([^"']+)["']/gm),
|
|
22
|
+
...source.matchAll(/\bimport\(\s*["']([^"']+)["']\s*\)/g),
|
|
23
|
+
].map((m) => m[1]);
|
|
24
|
+
|
|
25
|
+
expect([...new Set(specifiers)].sort()).toEqual(["next/server"]);
|
|
26
|
+
});
|
|
27
|
+
});
|
package/src/auth/proxy-gate.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @goerp/core/auth/proxy-gate — server-only request-gate for the Next.js
|
|
2
2
|
// proxy/middleware. Default-DENY authentication (authN); route-level authZ still
|
|
3
3
|
// happens via getCrudPermissions/checkPermission. Isolated in its own subpath so
|
|
4
|
-
// `next/server`
|
|
4
|
+
// `next/server` never leaks into client bundles via the auth barrel.
|
|
5
5
|
//
|
|
6
6
|
// Usage (app side):
|
|
7
7
|
// // src/proxy.ts
|
|
@@ -14,7 +14,7 @@ import { NextResponse } from "next/server";
|
|
|
14
14
|
import type { NextRequest } from "next/server";
|
|
15
15
|
|
|
16
16
|
export interface AuthProxyOptions {
|
|
17
|
-
/** API prefixes served without a session (
|
|
17
|
+
/** API prefixes served without a session (auth handler + public). Default: /api/auth, /api/public. */
|
|
18
18
|
publicApiPrefixes?: string[];
|
|
19
19
|
/** Pages reachable while logged out. Default: /sign-in. */
|
|
20
20
|
publicPages?: string[];
|
|
@@ -22,33 +22,49 @@ export interface AuthProxyOptions {
|
|
|
22
22
|
signInPath?: string;
|
|
23
23
|
/** Where to send a logged-in user who hits a guest page. Default: "/". */
|
|
24
24
|
homePath?: string;
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Session reader. STRONGLY recommended — pass the one your auth library ships
|
|
27
|
+
* (Better Auth: `getSessionCookie` from "better-auth/cookies"; NextAuth:
|
|
28
|
+
* `getToken` from "next-auth/jwt"). Default: presence of a known session
|
|
29
|
+
* cookie, see SESSION_COOKIE_NAMES.
|
|
30
|
+
*/
|
|
26
31
|
getToken?: (req: NextRequest) => Promise<unknown | null>;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
const startsWithAny = (pathname: string, list: string[]) =>
|
|
30
35
|
list.some((p) => pathname === p || pathname.startsWith(`${p}/`));
|
|
31
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Cookie tên gì thì coi như "có phiên" — dùng cho trường hợp app không truyền
|
|
39
|
+
* getToken. Middleware chạy ở Edge nên đây CHỈ là rào authN thô; chữ ký/hạn
|
|
40
|
+
* dùng vẫn do từng route kiểm qua getSession(). Không import next-auth ở đây:
|
|
41
|
+
* bundler phân giải tĩnh cả `await import()`, nên một dòng import next-auth
|
|
42
|
+
* trong nhánh chết cũng đủ làm mọi app Better Auth gãy middleware bằng
|
|
43
|
+
* "Module not found: Can't resolve 'next-auth/jwt'".
|
|
44
|
+
*/
|
|
45
|
+
const SESSION_COOKIE_NAMES = [
|
|
46
|
+
"better-auth.session_token",
|
|
47
|
+
"__Secure-better-auth.session_token",
|
|
48
|
+
"next-auth.session-token",
|
|
49
|
+
"__Secure-next-auth.session-token",
|
|
50
|
+
"authjs.session-token",
|
|
51
|
+
"__Secure-authjs.session-token",
|
|
52
|
+
];
|
|
53
|
+
|
|
32
54
|
export function createAuthProxy(options: AuthProxyOptions = {}) {
|
|
33
55
|
const publicApiPrefixes = options.publicApiPrefixes ?? ["/api/auth", "/api/public"];
|
|
34
56
|
const publicPages = options.publicPages ?? ["/sign-in"];
|
|
35
57
|
const signInPath = options.signInPath ?? "/sign-in";
|
|
36
58
|
const homePath = options.homePath ?? "/";
|
|
37
|
-
// next-auth chỉ được LAZY-load khi app không truyền getToken riêng — app đã
|
|
38
|
-
// sang Better Auth (không cài next-auth) sẽ không dính module-not-found lúc
|
|
39
|
-
// import proxy-gate (trước đây import top-level, next-auth lại chỉ nằm ở
|
|
40
|
-
// devDependencies của core).
|
|
41
59
|
const readToken =
|
|
42
60
|
options.getToken ??
|
|
43
|
-
(async (req: NextRequest) =>
|
|
44
|
-
|
|
45
|
-
return getToken({ req });
|
|
46
|
-
});
|
|
61
|
+
(async (req: NextRequest) =>
|
|
62
|
+
SESSION_COOKIE_NAMES.some((name) => req.cookies.has(name)) ? { cookie: true } : null);
|
|
47
63
|
|
|
48
64
|
return async function proxy(request: NextRequest) {
|
|
49
65
|
const { pathname, search } = request.nextUrl;
|
|
50
66
|
|
|
51
|
-
// API routes that authenticate themselves (
|
|
67
|
+
// API routes that authenticate themselves (the auth handler) or are public → pass.
|
|
52
68
|
if (startsWithAny(pathname, publicApiPrefixes)) return NextResponse.next();
|
|
53
69
|
|
|
54
70
|
const token = await readToken(request);
|
|
@@ -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
|
}
|