@goplusvn/core 0.1.49 → 0.1.51

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,61 @@
1
+ #!/usr/bin/env node
2
+ // CLI `goerp-features` — đồng bộ feature CÓ BẢNG DB của @goerp/core vào app.
3
+ //
4
+ // pnpm goerp-features status # feature/bước nào còn thiếu
5
+ // pnpm goerp-features sync # copy schema + materialize migrations
6
+ // pnpm goerp-features sync --only background-tasks
7
+ //
8
+ // Sau sync: chạy `pnpm prisma migrate deploy` (flow deploy hiện có) rồi
9
+ // `pnpm prisma generate`. SQL của core viết IDEMPOTENT nên app đã có bảng
10
+ // từ trước chạy lại vô hại.
11
+
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname, join } from "node:path";
14
+
15
+ import { featureStatus, syncFeatures } from "../scripts/feature-sync.mjs";
16
+
17
+ const featuresRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "features");
18
+ const appRoot = process.cwd();
19
+
20
+ const [, , command = "status", ...rest] = process.argv;
21
+ const onlyIdx = rest.indexOf("--only");
22
+ const only = onlyIdx >= 0 ? rest.slice(onlyIdx + 1).filter((a) => !a.startsWith("-")) : undefined;
23
+
24
+ if (command === "status") {
25
+ const statuses = featureStatus({ featuresRoot, appRoot });
26
+ if (statuses.length === 0) {
27
+ console.log("Không có feature nào trong @goerp/core/features.");
28
+ process.exit(0);
29
+ }
30
+ for (const f of statuses) {
31
+ const pending = f.steps.filter((s) => !s.materialized).length;
32
+ console.log(
33
+ `${pending === 0 && (!f.hasSchema || f.schemaSynced) ? "✓" : "•"} ${f.feature}` +
34
+ ` (schema: ${f.hasSchema ? (f.schemaSynced ? "đã sync" : "CHƯA sync") : "—"};` +
35
+ ` migrations: ${f.steps.length - pending}/${f.steps.length})`
36
+ );
37
+ for (const s of f.steps.filter((s) => !s.materialized)) {
38
+ console.log(` ↳ thiếu bước ${s.num}_${s.slug}`);
39
+ }
40
+ }
41
+ process.exit(0);
42
+ }
43
+
44
+ if (command === "sync") {
45
+ const actions = syncFeatures({ featuresRoot, appRoot, only });
46
+ if (actions.length === 0) {
47
+ console.log("✓ Không có gì để sync — app đã đủ schema + migrations.");
48
+ } else {
49
+ for (const a of actions) {
50
+ if (a.type === "schema") console.log(`✓ schema ${a.feature} → ${a.target}`);
51
+ else console.log(`✓ migrate ${a.feature} + ${a.step} → prisma/migrations/${a.dir}`);
52
+ }
53
+ console.log(
54
+ "\nTiếp theo: pnpm prisma migrate deploy && pnpm prisma generate"
55
+ );
56
+ }
57
+ process.exit(0);
58
+ }
59
+
60
+ console.error(`Lệnh không hợp lệ: ${command}. Dùng: status | sync [--only <feature>…]`);
61
+ process.exit(1);
@@ -0,0 +1,33 @@
1
+ # @goerp/core features — tính năng có bảng DB
2
+
3
+ Mỗi thư mục con là một feature core ship kèm **bảng DB**. App tiêu thụ chạy:
4
+
5
+ ```bash
6
+ pnpm goerp-features status # xem thiếu gì
7
+ pnpm goerp-features sync # copy schema + materialize migrations
8
+ pnpm prisma migrate deploy && pnpm prisma generate
9
+ ```
10
+
11
+ `sync` copy `schema.prisma` → `prisma/schema/goerp-<feature>.prisma` (file
12
+ GENERATED — đừng sửa tay) và tạo thư mục migration
13
+ `<timestamp>_goerp_<feature>_<NNN>_<slug>/` cho bước nào app chưa có. App giữ
14
+ **một** hệ migration duy nhất (`prisma migrate deploy`), không có DDL lúc boot.
15
+
16
+ ## Luật viết feature (tác giả core PHẢI theo)
17
+
18
+ 1. **SQL idempotent tuyệt đối** — `CREATE TABLE IF NOT EXISTS`,
19
+ `CREATE INDEX IF NOT EXISTS`, `ALTER TABLE … ADD COLUMN IF NOT EXISTS`.
20
+ Lý do: app từng tự tạo bảng bằng migration tay (vinhhoa) chạy lại phải vô hại.
21
+ 2. **Bước đã publish là BẤT BIẾN** — không sửa file `NNN_*.sql` cũ; đổi schema
22
+ thì thêm bước mới `NNN+1_*.sql` (và cập nhật `schema.prisma` fragment khớp).
23
+ 3. Tên bước: `NNN_slug.sql` (NNN ≥ 3 chữ số, slug kebab-case).
24
+ 4. `schema.prisma` fragment chỉ chứa model của feature này, dùng `@map`/`@@map`
25
+ snake_case như convention chung.
26
+ 5. Feature cần seed/RBAC: ghi rõ trong README của feature — seed chạy phía app
27
+ (Permission Registry của app là nguồn sự thật quyền).
28
+
29
+ ## Feature hiện có
30
+
31
+ - `background-tasks` — bảng `background_tasks` cho trung tâm tác vụ nền
32
+ (export/import chạy nền). Runtime hiện ở app (vinhhoa `src/server/tasks`);
33
+ sẽ dời lên core ở đợt F1.
@@ -0,0 +1,23 @@
1
+ -- goerp feature: background-tasks — bước 0001 (idempotent: app đã tự tạo bảng
2
+ -- từ trước chạy lại vô hại; quy ước MỌI bước SQL của core đều IF NOT EXISTS).
3
+ CREATE TABLE IF NOT EXISTS "background_tasks" (
4
+ "id" TEXT NOT NULL,
5
+ "type" TEXT NOT NULL,
6
+ "title" TEXT NOT NULL,
7
+ "status" TEXT NOT NULL DEFAULT 'pending',
8
+ "progress" INTEGER NOT NULL DEFAULT 0,
9
+ "params" JSONB,
10
+ "result" JSONB,
11
+ "error" TEXT,
12
+ "created_by" TEXT NOT NULL,
13
+ "branch_id" TEXT,
14
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
15
+ "started_at" TIMESTAMP(3),
16
+ "finished_at" TIMESTAMP(3),
17
+
18
+ CONSTRAINT "background_tasks_pkey" PRIMARY KEY ("id")
19
+ );
20
+
21
+ CREATE INDEX IF NOT EXISTS "background_tasks_created_by_created_at_idx" ON "background_tasks"("created_by", "created_at");
22
+
23
+ CREATE INDEX IF NOT EXISTS "background_tasks_status_idx" ON "background_tasks"("status");
@@ -0,0 +1,22 @@
1
+ // Tác vụ nền (export/import lớn…): hàng đợi trong DB, worker in-process.
2
+ // status: pending → running → success | error.
3
+ // result: { fileKey?, fileName?, rowCount?, errorFileKey?, summary? }.
4
+ model BackgroundTask {
5
+ id String @id @default(cuid()) @map("id")
6
+ type String @map("type")
7
+ title String @map("title")
8
+ status String @default("pending") @map("status")
9
+ progress Int @default(0) @map("progress")
10
+ params Json? @map("params")
11
+ result Json? @map("result")
12
+ error String? @map("error")
13
+ createdBy String @map("created_by")
14
+ branchId String? @map("branch_id")
15
+ createdAt DateTime @default(now()) @map("created_at")
16
+ startedAt DateTime? @map("started_at")
17
+ finishedAt DateTime? @map("finished_at")
18
+
19
+ @@index([createdBy, createdAt])
20
+ @@index([status])
21
+ @@map("background_tasks")
22
+ }
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.49",
4
+ "version": "0.1.51",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -11,8 +11,14 @@
11
11
  "module": "./src/index.ts",
12
12
  "types": "./src/index.ts",
13
13
  "sideEffects": false,
14
+ "bin": {
15
+ "goerp-features": "./bin/goerp-features.mjs"
16
+ },
14
17
  "files": [
15
18
  "src",
19
+ "bin",
20
+ "scripts",
21
+ "features",
16
22
  "README.md",
17
23
  "CHANGELOG.md",
18
24
  "PLATFORM.md"
@@ -37,6 +43,8 @@
37
43
  "./assets/*": "./src/assets/*",
38
44
  "./styles/*": "./src/styles/*",
39
45
  "./auth/api-handler": "./src/auth/api-handler.ts",
46
+ "./tasks": "./src/tasks/index.ts",
47
+ "./tasks/ui": "./src/tasks/ui/task-list-client.tsx",
40
48
  "./auth/proxy-gate": "./src/auth/proxy-gate.ts",
41
49
  "./rbac/route-handlers": "./src/rbac/route-handlers.ts",
42
50
  "./rbac/permissions-version": "./src/rbac/permissions-version.ts",
@@ -0,0 +1,145 @@
1
+ // Feature sync — bộ máy cho CLI `goerp-features` (bin/goerp-features.mjs).
2
+ //
3
+ // BÀI TOÁN: @goerp/core ship tính năng CÓ BẢNG DB (background-tasks,
4
+ // notifications, error-logs…) qua npm; mỗi app tiêu thụ (vinhhoa, thingtodo,
5
+ // app init mới) trước đây phải chép tay schema + migration → thiếu sót, lệch.
6
+ //
7
+ // THIẾT KẾ (chọn "materialize vào prisma/migrations của app" thay vì ledger
8
+ // runtime kiểu NocoBase — không DDL lúc boot, app giữ MỘT hệ migration duy
9
+ // nhất `prisma migrate deploy`):
10
+ // - Core ship mỗi feature tại features/<name>/:
11
+ // schema.prisma — fragment model (copy vào schema dir của app)
12
+ // migrations/NNN_slug.sql — bước SQL đánh số, PHẢI viết IDEMPOTENT
13
+ // (CREATE TABLE IF NOT EXISTS…) để app đã có
14
+ // bảng từ trước (vinhhoa) chạy lại vô hại.
15
+ // - `sync`: copy fragment → prisma/schema/goerp-<feature>.prisma (đè, có
16
+ // header GENERATED) + với mỗi bước CHƯA materialize, tạo thư mục
17
+ // <timestamp>_goerp_<feature>_<NNN>_<slug>/migration.sql. Nhận bước đã
18
+ // materialize bằng ĐUÔI TÊN (bỏ qua timestamp) nên chạy lại idempotent.
19
+ // - `status`: liệt kê feature/bước, cái nào pending.
20
+ //
21
+ // Logic thuần (fs qua tham số path) để unit-test được bằng vitest trên tmp dir.
22
+
23
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+
26
+ /** features/<name>/migrations/NNN_slug.sql → [{num, slug, file}] theo thứ tự. */
27
+ export function listFeatureSteps(featureDir) {
28
+ const dir = join(featureDir, "migrations");
29
+ if (!existsSync(dir)) return [];
30
+ return readdirSync(dir)
31
+ .filter((f) => /^\d{3,}_[a-z0-9-]+\.sql$/.test(f))
32
+ .sort()
33
+ .map((f) => {
34
+ const m = f.match(/^(\d{3,})_([a-z0-9-]+)\.sql$/);
35
+ return { num: m[1], slug: m[2], file: join(dir, f) };
36
+ });
37
+ }
38
+
39
+ /** Tên mọi feature trong thư mục features/ của core. */
40
+ export function listFeatures(featuresRoot) {
41
+ if (!existsSync(featuresRoot)) return [];
42
+ return readdirSync(featuresRoot, { withFileTypes: true })
43
+ .filter((e) => e.isDirectory())
44
+ .map((e) => e.name)
45
+ .sort();
46
+ }
47
+
48
+ /** Đuôi định danh của một bước — phần tên KHÔNG đổi giữa các app/timestamp. */
49
+ export function stepDirSuffix(feature, step) {
50
+ return `goerp_${feature.replace(/-/g, "_")}_${step.num}_${step.slug.replace(/-/g, "_")}`;
51
+ }
52
+
53
+ /** Bước đã được materialize vào prisma/migrations của app chưa? */
54
+ export function isStepMaterialized(appMigrationsDir, feature, step) {
55
+ if (!existsSync(appMigrationsDir)) return false;
56
+ const suffix = `_${stepDirSuffix(feature, step)}`;
57
+ return readdirSync(appMigrationsDir).some((d) => d.endsWith(suffix));
58
+ }
59
+
60
+ /**
61
+ * Timestamp prefix kiểu prisma (YYYYMMDDHHMMSS). `offset` giây cộng thêm để
62
+ * nhiều bước sinh trong cùng lần sync giữ đúng thứ tự.
63
+ */
64
+ export function migrationTimestamp(now, offset = 0) {
65
+ const d = new Date(now.getTime() + offset * 1000);
66
+ const p = (n, w = 2) => String(n).padStart(w, "0");
67
+ return (
68
+ `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` +
69
+ `${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Trạng thái toàn bộ feature so với một app.
75
+ * → [{feature, steps: [{num, slug, materialized}] , schemaTarget, schemaSynced}]
76
+ */
77
+ export function featureStatus({ featuresRoot, appRoot }) {
78
+ const migrationsDir = join(appRoot, "prisma", "migrations");
79
+ return listFeatures(featuresRoot).map((feature) => {
80
+ const featureDir = join(featuresRoot, feature);
81
+ const schemaSource = join(featureDir, "schema.prisma");
82
+ const schemaTarget = join(appRoot, "prisma", "schema", `goerp-${feature}.prisma`);
83
+ const schemaSynced =
84
+ existsSync(schemaSource) &&
85
+ existsSync(schemaTarget) &&
86
+ readFileSync(schemaTarget, "utf8").includes(readFileSync(schemaSource, "utf8").trim());
87
+ return {
88
+ feature,
89
+ hasSchema: existsSync(schemaSource),
90
+ schemaTarget,
91
+ schemaSynced,
92
+ steps: listFeatureSteps(featureDir).map((step) => ({
93
+ ...step,
94
+ materialized: isStepMaterialized(migrationsDir, feature, step),
95
+ })),
96
+ };
97
+ });
98
+ }
99
+
100
+ const GENERATED_HEADER = (feature) =>
101
+ `// GENERATED từ @goerp/core (features/${feature}/schema.prisma) — ĐỪNG sửa tay.\n` +
102
+ `// Nâng cấp core xong chạy: pnpm goerp-features sync\n\n`;
103
+
104
+ /**
105
+ * Đồng bộ 1 app: copy schema fragments + materialize các bước SQL còn thiếu.
106
+ * Trả về log hành động (để CLI in + test assert). KHÔNG chạy prisma — app tự
107
+ * `prisma migrate deploy` (giữ nguyên flow deploy hiện có).
108
+ *
109
+ * @param {{ featuresRoot: string, appRoot: string, now?: Date, only?: string[] }} options
110
+ */
111
+ export function syncFeatures({ featuresRoot, appRoot, now = new Date(), only = [] }) {
112
+ const actions = [];
113
+ const migrationsDir = join(appRoot, "prisma", "migrations");
114
+ let offset = 0;
115
+
116
+ for (const feature of listFeatures(featuresRoot)) {
117
+ if (only.length > 0 && !only.includes(feature)) continue;
118
+ const featureDir = join(featuresRoot, feature);
119
+
120
+ // 1) Schema fragment → prisma/schema/goerp-<feature>.prisma (đè)
121
+ const schemaSource = join(featureDir, "schema.prisma");
122
+ if (existsSync(schemaSource)) {
123
+ const schemaDir = join(appRoot, "prisma", "schema");
124
+ mkdirSync(schemaDir, { recursive: true });
125
+ const target = join(schemaDir, `goerp-${feature}.prisma`);
126
+ const content = GENERATED_HEADER(feature) + readFileSync(schemaSource, "utf8");
127
+ const changed = !existsSync(target) || readFileSync(target, "utf8") !== content;
128
+ if (changed) {
129
+ writeFileSync(target, content);
130
+ actions.push({ type: "schema", feature, target });
131
+ }
132
+ }
133
+
134
+ // 2) Materialize các bước SQL chưa có
135
+ for (const step of listFeatureSteps(featureDir)) {
136
+ if (isStepMaterialized(migrationsDir, feature, step)) continue;
137
+ const dirName = `${migrationTimestamp(now, offset++)}_${stepDirSuffix(feature, step)}`;
138
+ const dir = join(migrationsDir, dirName);
139
+ mkdirSync(dir, { recursive: true });
140
+ cpSync(step.file, join(dir, "migration.sql"));
141
+ actions.push({ type: "migration", feature, step: `${step.num}_${step.slug}`, dir: dirName });
142
+ }
143
+ }
144
+ return actions;
145
+ }
@@ -0,0 +1,135 @@
1
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+
7
+ import { featureStatus, migrationTimestamp, syncFeatures } from "../../../scripts/feature-sync.mjs";
8
+
9
+ let featuresRoot: string;
10
+ let appRoot: string;
11
+
12
+ function writeFeature(name: string, steps: Record<string, string>, schema?: string) {
13
+ const dir = join(featuresRoot, name);
14
+ mkdirSync(join(dir, "migrations"), { recursive: true });
15
+ if (schema) writeFileSync(join(dir, "schema.prisma"), schema);
16
+ for (const [file, sql] of Object.entries(steps)) {
17
+ writeFileSync(join(dir, "migrations", file), sql);
18
+ }
19
+ }
20
+
21
+ beforeEach(() => {
22
+ featuresRoot = mkdtempSync(join(tmpdir(), "goerp-features-"));
23
+ appRoot = mkdtempSync(join(tmpdir(), "goerp-app-"));
24
+ mkdirSync(join(appRoot, "prisma", "migrations"), { recursive: true });
25
+ });
26
+
27
+ afterEach(() => {
28
+ rmSync(featuresRoot, { recursive: true, force: true });
29
+ rmSync(appRoot, { recursive: true, force: true });
30
+ });
31
+
32
+ describe("syncFeatures", () => {
33
+ it("materialize schema fragment + bước SQL vào app trắng", () => {
34
+ writeFeature(
35
+ "background-tasks",
36
+ { "0001_init.sql": "CREATE TABLE IF NOT EXISTS t(id text);" },
37
+ "model BackgroundTask { id String @id }"
38
+ );
39
+
40
+ const actions = syncFeatures({ featuresRoot, appRoot, now: new Date("2026-07-31T00:00:00Z") })
41
+ expect(actions.map((a: { type: string }) => a.type)).toEqual(["schema", "migration"])
42
+
43
+ // Schema fragment có header GENERATED
44
+ const schema = readFileSync(
45
+ join(appRoot, "prisma", "schema", "goerp-background-tasks.prisma"),
46
+ "utf8"
47
+ )
48
+ expect(schema).toContain("GENERATED từ @goerp/core")
49
+ expect(schema).toContain("model BackgroundTask")
50
+
51
+ // Migration folder đúng tên <ts>_goerp_<feature>_<step>
52
+ const dirs = readdirSync(join(appRoot, "prisma", "migrations"))
53
+ expect(dirs).toEqual(["20260731000000_goerp_background_tasks_0001_init"])
54
+ expect(
55
+ readFileSync(
56
+ join(appRoot, "prisma", "migrations", dirs[0], "migration.sql"),
57
+ "utf8"
58
+ )
59
+ ).toContain("CREATE TABLE IF NOT EXISTS")
60
+ })
61
+
62
+ it("idempotent: sync lần 2 không tạo gì thêm (bất kể timestamp khác)", () => {
63
+ writeFeature("background-tasks", { "0001_init.sql": "SELECT 1;" })
64
+ syncFeatures({ featuresRoot, appRoot, now: new Date("2026-07-31T00:00:00Z") })
65
+ const again = syncFeatures({ featuresRoot, appRoot, now: new Date("2026-08-01T09:09:09Z") })
66
+ expect(again).toEqual([])
67
+ expect(readdirSync(join(appRoot, "prisma", "migrations"))).toHaveLength(1)
68
+ })
69
+
70
+ it("nâng cấp core thêm bước mới → chỉ materialize bước thiếu, giữ thứ tự", () => {
71
+ writeFeature("background-tasks", { "0001_init.sql": "SELECT 1;" })
72
+ syncFeatures({ featuresRoot, appRoot, now: new Date("2026-07-31T00:00:00Z") })
73
+
74
+ writeFileSync(
75
+ join(featuresRoot, "background-tasks", "migrations", "0002_add-col.sql"),
76
+ "ALTER TABLE t ADD COLUMN IF NOT EXISTS x text;"
77
+ )
78
+ const actions = syncFeatures({ featuresRoot, appRoot, now: new Date("2026-08-02T00:00:00Z") })
79
+ expect(actions).toHaveLength(1)
80
+ expect(actions[0].step).toBe("0002_add-col")
81
+
82
+ const dirs = readdirSync(join(appRoot, "prisma", "migrations")).sort()
83
+ expect(dirs[0]).toContain("0001_init")
84
+ expect(dirs[1]).toContain("0002_add_col")
85
+ })
86
+
87
+ it("nhiều bước trong 1 lần sync giữ thứ tự qua timestamp tăng dần", () => {
88
+ writeFeature("f-a", { "0001_one.sql": "SELECT 1;", "0002_two.sql": "SELECT 2;" })
89
+ syncFeatures({ featuresRoot, appRoot, now: new Date("2026-07-31T00:00:00Z") })
90
+ const dirs = readdirSync(join(appRoot, "prisma", "migrations")).sort()
91
+ expect(dirs[0]).toContain("0001_one")
92
+ expect(dirs[1]).toContain("0002_two")
93
+ expect(dirs[0] < dirs[1]).toBe(true)
94
+ })
95
+
96
+ it("--only giới hạn feature; schema đổi nội dung thì được ghi đè", () => {
97
+ writeFeature("f-a", { "0001_a.sql": "SELECT 1;" }, "model A { id String @id }")
98
+ writeFeature("f-b", { "0001_b.sql": "SELECT 1;" })
99
+ const actions = syncFeatures({ featuresRoot, appRoot, only: ["f-a"] })
100
+ expect(actions.every((a: { feature: string }) => a.feature === "f-a")).toBe(true)
101
+
102
+ // core bump: schema fragment đổi → sync ghi đè bản GENERATED
103
+ writeFileSync(
104
+ join(featuresRoot, "f-a", "schema.prisma"),
105
+ "model A { id String @id\n extra String? }"
106
+ )
107
+ const again = syncFeatures({ featuresRoot, appRoot, only: ["f-a"] })
108
+ expect(again.some((a: { type: string }) => a.type === "schema")).toBe(true)
109
+ expect(
110
+ readFileSync(join(appRoot, "prisma", "schema", "goerp-f-a.prisma"), "utf8")
111
+ ).toContain("extra String?")
112
+ })
113
+ })
114
+
115
+ describe("featureStatus", () => {
116
+ it("báo pending trước sync, đủ sau sync", () => {
117
+ writeFeature("f-a", { "0001_a.sql": "SELECT 1;" }, "model A { id String @id }")
118
+ let status = featureStatus({ featuresRoot, appRoot })
119
+ expect(status[0].steps[0].materialized).toBe(false)
120
+ expect(status[0].schemaSynced).toBe(false)
121
+
122
+ syncFeatures({ featuresRoot, appRoot })
123
+ status = featureStatus({ featuresRoot, appRoot })
124
+ expect(status[0].steps[0].materialized).toBe(true)
125
+ expect(status[0].schemaSynced).toBe(true)
126
+ })
127
+ })
128
+
129
+ describe("migrationTimestamp", () => {
130
+ it("dạng YYYYMMDDHHMMSS UTC + offset giây", () => {
131
+ const now = new Date("2026-07-31T01:02:03Z")
132
+ expect(migrationTimestamp(now)).toBe("20260731010203")
133
+ expect(migrationTimestamp(now, 2)).toBe("20260731010205")
134
+ })
135
+ })
@@ -0,0 +1,213 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ configureTaskRunner,
5
+ enqueueTask,
6
+ readTaskFile,
7
+ reclaimStaleTasks,
8
+ registerTaskHandler,
9
+ saveTaskFile,
10
+ type TaskDb,
11
+ type TaskNotifyInput,
12
+ } from "../task-runner";
13
+
14
+ function makeDb(overrides: Partial<Record<string, unknown>> = {}) {
15
+ const rows = new Map<string, Record<string, unknown>>();
16
+ let seq = 0;
17
+ const db = {
18
+ backgroundTask: {
19
+ create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
20
+ const id = `t${++seq}`;
21
+ const row = { id, status: "pending", ...data };
22
+ rows.set(id, row);
23
+ return row;
24
+ }),
25
+ update: vi.fn(
26
+ async ({
27
+ where,
28
+ data,
29
+ }: {
30
+ where: { id: string };
31
+ data: Record<string, unknown>;
32
+ }) => {
33
+ const row = rows.get(where.id);
34
+ if (row) Object.assign(row, data);
35
+ return row;
36
+ },
37
+ ),
38
+ updateMany: vi.fn(
39
+ async ({
40
+ where,
41
+ data,
42
+ }: {
43
+ where: Record<string, unknown>;
44
+ data: Record<string, unknown>;
45
+ }) => {
46
+ let count = 0;
47
+ for (const row of rows.values()) {
48
+ const statusCond = where.status as
49
+ | string
50
+ | { in: string[] }
51
+ | undefined;
52
+ const statusOk =
53
+ statusCond === undefined ||
54
+ (typeof statusCond === "string"
55
+ ? row.status === statusCond
56
+ : (statusCond.in as string[]).includes(row.status as string));
57
+ const idOk = where.id === undefined || row.id === where.id;
58
+ if (statusOk && idOk) {
59
+ Object.assign(row, data);
60
+ count++;
61
+ }
62
+ }
63
+ return { count };
64
+ },
65
+ ),
66
+ findUnique: vi.fn(async ({ where }: { where: { id: string } }) => {
67
+ return rows.get(where.id) ?? null;
68
+ }),
69
+ ...overrides,
70
+ },
71
+ __rows: rows,
72
+ };
73
+ return db as unknown as TaskDb & { __rows: Map<string, Record<string, unknown>> };
74
+ }
75
+
76
+ const flush = () => new Promise((r) => setTimeout(r, 10));
77
+
78
+ describe("task-runner", () => {
79
+ let notifications: TaskNotifyInput[];
80
+
81
+ beforeEach(() => {
82
+ notifications = [];
83
+ });
84
+
85
+ it("chưa configure → enqueue throw với hướng dẫn", async () => {
86
+ // Reset config bằng cách configure với db hợp lệ SAU test này — ở đây
87
+ // module có thể đã được configure bởi test khác nên chỉ chạy khi chưa có.
88
+ // (Thứ tự an toàn: test này đứng đầu file, config module-level còn null.)
89
+ await expect(
90
+ enqueueTask({ type: "x", title: "x", createdBy: "u1" }),
91
+ ).rejects.toThrow(/configureTaskRunner/);
92
+ });
93
+
94
+ it("enqueue → handler chạy, success + progress 100 + notify có link tải", async () => {
95
+ const db = makeDb();
96
+ configureTaskRunner({
97
+ db,
98
+ notify: async (n) => {
99
+ notifications.push(n);
100
+ },
101
+ });
102
+ registerTaskHandler("demo:ok", async ({ setProgress }) => {
103
+ await setProgress(50);
104
+ return { fileKey: "local:a.xlsx", fileName: "a.xlsx", rowCount: 3 };
105
+ });
106
+
107
+ const task = await enqueueTask({
108
+ type: "demo:ok",
109
+ title: "Xuất demo",
110
+ createdBy: "u1",
111
+ });
112
+ await flush();
113
+
114
+ const row = db.__rows.get(task.id)!;
115
+ expect(row.status).toBe("success");
116
+ expect(row.progress).toBe(100);
117
+ expect(notifications).toHaveLength(1);
118
+ expect(notifications[0].type).toBe("success");
119
+ expect(notifications[0].url).toBe(`/api/tasks/${task.id}/download`);
120
+ });
121
+
122
+ it("handler throw → status error + notify thất bại", async () => {
123
+ const db = makeDb();
124
+ configureTaskRunner({
125
+ db,
126
+ notify: async (n) => {
127
+ notifications.push(n);
128
+ },
129
+ });
130
+ registerTaskHandler("demo:boom", async () => {
131
+ throw new Error("nổ có chủ đích");
132
+ });
133
+
134
+ const task = await enqueueTask({
135
+ type: "demo:boom",
136
+ title: "Nổ",
137
+ createdBy: "u1",
138
+ });
139
+ await flush();
140
+
141
+ const row = db.__rows.get(task.id)!;
142
+ expect(row.status).toBe("error");
143
+ expect(row.error).toBe("nổ có chủ đích");
144
+ expect(notifications[0].type).toBe("error");
145
+ });
146
+
147
+ it("kết quả chỉ có errorFileKey (import lỗi) → notify warning + link ?file=error", async () => {
148
+ const db = makeDb();
149
+ configureTaskRunner({
150
+ db,
151
+ notify: async (n) => {
152
+ notifications.push(n);
153
+ },
154
+ });
155
+ registerTaskHandler("demo:import-err", async () => ({
156
+ errorFileKey: "local:loi.xlsx",
157
+ summary: "3 dòng lỗi",
158
+ }));
159
+
160
+ const task = await enqueueTask({
161
+ type: "demo:import-err",
162
+ title: "Nhập demo",
163
+ createdBy: "u1",
164
+ });
165
+ await flush();
166
+
167
+ expect(notifications[0].type).toBe("warning");
168
+ expect(notifications[0].url).toBe(
169
+ `/api/tasks/${task.id}/download?file=error`,
170
+ );
171
+ });
172
+
173
+ it("type chưa đăng ký → enqueue throw ngay (không tạo row)", async () => {
174
+ const db = makeDb();
175
+ configureTaskRunner({ db });
176
+ await expect(
177
+ enqueueTask({ type: "demo:missing", title: "x", createdBy: "u1" }),
178
+ ).rejects.toThrow(/chưa đăng ký/);
179
+ expect(db.__rows.size).toBe(0);
180
+ });
181
+
182
+ it("reclaimStaleTasks: pending/running mồ côi → error", async () => {
183
+ const db = makeDb();
184
+ configureTaskRunner({ db });
185
+ db.__rows.set("stale1", { id: "stale1", status: "running" });
186
+ db.__rows.set("stale2", { id: "stale2", status: "pending" });
187
+ db.__rows.set("done", { id: "done", status: "success" });
188
+
189
+ await reclaimStaleTasks();
190
+
191
+ expect(db.__rows.get("stale1")!.status).toBe("error");
192
+ expect(db.__rows.get("stale2")!.status).toBe("error");
193
+ expect(db.__rows.get("done")!.status).toBe("success");
194
+ });
195
+
196
+ it("storage seam: có storage thì save/read đi qua storage", async () => {
197
+ const store = new Map<string, Buffer>();
198
+ configureTaskRunner({
199
+ db: makeDb(),
200
+ storage: {
201
+ save: async (buf, key) => {
202
+ store.set(`s3:${key}`, buf);
203
+ return `s3:${key}`;
204
+ },
205
+ read: async (key) => store.get(key)!,
206
+ },
207
+ });
208
+
209
+ const key = await saveTaskFile(Buffer.from("abc"), "x/y.xlsx", "app/x");
210
+ expect(key).toBe("s3:x/y.xlsx");
211
+ expect((await readTaskFile(key)).toString()).toBe("abc");
212
+ });
213
+ });
@@ -0,0 +1,19 @@
1
+ // Trung tâm tác vụ nền — engine dùng chung. Bảng đi kèm ship qua
2
+ // `goerp-features sync` (feature background-tasks). App wiring mẫu: vinhhoa
3
+ // src/server/tasks (configureTaskRunner + handlers + routes /api/tasks).
4
+ export {
5
+ configureTaskRunner,
6
+ enqueueTask,
7
+ readTaskFile,
8
+ reclaimStaleTasks,
9
+ registerTaskHandler,
10
+ saveTaskFile,
11
+ type EnqueueInput,
12
+ type TaskContext,
13
+ type TaskDb,
14
+ type TaskFileResult,
15
+ type TaskHandler,
16
+ type TaskNotifyInput,
17
+ type TaskRecord,
18
+ type TaskStorage,
19
+ } from "./task-runner";
@@ -0,0 +1,307 @@
1
+ import { mkdir, readFile, writeFile } from "fs/promises";
2
+ import { dirname, join } from "path";
3
+
4
+ /**
5
+ * Trung tâm tác vụ nền — engine dùng chung cho mọi app goerp (bảng
6
+ * `background_tasks` ship qua `goerp-features sync`, feature background-tasks).
7
+ *
8
+ * Hàng đợi trong DB, worker IN-PROCESS: enqueue xong là `void runTask(id)`
9
+ * ngay trong container đang phục vụ request (deploy 1 container — không cần
10
+ * queue ngoài). Vòng đời: pending → running (CAS updateMany, chống chạy đôi)
11
+ * → success | error. Server restart giữa chừng → `reclaimStaleTasks()` (gọi
12
+ * từ instrumentation) đánh dấu error để user chạy lại.
13
+ *
14
+ * App cắm phụ thuộc qua `configureTaskRunner` (cùng khuôn
15
+ * `configureSettingsService`): db (Prisma client có model BackgroundTask),
16
+ * notify (chuông/push — tuỳ chọn), storage (S3/MinIO — tuỳ chọn, mặc định
17
+ * thư mục local PRIVATE, KHÔNG để trong public/).
18
+ */
19
+
20
+ export interface TaskFileResult {
21
+ fileKey?: string;
22
+ fileName?: string;
23
+ contentType?: string;
24
+ rowCount?: number;
25
+ errorFileKey?: string;
26
+ errorFileName?: string;
27
+ summary?: string;
28
+ }
29
+
30
+ export interface TaskContext {
31
+ taskId: string;
32
+ params: unknown;
33
+ /** Cập nhật % tiến độ (0–100) — throttle sẵn, gọi thoải mái theo chunk. */
34
+ setProgress: (percent: number) => Promise<void>;
35
+ }
36
+
37
+ export type TaskHandler = (ctx: TaskContext) => Promise<TaskFileResult>;
38
+
39
+ export interface TaskRecord {
40
+ id: string;
41
+ type: string;
42
+ title: string;
43
+ status: string;
44
+ params: unknown;
45
+ createdBy: string;
46
+ }
47
+
48
+ /**
49
+ * Delegate Prisma tối thiểu — structural, args nới thành `any` CÓ CHỦ ĐÍCH:
50
+ * client Prisma sinh ra của app có kiểu args HẸP hơn (contravariance) nên
51
+ * khai chặt ở đây sẽ không assignable (cùng bài học SettingsDb).
52
+ */
53
+ export interface TaskDb {
54
+ backgroundTask: {
55
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
+ create(args: any): Promise<TaskRecord>;
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ update(args: any): Promise<unknown>;
59
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
+ updateMany(args: any): Promise<{ count: number }>;
61
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
62
+ findUnique(args: any): Promise<TaskRecord | null>;
63
+ };
64
+ }
65
+
66
+ export interface TaskNotifyInput {
67
+ userIds: string[];
68
+ type: "success" | "error" | "warning" | "info";
69
+ category: string;
70
+ title: string;
71
+ content: string;
72
+ url?: string;
73
+ resourceType?: string;
74
+ resourceId?: string;
75
+ }
76
+
77
+ export interface TaskStorage {
78
+ /** Trả key đã lưu, hoặc `null` = "để core fallback local" (vd S3 đang tắt). */
79
+ save(buffer: Buffer, key: string, contentType: string): Promise<string | null>;
80
+ /** Chỉ được gọi cho key KHÔNG phải `local:` — key local core tự đọc. */
81
+ read(fileKey: string): Promise<Buffer>;
82
+ }
83
+
84
+ interface TaskRunnerConfig {
85
+ db: TaskDb;
86
+ /** Báo chuông/push khi task xong-lỗi — bỏ trống thì im lặng. */
87
+ notify?: (input: TaskNotifyInput) => Promise<unknown>;
88
+ /** Kho file kết quả — bỏ trống dùng thư mục local `localDir`. */
89
+ storage?: TaskStorage;
90
+ /** Thư mục fallback local (mặc định <cwd>/storage/task-files). */
91
+ localDir?: string;
92
+ }
93
+
94
+ let config: TaskRunnerConfig | null = null;
95
+
96
+ export function configureTaskRunner(next: TaskRunnerConfig): void {
97
+ config = next;
98
+ }
99
+
100
+ function requireConfig(): TaskRunnerConfig {
101
+ if (!config) {
102
+ throw new Error(
103
+ "[tasks] chưa configureTaskRunner({ db, notify?, storage? }) — gọi 1 lần lúc khởi tạo app (cạnh configureSettingsService).",
104
+ );
105
+ }
106
+ return config;
107
+ }
108
+
109
+ const registry = new Map<string, TaskHandler>();
110
+
111
+ export function registerTaskHandler(type: string, handler: TaskHandler): void {
112
+ registry.set(type, handler);
113
+ }
114
+
115
+ export interface EnqueueInput {
116
+ type: string;
117
+ title: string;
118
+ params?: unknown;
119
+ createdBy: string;
120
+ branchId?: string | null;
121
+ }
122
+
123
+ /**
124
+ * Tạo task + chạy ngay trong process (fire-and-forget). AUTHORIZE TRƯỚC KHI
125
+ * GỌI: worker chạy ngoài request nên không còn session — mọi giới hạn quyền
126
+ * (scope chi nhánh, quyền xem giá vốn…) phải được snapshot vào `params`.
127
+ */
128
+ export async function enqueueTask(input: EnqueueInput): Promise<TaskRecord> {
129
+ const { db } = requireConfig();
130
+ if (!registry.has(input.type)) {
131
+ throw new Error(`[tasks] type "${input.type}" chưa đăng ký handler`);
132
+ }
133
+ const task = await db.backgroundTask.create({
134
+ data: {
135
+ type: input.type,
136
+ title: input.title,
137
+ params: input.params ?? undefined,
138
+ createdBy: input.createdBy,
139
+ branchId: input.branchId ?? null,
140
+ },
141
+ });
142
+ void runTask(task.id);
143
+ return task;
144
+ }
145
+
146
+ async function runTask(id: string): Promise<void> {
147
+ const { db } = requireConfig();
148
+ // CAS pending→running: chỉ một luồng thắng (chống double-run khi retry).
149
+ const claimed = await db.backgroundTask.updateMany({
150
+ where: { id, status: "pending" },
151
+ data: { status: "running", startedAt: new Date() },
152
+ });
153
+ if (claimed.count === 0) return;
154
+
155
+ const task = await db.backgroundTask.findUnique({ where: { id } });
156
+ if (!task) return;
157
+ const handler = registry.get(task.type);
158
+
159
+ try {
160
+ if (!handler) {
161
+ throw new Error(`[tasks] type "${task.type}" chưa đăng ký handler`);
162
+ }
163
+
164
+ let lastWrite = 0;
165
+ const setProgress = async (percent: number) => {
166
+ const now = Date.now();
167
+ if (now - lastWrite < 500) return; // throttle ghi DB
168
+ lastWrite = now;
169
+ await db.backgroundTask.update({
170
+ where: { id },
171
+ data: { progress: Math.max(0, Math.min(99, Math.round(percent))) },
172
+ });
173
+ };
174
+
175
+ const result = await handler({
176
+ taskId: id,
177
+ params: task.params,
178
+ setProgress,
179
+ });
180
+
181
+ await db.backgroundTask.update({
182
+ where: { id },
183
+ data: {
184
+ status: "success",
185
+ progress: 100,
186
+ result: result as unknown as Record<string, unknown>,
187
+ finishedAt: new Date(),
188
+ },
189
+ });
190
+ await notifyTaskDone(task.createdBy, id, task.title, result);
191
+ } catch (error) {
192
+ const message = error instanceof Error ? error.message : String(error);
193
+ console.error(`[tasks] ${task.type} (${id}) lỗi:`, error);
194
+ await db.backgroundTask
195
+ .update({
196
+ where: { id },
197
+ data: {
198
+ status: "error",
199
+ error: message || "Tác vụ thất bại",
200
+ finishedAt: new Date(),
201
+ },
202
+ })
203
+ .catch(() => {});
204
+ await notifyTaskFailed(task.createdBy, task.title, message);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Gọi 1 lần khi server boot (instrumentation): task còn "running"/"pending"
210
+ * là mồ côi của process trước (restart giữa chừng) → error để user chạy lại.
211
+ */
212
+ export async function reclaimStaleTasks(): Promise<void> {
213
+ const { db } = requireConfig();
214
+ const { count } = await db.backgroundTask.updateMany({
215
+ where: { status: { in: ["pending", "running"] } },
216
+ data: {
217
+ status: "error",
218
+ error: "Server khởi động lại giữa chừng — vui lòng chạy lại tác vụ.",
219
+ finishedAt: new Date(),
220
+ },
221
+ });
222
+ if (count > 0) {
223
+ console.warn(`[tasks] reclaim ${count} task mồ côi sau restart`);
224
+ }
225
+ }
226
+
227
+ // ─── Thông báo khi xong (qua seam notify — app cắm chuông/push) ───
228
+
229
+ async function notifyTaskDone(
230
+ userId: string,
231
+ taskId: string,
232
+ title: string,
233
+ result: TaskFileResult,
234
+ ): Promise<void> {
235
+ const { notify } = requireConfig();
236
+ if (!notify) return;
237
+ // Task chạy XONG nhưng nghiệp vụ fail từng dòng (import) → warning + link
238
+ // tải file lỗi để sửa-rồi-nạp-lại.
239
+ const hasErrorFile = !result.fileKey && !!result.errorFileKey;
240
+ await notify({
241
+ userIds: [userId],
242
+ type: hasErrorFile ? "warning" : "success",
243
+ category: "task",
244
+ title: hasErrorFile ? `Cần sửa file: ${title}` : `Hoàn tất: ${title}`,
245
+ content: result.fileKey
246
+ ? `${result.summary ?? `${result.rowCount ?? ""} dòng`} — bấm để tải file.`.trim()
247
+ : hasErrorFile
248
+ ? `${result.summary ?? "Có dòng lỗi."} Bấm để tải file lỗi từng dòng.`
249
+ : (result.summary ?? "Tác vụ đã chạy xong."),
250
+ url: result.fileKey
251
+ ? `/api/tasks/${taskId}/download`
252
+ : hasErrorFile
253
+ ? `/api/tasks/${taskId}/download?file=error`
254
+ : undefined,
255
+ resourceType: "background_task",
256
+ resourceId: taskId,
257
+ });
258
+ }
259
+
260
+ async function notifyTaskFailed(
261
+ userId: string,
262
+ title: string,
263
+ message?: string,
264
+ ): Promise<void> {
265
+ const { notify } = requireConfig();
266
+ if (!notify) return;
267
+ await notify({
268
+ userIds: [userId],
269
+ type: "error",
270
+ category: "task",
271
+ title: `Thất bại: ${title}`,
272
+ content: message || "Tác vụ nền gặp lỗi — thử chạy lại.",
273
+ });
274
+ }
275
+
276
+ // ─── Lưu/đọc file kết quả (storage seam; fallback thư mục PRIVATE local) ───
277
+
278
+ function localDir(): string {
279
+ return requireConfig().localDir ?? join(process.cwd(), "storage", "task-files");
280
+ }
281
+
282
+ export async function saveTaskFile(
283
+ buffer: Buffer,
284
+ key: string,
285
+ contentType: string,
286
+ ): Promise<string> {
287
+ const { storage } = requireConfig();
288
+ if (storage) {
289
+ const saved = await storage.save(buffer, key, contentType);
290
+ if (saved) return saved;
291
+ }
292
+ const filepath = join(localDir(), key);
293
+ await mkdir(dirname(filepath), { recursive: true });
294
+ await writeFile(filepath, buffer);
295
+ return `local:${key}`;
296
+ }
297
+
298
+ export async function readTaskFile(fileKey: string): Promise<Buffer> {
299
+ const { storage } = requireConfig();
300
+ if (fileKey.startsWith("local:")) {
301
+ return readFile(join(localDir(), fileKey.slice("local:".length)));
302
+ }
303
+ if (!storage) {
304
+ throw new Error(`[tasks] fileKey "${fileKey}" cần storage seam (S3/MinIO) nhưng chưa cấu hình.`);
305
+ }
306
+ return storage.read(fileKey);
307
+ }
@@ -0,0 +1,128 @@
1
+ "use client";
2
+
3
+ import { format } from "date-fns";
4
+ import { Download, FileWarning, ListChecks } from "lucide-react";
5
+ import useSWR from "swr";
6
+
7
+ import { getStatusMeta } from "../../ui/shared/status-indicator";
8
+
9
+ /**
10
+ * Trang "Tác vụ nền" của CHÍNH user — dùng chung mọi app goerp: list 50 task,
11
+ * poll 3s khi còn task chạy, progress bar, nút tải file kết quả / file lỗi.
12
+ * App chỉ cần page server-shell (gate session) render component này; API
13
+ * chuẩn: GET /api/tasks + GET /api/tasks/[id]/download (xem app mẫu vinhhoa).
14
+ */
15
+
16
+ interface TaskRow {
17
+ id: string;
18
+ type: string;
19
+ title: string;
20
+ status: "pending" | "running" | "success" | "error";
21
+ progress: number;
22
+ result?: {
23
+ fileKey?: string;
24
+ fileName?: string;
25
+ errorFileKey?: string;
26
+ errorFileName?: string;
27
+ summary?: string;
28
+ } | null;
29
+ error?: string | null;
30
+ createdAt: string;
31
+ finishedAt?: string | null;
32
+ }
33
+
34
+ const fetcher = (url: string) => fetch(url).then((r) => r.json());
35
+
36
+ const STATUS_LABEL: Record<TaskRow["status"], string> = {
37
+ pending: "Chờ chạy",
38
+ running: "Đang chạy",
39
+ success: "Hoàn tất",
40
+ error: "Lỗi",
41
+ };
42
+
43
+ export function TaskListClient({ apiUrl = "/api/tasks" }: { apiUrl?: string }) {
44
+ const { data } = useSWR<{ tasks: TaskRow[] }>(`${apiUrl}?take=50`, fetcher, {
45
+ // Còn task đang chạy → poll nhanh để progress nhảy; yên ắng thì thôi.
46
+ refreshInterval: (latest) =>
47
+ latest?.tasks?.some(
48
+ (t) => t.status === "running" || t.status === "pending",
49
+ )
50
+ ? 3000
51
+ : 0,
52
+ });
53
+ const tasks = data?.tasks ?? [];
54
+
55
+ return (
56
+ <div className="mx-auto max-w-3xl space-y-3 p-4">
57
+ <div className="flex items-center gap-2">
58
+ <ListChecks className="h-5 w-5 text-muted-foreground" />
59
+ <h1 className="text-base font-bold text-foreground">Tác vụ nền</h1>
60
+ <span className="text-xs text-muted-foreground">
61
+ export/import lớn chạy nền — xong sẽ báo qua chuông thông báo
62
+ </span>
63
+ </div>
64
+
65
+ {tasks.length === 0 ? (
66
+ <p className="rounded-xl border border-border bg-card px-4 py-10 text-center text-sm text-muted-foreground">
67
+ Chưa có tác vụ nào. Xuất/Nhập Excel dữ liệu lớn sẽ hiện ở đây.
68
+ </p>
69
+ ) : (
70
+ <div className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card">
71
+ {tasks.map((task) => {
72
+ const meta = getStatusMeta(
73
+ task.status === "success"
74
+ ? "completed"
75
+ : task.status === "error"
76
+ ? "cancelled"
77
+ : "pending",
78
+ STATUS_LABEL[task.status],
79
+ );
80
+ return (
81
+ <div key={task.id} className="flex items-center gap-3 px-4 py-3">
82
+ <span
83
+ className={`h-2 w-2 shrink-0 rounded-full ${meta.dotClass}`}
84
+ />
85
+ <div className="min-w-0 flex-1">
86
+ <p className="truncate text-sm font-medium text-foreground">
87
+ {task.title}
88
+ </p>
89
+ <p className="mt-0.5 truncate text-xs text-muted-foreground">
90
+ {format(new Date(task.createdAt), "dd/MM HH:mm")} ·{" "}
91
+ {task.status === "error"
92
+ ? task.error || "Tác vụ thất bại"
93
+ : (task.result?.summary ?? meta.label)}
94
+ </p>
95
+ {(task.status === "running" ||
96
+ task.status === "pending") && (
97
+ <div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-muted">
98
+ <div
99
+ className="h-full rounded-full bg-primary transition-all"
100
+ style={{ width: `${Math.max(task.progress, 4)}%` }}
101
+ />
102
+ </div>
103
+ )}
104
+ </div>
105
+ {task.status === "success" && task.result?.fileKey && (
106
+ <a
107
+ href={`${apiUrl}/${task.id}/download`}
108
+ className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs font-medium text-foreground hover:bg-muted"
109
+ >
110
+ <Download className="h-3.5 w-3.5" /> Tải file
111
+ </a>
112
+ )}
113
+ {task.status === "success" && task.result?.errorFileKey && (
114
+ <a
115
+ href={`${apiUrl}/${task.id}/download?file=error`}
116
+ className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-amber-200 bg-amber-50 px-2.5 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-100 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-300"
117
+ >
118
+ <FileWarning className="h-3.5 w-3.5" /> File lỗi
119
+ </a>
120
+ )}
121
+ </div>
122
+ );
123
+ })}
124
+ </div>
125
+ )}
126
+ </div>
127
+ );
128
+ }