@goplusvn/core 0.1.55 → 0.1.56

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 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.55",
4
+ "version": "0.1.56",
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",
@@ -1,33 +1,18 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
- import { useDropzone } from "react-dropzone";
5
- import { AlertCircle, Download, FileText, Upload, X } from "lucide-react";
6
-
7
- import type { EntityConfig, ImportOptions, ImportResult } from "../../types";
8
-
9
- import {
10
- downloadFile,
11
- generateCSVTemplate,
12
- generateJSONTemplate,
13
- generateXLSXTemplate,
14
- parseFile,
15
- validateImportData,
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: controlledOpen,
45
- onOpenChange: controlledOnOpenChange,
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
- <Dialog open={open} onOpenChange={setOpen}>
258
- <DialogTrigger asChild>
259
- <Button variant="outline" size="sm">
260
- <Upload className="mr-2 h-4 w-4" />
261
- Import
262
- </Button>
263
- </DialogTrigger>
264
- <DialogContent className="sm:max-w-[640px]">
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
  }
@@ -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(""),