@goplusvn/core 0.1.71 → 0.1.72
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/package.json +2 -1
- package/src/auth/index.ts +5 -1
- package/src/configs/entities/departments.config.ts +1 -0
- package/src/configs/entities/material-categories.config.ts +1 -0
- package/src/crud/components/crud-export-button.tsx +3 -2
- package/src/crud/components/crud-page.tsx +56 -25
- package/src/crud/components/crud-row-actions.tsx +16 -4
- package/src/crud/crud-route-handlers.test.ts +352 -0
- package/src/crud/crud-route-handlers.ts +260 -13
- package/src/crud/index.ts +3 -0
- package/src/crud/lib/coerce.test.ts +118 -0
- package/src/crud/lib/coerce.ts +95 -0
- package/src/crud/lib/crud-utils.ts +4 -0
- package/src/crud/lib/entity-endpoints.test.ts +41 -0
- package/src/crud/lib/entity-endpoints.ts +23 -0
- package/src/crud/lib/errors.ts +14 -0
- package/src/crud/lib/import-request.ts +99 -0
- package/src/crud/lib/mutation-builder.test.ts +114 -0
- package/src/crud/lib/mutation-builder.ts +27 -34
- package/src/crud/lib/permissions.test.ts +57 -0
- package/src/crud/lib/permissions.ts +25 -13
- package/src/crud/lib/query-builder.ts +13 -6
- package/src/crud/pages/entity-crud-page.tsx +11 -8
- package/src/crud/server-service.test.ts +112 -0
- package/src/crud/server-service.ts +52 -27
- package/src/crud/server.ts +11 -1
- package/src/guardrails/rules/design.ts +3 -2
- package/src/types/index.ts +75 -10
- package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
- package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
- package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
- package/src/ui/data-display/data-table/data-table.tsx +68 -23
- package/src/ui/data-display/data-table-pagination.tsx +25 -8
- package/src/ui/data-display/index.tsx +1 -0
- package/src/ui/data-display/use-client-pagination.ts +48 -0
- package/templates/starter-app/AGENTS.md +1 -1
- package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
- package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
- package/templates/starter-app/scripts/rbac-sync.ts +49 -21
- package/templates/starter-app/src/__tests__/architecture.test.ts +3 -2
- package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +4 -4
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +1 -2
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +7 -5
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +20 -9
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +4 -4
- package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +5 -5
- package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
- package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +3 -1
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +13 -5
- package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +3 -2
- package/templates/starter-app/src/app/[lang]/layout.tsx +2 -2
- package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
- package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
- package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
- package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
- package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
- package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
- package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
- package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
- package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
- package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
- package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
- package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
- package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
- package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
- package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
- package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
- package/templates/starter-app/src/app/api/job-titles/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
- package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
- package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
- package/templates/starter-app/src/app/api/roles/route.ts +2 -1
- package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +5 -5
- package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +1 -1
- package/templates/starter-app/src/app/api/security/sessions/route.ts +2 -2
- package/templates/starter-app/src/app/api/system-categories/route.ts +39 -14
- package/templates/starter-app/src/app/api/system-category-groups/route.ts +32 -14
- package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
- package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
- package/templates/starter-app/src/app/api/upload/route.ts +1 -2
- package/templates/starter-app/src/app/api/users/[id]/route.ts +20 -12
- package/templates/starter-app/src/app/api/users/route.ts +11 -9
- package/templates/starter-app/src/app/globals.css +0 -1
- package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
- package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
- package/templates/starter-app/src/configs/entities/index.ts +1 -1
- package/templates/starter-app/src/configs/entities/job-titles.config.ts +2 -0
- package/templates/starter-app/src/configs/entities/system-alerts.config.ts +2 -0
- package/templates/starter-app/src/configs/entities/users.config.ts +15 -2
- package/templates/starter-app/src/configs/i18n.ts +6 -6
- package/templates/starter-app/src/configs/permissions/menu-tree.ts +1 -1
- package/templates/starter-app/src/data/dictionary.ts +2 -1
- package/templates/starter-app/src/data/navigations.ts +12 -2
- package/templates/starter-app/src/lib/action-guard.ts +3 -4
- package/templates/starter-app/src/lib/api-handler.ts +27 -19
- package/templates/starter-app/src/lib/auth.ts +3 -1
- package/templates/starter-app/src/lib/branch-scope.ts +1 -1
- package/templates/starter-app/src/lib/crud/index.ts +5 -12
- package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
- package/templates/starter-app/src/lib/logger.ts +11 -4
- package/templates/starter-app/src/lib/page-guard.ts +2 -3
- package/templates/starter-app/src/lib/prisma.ts +13 -5
- package/templates/starter-app/src/lib/rbac/access.ts +2 -2
- package/templates/starter-app/src/lib/storage.ts +3 -1
- package/templates/starter-app/src/providers/index.tsx +2 -1
- package/templates/starter-app/src/providers/theme-provider.tsx +4 -3
- package/templates/starter-app/src/server/services/company-service.ts +4 -2
- package/templates/starter-app/src/server/services/notification-service.ts +6 -6
- package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
- package/templates/starter-app/src/server/services/user-service.ts +2 -2
- package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
- package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
- package/templates/starter-app/tsconfig.json +8 -0
- package/templates/starter-app/vitest.config.ts +17 -1
|
@@ -25,22 +25,34 @@ import {
|
|
|
25
25
|
} from "../primitives/client";
|
|
26
26
|
|
|
27
27
|
interface DataTablePaginationProps<TData> {
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Instance tanstack. BỎ TRỐNG khi dùng thuần state: truyền đủ
|
|
30
|
+
* totalItems/currentPage/pageSize + onPageChange/onPageSizeChange
|
|
31
|
+
* (ghép với hook `useClientPagination`).
|
|
32
|
+
*/
|
|
33
|
+
table?: Table<TData>;
|
|
29
34
|
totalItems?: number;
|
|
35
|
+
/** Trang hiện tại, 1-BASED (KHÁC pageIndex 0-based của tanstack). */
|
|
30
36
|
currentPage?: number;
|
|
31
37
|
pageSize?: number;
|
|
38
|
+
/** Nhận trang 1-based. Có mặt thì THẮNG table.setPageIndex. */
|
|
39
|
+
onPageChange?: (page: number) => void;
|
|
40
|
+
/** Có mặt thì THẮNG table.setPageSize. Reset về trang 1 là việc của caller (useClientPagination đã lo). */
|
|
41
|
+
onPageSizeChange?: (pageSize: number) => void;
|
|
32
42
|
}
|
|
33
43
|
|
|
34
|
-
export function DataTablePagination<TData>({
|
|
44
|
+
export function DataTablePagination<TData = unknown>({
|
|
35
45
|
table,
|
|
36
46
|
totalItems,
|
|
37
47
|
currentPage,
|
|
38
48
|
pageSize,
|
|
49
|
+
onPageChange,
|
|
50
|
+
onPageSizeChange,
|
|
39
51
|
}: DataTablePaginationProps<TData>) {
|
|
40
|
-
const state = table
|
|
41
|
-
const effPageSize = pageSize ?? state
|
|
42
|
-
const effCurrentPage = currentPage ?? state.pageIndex + 1;
|
|
43
|
-
const total = totalItems ?? table
|
|
52
|
+
const state = table?.getState().pagination;
|
|
53
|
+
const effPageSize = pageSize ?? state?.pageSize ?? 10;
|
|
54
|
+
const effCurrentPage = currentPage ?? (state ? state.pageIndex + 1 : 1);
|
|
55
|
+
const total = totalItems ?? table?.getFilteredRowModel().rows.length ?? 0;
|
|
44
56
|
const pageCount = Math.max(1, Math.ceil(total / effPageSize));
|
|
45
57
|
const startItem = total > 0 ? (effCurrentPage - 1) * effPageSize + 1 : 0;
|
|
46
58
|
const endItem = total > 0 ? Math.min(effCurrentPage * effPageSize, total) : 0;
|
|
@@ -52,7 +64,8 @@ export function DataTablePagination<TData>({
|
|
|
52
64
|
|
|
53
65
|
const goToPage = (page: number) => {
|
|
54
66
|
if (page < 1 || page > pageCount) return;
|
|
55
|
-
|
|
67
|
+
if (onPageChange) onPageChange(page);
|
|
68
|
+
else table?.setPageIndex(page - 1);
|
|
56
69
|
};
|
|
57
70
|
|
|
58
71
|
const handlePageInputBlur = () => {
|
|
@@ -77,7 +90,11 @@ export function DataTablePagination<TData>({
|
|
|
77
90
|
</p>
|
|
78
91
|
<Select
|
|
79
92
|
value={`${effPageSize}`}
|
|
80
|
-
onValueChange={(value) =>
|
|
93
|
+
onValueChange={(value) => {
|
|
94
|
+
const size = Number(value);
|
|
95
|
+
if (onPageSizeChange) onPageSizeChange(size);
|
|
96
|
+
else table?.setPageSize(size);
|
|
97
|
+
}}
|
|
81
98
|
>
|
|
82
99
|
<SelectTrigger className="!h-6 !min-h-0 py-0 w-[60px] sm:w-[65px] text-[11px] px-1.5 focus:ring-inset focus:ring-offset-0">
|
|
83
100
|
<SelectValue placeholder={effPageSize} />
|
|
@@ -14,6 +14,7 @@ export { DataTableViewOptions } from "./data-table-view-options";
|
|
|
14
14
|
|
|
15
15
|
export * from "./formatted-number-input";
|
|
16
16
|
export * from "./data-table-pagination";
|
|
17
|
+
export * from "./use-client-pagination";
|
|
17
18
|
export * from "./avatar";
|
|
18
19
|
export * from "./kpi-card";
|
|
19
20
|
export * from "./compact-stat-bar";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @goerp/core/ui/data-display
|
|
2
|
+
// Hook phân trang CLIENT-STATE THUẦN cho bảng client-side (dialog/tab không
|
|
3
|
+
// muốn gắn URL). Ghép với <DataTablePagination totalItems currentPage pageSize
|
|
4
|
+
// onPageChange onPageSizeChange> (không cần table tanstack). Trang 1-based.
|
|
5
|
+
// Cần gắn URL thì dùng UrlPagination/ShallowPagination, KHÔNG dùng hook này.
|
|
6
|
+
|
|
7
|
+
"use client";
|
|
8
|
+
|
|
9
|
+
import { useCallback, useMemo, useState } from "react";
|
|
10
|
+
|
|
11
|
+
export interface ClientPagination<T> {
|
|
12
|
+
/** Trang hiện tại, 1-based — đã clamp vào [1, pageCount] khi items co lại. */
|
|
13
|
+
page: number;
|
|
14
|
+
setPage: (page: number) => void;
|
|
15
|
+
pageSize: number;
|
|
16
|
+
/** Đổi cỡ trang thì TỰ về trang 1 (tránh rơi ngoài phạm vi). */
|
|
17
|
+
setPageSize: (pageSize: number) => void;
|
|
18
|
+
/** Lát cắt items của trang hiện tại. */
|
|
19
|
+
pageItems: T[];
|
|
20
|
+
total: number;
|
|
21
|
+
pageCount: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useClientPagination<T>(
|
|
25
|
+
items: T[],
|
|
26
|
+
initialPageSize = 10,
|
|
27
|
+
): ClientPagination<T> {
|
|
28
|
+
const [rawPage, setPage] = useState(1);
|
|
29
|
+
const [pageSize, setPageSizeState] = useState(initialPageSize);
|
|
30
|
+
|
|
31
|
+
const total = items.length;
|
|
32
|
+
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
|
33
|
+
// Clamp thay vì setState trong effect: items co lại (xoá/lọc) không tạo
|
|
34
|
+
// render trung gian với trang rỗng.
|
|
35
|
+
const page = Math.min(Math.max(1, rawPage), pageCount);
|
|
36
|
+
|
|
37
|
+
const pageItems = useMemo(
|
|
38
|
+
() => items.slice((page - 1) * pageSize, page * pageSize),
|
|
39
|
+
[items, page, pageSize],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const setPageSize = useCallback((size: number) => {
|
|
43
|
+
setPageSizeState(size);
|
|
44
|
+
setPage(1);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
return { page, setPage, pageSize, setPageSize, pageItems, total, pageCount };
|
|
48
|
+
}
|
|
@@ -58,7 +58,7 @@ ESLint, nên khi thấy id thì tra thẳng được:
|
|
|
58
58
|
| `layering/*` | ngoài module chỉ import qua public API; `lib` không gọi ngược lên `modules`; domain không dính `next/*` |
|
|
59
59
|
| `one-door/*` | một Prisma client, một cửa kho tập tin / phạm vi chi nhánh; export qua `useExport`; API 500 phải ghi `error_logs` |
|
|
60
60
|
| `structure/*` | đặt tên & vị trí file trong `app/`; `.dockerignore` không nuốt mã nguồn |
|
|
61
|
-
| `design/*` | không `tailwind.config.ts`; token lấy từ core; không `flex {bp}:hidden`; bảng dùng `
|
|
61
|
+
| `design/*` | không `tailwind.config.ts`; token lấy từ core; không `flex {bp}:hidden`; bảng dùng `DataTable` từ `@goerp/core/ui` |
|
|
62
62
|
| `safety/*` | không `FOR UPDATE` thô; không gọi mạng trong `$transaction`; `findMany` trong page phải có `take` |
|
|
63
63
|
| `debt/*` | trần `any` theo thư mục — chỉ được hạ, và **không được dư** |
|
|
64
64
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
-- CreateTable
|
|
2
|
+
CREATE TABLE "job_titles" (
|
|
3
|
+
"id" TEXT NOT NULL,
|
|
4
|
+
"code" TEXT NOT NULL,
|
|
5
|
+
"name" TEXT NOT NULL,
|
|
6
|
+
"description" TEXT,
|
|
7
|
+
"level" INTEGER NOT NULL DEFAULT 0,
|
|
8
|
+
"status" TEXT NOT NULL DEFAULT 'active',
|
|
9
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
10
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
11
|
+
"created_by" TEXT,
|
|
12
|
+
"updated_by" TEXT,
|
|
13
|
+
|
|
14
|
+
CONSTRAINT "job_titles_pkey" PRIMARY KEY ("id")
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
-- CreateTable
|
|
18
|
+
CREATE TABLE "system_categories" (
|
|
19
|
+
"id" TEXT NOT NULL,
|
|
20
|
+
"code" TEXT NOT NULL,
|
|
21
|
+
"name" TEXT NOT NULL,
|
|
22
|
+
"group_code" TEXT NOT NULL,
|
|
23
|
+
"description" TEXT NOT NULL,
|
|
24
|
+
"status" TEXT NOT NULL DEFAULT 'active',
|
|
25
|
+
"order" INTEGER,
|
|
26
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
27
|
+
"created_by" TEXT NOT NULL,
|
|
28
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
29
|
+
"updated_by" TEXT NOT NULL,
|
|
30
|
+
|
|
31
|
+
CONSTRAINT "system_categories_pkey" PRIMARY KEY ("id")
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
-- CreateTable
|
|
35
|
+
CREATE TABLE "system_category_groups" (
|
|
36
|
+
"id" TEXT NOT NULL,
|
|
37
|
+
"code" TEXT NOT NULL,
|
|
38
|
+
"name" TEXT NOT NULL,
|
|
39
|
+
"description" TEXT NOT NULL,
|
|
40
|
+
"status" TEXT NOT NULL DEFAULT 'active',
|
|
41
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
42
|
+
"created_by" TEXT NOT NULL,
|
|
43
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
44
|
+
"updated_by" TEXT NOT NULL,
|
|
45
|
+
|
|
46
|
+
CONSTRAINT "system_category_groups_pkey" PRIMARY KEY ("id")
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
-- CreateTable
|
|
50
|
+
CREATE TABLE "system_alerts" (
|
|
51
|
+
"id" TEXT NOT NULL,
|
|
52
|
+
"title" TEXT NOT NULL,
|
|
53
|
+
"message" TEXT NOT NULL,
|
|
54
|
+
"type" TEXT NOT NULL DEFAULT 'info',
|
|
55
|
+
"status" TEXT NOT NULL DEFAULT 'pending',
|
|
56
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
57
|
+
"created_by" TEXT,
|
|
58
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
59
|
+
"updated_by" TEXT,
|
|
60
|
+
|
|
61
|
+
CONSTRAINT "system_alerts_pkey" PRIMARY KEY ("id")
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
-- CreateIndex
|
|
65
|
+
CREATE UNIQUE INDEX "job_titles_code_key" ON "job_titles"("code");
|
|
66
|
+
|
|
67
|
+
-- CreateIndex
|
|
68
|
+
CREATE INDEX "job_titles_code_idx" ON "job_titles"("code");
|
|
69
|
+
|
|
70
|
+
-- CreateIndex
|
|
71
|
+
CREATE INDEX "job_titles_status_idx" ON "job_titles"("status");
|
|
72
|
+
|
|
73
|
+
-- CreateIndex
|
|
74
|
+
CREATE INDEX "job_titles_level_idx" ON "job_titles"("level");
|
|
75
|
+
|
|
76
|
+
-- CreateIndex
|
|
77
|
+
CREATE UNIQUE INDEX "system_categories_code_key" ON "system_categories"("code");
|
|
78
|
+
|
|
79
|
+
-- CreateIndex
|
|
80
|
+
CREATE UNIQUE INDEX "system_category_groups_code_key" ON "system_category_groups"("code");
|
|
81
|
+
|
|
82
|
+
-- CreateIndex
|
|
83
|
+
CREATE INDEX "system_alerts_status_idx" ON "system_alerts"("status");
|
|
84
|
+
|
|
85
|
+
-- CreateIndex
|
|
86
|
+
CREATE INDEX "system_alerts_type_idx" ON "system_alerts"("type");
|
|
87
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- CreateTable
|
|
2
|
+
CREATE TABLE "companies" (
|
|
3
|
+
"id" TEXT NOT NULL,
|
|
4
|
+
"name" TEXT NOT NULL,
|
|
5
|
+
"tax_code" TEXT NOT NULL,
|
|
6
|
+
"address" TEXT NOT NULL,
|
|
7
|
+
"phone" TEXT NOT NULL,
|
|
8
|
+
"email" TEXT NOT NULL,
|
|
9
|
+
"logo_url" TEXT,
|
|
10
|
+
"system_name" TEXT,
|
|
11
|
+
"status" TEXT NOT NULL DEFAULT 'active',
|
|
12
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
13
|
+
"created_by" TEXT NOT NULL DEFAULT 'system',
|
|
14
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
15
|
+
"updated_by" TEXT NOT NULL DEFAULT 'system',
|
|
16
|
+
|
|
17
|
+
CONSTRAINT "companies_pkey" PRIMARY KEY ("id")
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
-- CreateIndex
|
|
21
|
+
CREATE UNIQUE INDEX "companies_tax_code_key" ON "companies"("tax_code");
|
|
22
|
+
|
|
23
|
+
-- CreateIndex
|
|
24
|
+
CREATE INDEX "companies_status_idx" ON "companies"("status");
|
|
25
|
+
|
|
@@ -27,6 +27,7 @@ import { randomUUID } from "node:crypto"
|
|
|
27
27
|
|
|
28
28
|
import { PrismaPg } from "@prisma/adapter-pg"
|
|
29
29
|
import { PrismaClient } from "@prisma/client"
|
|
30
|
+
|
|
30
31
|
import "dotenv/config"
|
|
31
32
|
|
|
32
33
|
import { permissionRegistry } from "../src/configs/permissions"
|
|
@@ -46,29 +47,39 @@ const STANDARD_ACTIONS: Record<string, string> = {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
const connectionString = process.env.DATABASE_URL
|
|
49
|
-
if (!connectionString)
|
|
50
|
+
if (!connectionString)
|
|
51
|
+
throw new Error("DATABASE_URL chưa được set (xem .env.example)")
|
|
50
52
|
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) })
|
|
51
53
|
|
|
52
54
|
async function main() {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
+
const resourceCount = permissionRegistry.reduce(
|
|
56
|
+
(n, f) => n + f.resources.length,
|
|
57
|
+
0
|
|
58
|
+
)
|
|
55
59
|
console.log(
|
|
56
|
-
`→ rbac-sync: ${permissionRegistry.length} phân hệ, ${resourceCount} resource${DRY_RUN ? " [DRY RUN]" : ""}
|
|
60
|
+
`→ rbac-sync: ${permissionRegistry.length} phân hệ, ${resourceCount} resource${DRY_RUN ? " [DRY RUN]" : ""}`
|
|
57
61
|
)
|
|
58
62
|
|
|
59
63
|
// ── 1. Action: tạo nếu thiếu ───────────────────────────────────────────
|
|
60
|
-
const declaredActions = new Map<
|
|
64
|
+
const declaredActions = new Map<
|
|
65
|
+
string,
|
|
66
|
+
{ name: string; description?: string }
|
|
67
|
+
>()
|
|
61
68
|
for (const feature of permissionRegistry) {
|
|
62
69
|
for (const a of feature.customActions ?? []) {
|
|
63
70
|
declaredActions.set(a.code, { name: a.name, description: a.description })
|
|
64
71
|
}
|
|
65
72
|
}
|
|
66
73
|
const usedActionCodes = new Set(
|
|
67
|
-
permissionRegistry.flatMap((f) =>
|
|
74
|
+
permissionRegistry.flatMap((f) =>
|
|
75
|
+
f.resources.flatMap((r) => [...r.actions])
|
|
76
|
+
)
|
|
68
77
|
)
|
|
69
78
|
|
|
70
79
|
const existingActionCodes = new Set(
|
|
71
|
-
(await prisma.action.findMany({ select: { code: true } })).map(
|
|
80
|
+
(await prisma.action.findMany({ select: { code: true } })).map(
|
|
81
|
+
(a) => a.code
|
|
82
|
+
)
|
|
72
83
|
)
|
|
73
84
|
|
|
74
85
|
for (const code of usedActionCodes) {
|
|
@@ -81,7 +92,7 @@ async function main() {
|
|
|
81
92
|
// Dừng hẳn thay vì tạo action rác: sai chính tả một action code sẽ khiến
|
|
82
93
|
// quyền không bao giờ khớp và nút bấm ẩn vĩnh viễn.
|
|
83
94
|
throw new Error(
|
|
84
|
-
`Action "${code}" được dùng nhưng không nằm trong bộ chuẩn và chưa khai ở customActions của phân hệ nào
|
|
95
|
+
`Action "${code}" được dùng nhưng không nằm trong bộ chuẩn và chưa khai ở customActions của phân hệ nào.`
|
|
85
96
|
)
|
|
86
97
|
}
|
|
87
98
|
console.log(` + action: ${code} (${decl.name})`)
|
|
@@ -113,12 +124,14 @@ async function main() {
|
|
|
113
124
|
}
|
|
114
125
|
// Lớp tùy chỉnh của admin (thêm/tắt action trên UI) được BẢO TOÀN:
|
|
115
126
|
// hiệu lực = registry ∪ customActions − disabledActions
|
|
116
|
-
const custom = Array.isArray(config.customActions)
|
|
127
|
+
const custom = Array.isArray(config.customActions)
|
|
128
|
+
? (config.customActions as string[])
|
|
129
|
+
: []
|
|
117
130
|
const disabled = Array.isArray(config.disabledActions)
|
|
118
131
|
? (config.disabledActions as string[])
|
|
119
132
|
: []
|
|
120
133
|
config.actions = [...new Set([...r.actions, ...custom])].filter(
|
|
121
|
-
(a) => !disabled.includes(a)
|
|
134
|
+
(a) => !disabled.includes(a)
|
|
122
135
|
)
|
|
123
136
|
|
|
124
137
|
if (!existing) {
|
|
@@ -147,14 +160,16 @@ async function main() {
|
|
|
147
160
|
}
|
|
148
161
|
|
|
149
162
|
// ── 3. Grant mặc định — CHỈ cho resource vừa tạo (trừ khi --force-grants) ──
|
|
150
|
-
const wantsGrants = (code: string) =>
|
|
163
|
+
const wantsGrants = (code: string) =>
|
|
164
|
+
FORCE_GRANTS || createdResourceCodes.has(code)
|
|
151
165
|
let skippedGrants = 0
|
|
152
166
|
|
|
153
167
|
const neededRoleCodes = new Set<string>()
|
|
154
168
|
for (const feature of permissionRegistry) {
|
|
155
169
|
for (const r of feature.resources) {
|
|
156
170
|
if (!wantsGrants(r.code)) continue
|
|
157
|
-
for (const roleCode of Object.keys(r.defaultGrants ?? {}))
|
|
171
|
+
for (const roleCode of Object.keys(r.defaultGrants ?? {}))
|
|
172
|
+
neededRoleCodes.add(roleCode)
|
|
158
173
|
}
|
|
159
174
|
}
|
|
160
175
|
const presentRoles = new Set(
|
|
@@ -165,7 +180,7 @@ async function main() {
|
|
|
165
180
|
select: { code: true },
|
|
166
181
|
})
|
|
167
182
|
).map((r) => r.code)
|
|
168
|
-
: []
|
|
183
|
+
: []
|
|
169
184
|
)
|
|
170
185
|
|
|
171
186
|
for (const feature of permissionRegistry) {
|
|
@@ -174,11 +189,14 @@ async function main() {
|
|
|
174
189
|
for (const [roleCode, grant] of Object.entries(r.defaultGrants ?? {})) {
|
|
175
190
|
if (!presentRoles.has(roleCode)) {
|
|
176
191
|
skippedGrants++
|
|
177
|
-
console.warn(
|
|
192
|
+
console.warn(
|
|
193
|
+
` ! vai trò "${roleCode}" chưa tồn tại — bỏ qua grant cho ${r.code}`
|
|
194
|
+
)
|
|
178
195
|
continue
|
|
179
196
|
}
|
|
180
197
|
for (const actionCode of grant === "*" ? r.actions : grant) {
|
|
181
|
-
if (VERBOSE || !FORCE_GRANTS)
|
|
198
|
+
if (VERBOSE || !FORCE_GRANTS)
|
|
199
|
+
console.log(` ✓ grant: ${roleCode} → ${r.code}:${actionCode}`)
|
|
182
200
|
if (!DRY_RUN) {
|
|
183
201
|
await prisma.rolePermission.upsert({
|
|
184
202
|
where: {
|
|
@@ -188,7 +206,12 @@ async function main() {
|
|
|
188
206
|
actionCode,
|
|
189
207
|
},
|
|
190
208
|
},
|
|
191
|
-
create: {
|
|
209
|
+
create: {
|
|
210
|
+
id: randomUUID(),
|
|
211
|
+
roleCode,
|
|
212
|
+
resourceCode: r.code,
|
|
213
|
+
actionCode,
|
|
214
|
+
},
|
|
192
215
|
update: {},
|
|
193
216
|
})
|
|
194
217
|
}
|
|
@@ -199,22 +222,27 @@ async function main() {
|
|
|
199
222
|
|
|
200
223
|
// ── 4. Drift (chỉ cảnh báo) ────────────────────────────────────────────
|
|
201
224
|
const registryCodes = new Set(
|
|
202
|
-
permissionRegistry.flatMap((f) => f.resources.map((r) => r.code))
|
|
225
|
+
permissionRegistry.flatMap((f) => f.resources.map((r) => r.code))
|
|
203
226
|
)
|
|
204
227
|
const legacy = (
|
|
205
|
-
await prisma.resource.findMany({
|
|
228
|
+
await prisma.resource.findMany({
|
|
229
|
+
where: { status: "active" },
|
|
230
|
+
select: { code: true },
|
|
231
|
+
})
|
|
206
232
|
)
|
|
207
233
|
.map((r) => r.code)
|
|
208
234
|
.filter((code) => !registryCodes.has(code))
|
|
209
235
|
.sort()
|
|
210
236
|
|
|
211
237
|
if (legacy.length) {
|
|
212
|
-
console.log(
|
|
238
|
+
console.log(
|
|
239
|
+
`→ Drift: ${legacy.length} resource trong DB chưa khai registry`
|
|
240
|
+
)
|
|
213
241
|
if (VERBOSE) console.log(` ${legacy.join(", ")}`)
|
|
214
242
|
}
|
|
215
243
|
|
|
216
244
|
console.log(
|
|
217
|
-
`→ Xong: ${createdResourceCodes.size} resource mới, ${registryCodes.size - createdResourceCodes.size} resource cập nhật
|
|
245
|
+
`→ Xong: ${createdResourceCodes.size} resource mới, ${registryCodes.size - createdResourceCodes.size} resource cập nhật.`
|
|
218
246
|
)
|
|
219
247
|
|
|
220
248
|
// Bỏ qua grant = resource đã tạo nhưng KHÔNG ai có quyền, và lần chạy sau
|
|
@@ -222,7 +250,7 @@ async function main() {
|
|
|
222
250
|
if (skippedGrants) {
|
|
223
251
|
console.warn(
|
|
224
252
|
`\n⚠ ${skippedGrants} grant bị bỏ vì vai trò chưa tồn tại. Chạy \`pnpm seed\` để tạo vai trò,` +
|
|
225
|
-
` rồi \`pnpm rbac-sync --force-grants\` để áp lại — nếu không sẽ không ai vào được màn hình nào
|
|
253
|
+
` rồi \`pnpm rbac-sync --force-grants\` để áp lại — nếu không sẽ không ai vào được màn hình nào.`
|
|
226
254
|
)
|
|
227
255
|
}
|
|
228
256
|
}
|
|
@@ -2,9 +2,10 @@ import { join } from "node:path"
|
|
|
2
2
|
|
|
3
3
|
import { runCoreGuardrails } from "@goerp/core/guardrails"
|
|
4
4
|
|
|
5
|
-
import { permissionRegistry } from "@/configs/permissions"
|
|
6
5
|
import { navigations } from "@/data/navigations"
|
|
7
6
|
|
|
7
|
+
import { permissionRegistry } from "@/configs/permissions"
|
|
8
|
+
|
|
8
9
|
/**
|
|
9
10
|
* HÀNG RÀO KIẾN TRÚC — mỗi thước ở đây ứng với một lỗi im lặng đã từng làm mất
|
|
10
11
|
* thời gian trong app thật: quyền khai sai thì nút biến mất mà không báo lỗi,
|
|
@@ -44,7 +45,7 @@ runCoreGuardrails({
|
|
|
44
45
|
// tương ứng trong CÙNG commit — trần dư cũng bị báo đỏ.
|
|
45
46
|
"debt/any": {
|
|
46
47
|
app: 2, // Record<string, any> cho where của Prisma (audit, error-logs)
|
|
47
|
-
lib:
|
|
48
|
+
lib: 0, // Session của app gán thẳng vào checkPermission — không cần ép kiểu
|
|
48
49
|
server: 1, // mask(config: any) trong system-config-service
|
|
49
50
|
},
|
|
50
51
|
},
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
// Trang hành động = catalog TRA CỨU. Action mới khai trong customActions của
|
|
2
|
+
// Permission Registry rồi chạy rbac-sync.
|
|
3
|
+
import { ActionCatalogPage } from "@goerp/core/rbac/pages"
|
|
2
4
|
import {
|
|
3
5
|
getActionsForPermissionMatrix,
|
|
4
6
|
getResourcesForPermissionMatrix,
|
|
5
7
|
} from "@goerp/core/rbac/resource-service"
|
|
6
|
-
// Trang hành động = catalog TRA CỨU. Action mới khai trong customActions của
|
|
7
|
-
// Permission Registry rồi chạy rbac-sync.
|
|
8
|
-
import { ActionCatalogPage } from "@goerp/core/rbac/pages"
|
|
9
8
|
|
|
10
9
|
import { getActionMetaMap } from "@/configs/permissions"
|
|
11
10
|
import { RESOURCE_PAGES } from "@/configs/permissions/menu-tree"
|
|
11
|
+
import { requirePageAccess } from "@/lib/page-guard"
|
|
12
12
|
import { db } from "@/lib/prisma"
|
|
13
13
|
|
|
14
14
|
export const metadata = { title: "Hành động" }
|
|
@@ -13,8 +13,7 @@ export const metadata = { title: "Bộ nhớ đệm" }
|
|
|
13
13
|
// serialize closure → "Functions cannot be passed directly to Client Components".
|
|
14
14
|
async function assertCacheAccess(action: "view" | "update") {
|
|
15
15
|
const session = await getSession()
|
|
16
|
-
|
|
17
|
-
if (!checkPermission(s, "system-cache", action)) {
|
|
16
|
+
if (!checkPermission(session, "system-cache", action)) {
|
|
18
17
|
throw new Error("Forbidden")
|
|
19
18
|
}
|
|
20
19
|
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
"use server"
|
|
2
2
|
|
|
3
3
|
import { revalidatePath } from "next/cache"
|
|
4
|
+
import { updateSystemCompany } from "@/server/services/company-service"
|
|
5
|
+
|
|
6
|
+
import type { CompanyProfileInput } from "@/server/services/company-service"
|
|
4
7
|
|
|
5
8
|
import { requirePermission } from "@/lib/action-guard"
|
|
6
|
-
import {
|
|
7
|
-
updateSystemCompany,
|
|
8
|
-
type CompanyProfileInput,
|
|
9
|
-
} from "@/server/services/company-service"
|
|
10
9
|
|
|
11
10
|
/** Hồ sơ công ty dùng chung quyền với Cấu hình hệ thống (system-setting). */
|
|
12
11
|
export async function submitCompanyProfile(data: CompanyProfileInput) {
|
|
@@ -15,7 +14,10 @@ export async function submitCompanyProfile(data: CompanyProfileInput) {
|
|
|
15
14
|
const session = await requirePermission("system-setting", "update")
|
|
16
15
|
userId = session.user?.id ?? "system"
|
|
17
16
|
} catch {
|
|
18
|
-
return {
|
|
17
|
+
return {
|
|
18
|
+
success: false,
|
|
19
|
+
error: "Bạn không có quyền cập nhật hồ sơ công ty",
|
|
20
|
+
}
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
try {
|
package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
|
-
import { useForm } from "react-hook-form"
|
|
5
|
-
import { zodResolver } from "@hookform/resolvers/zod"
|
|
6
|
-
import { toast } from "sonner"
|
|
7
|
-
import { z } from "zod"
|
|
8
|
-
|
|
9
4
|
import {
|
|
10
5
|
Card,
|
|
11
6
|
CardContent,
|
|
@@ -21,6 +16,10 @@ import {
|
|
|
21
16
|
} from "@goerp/core/ui"
|
|
22
17
|
import { Button } from "@goerp/core/ui/primitives/button"
|
|
23
18
|
import { Input } from "@goerp/core/ui/primitives/input"
|
|
19
|
+
import { zodResolver } from "@hookform/resolvers/zod"
|
|
20
|
+
import { useForm } from "react-hook-form"
|
|
21
|
+
import { toast } from "sonner"
|
|
22
|
+
import { z } from "zod"
|
|
24
23
|
|
|
25
24
|
import { submitCompanyProfile } from "./actions"
|
|
26
25
|
|
|
@@ -58,7 +57,11 @@ const FIELDS: Array<{
|
|
|
58
57
|
{ name: "address", label: "Địa chỉ" },
|
|
59
58
|
{ name: "phone", label: "Số điện thoại" },
|
|
60
59
|
{ name: "email", label: "Email" },
|
|
61
|
-
{
|
|
60
|
+
{
|
|
61
|
+
name: "systemName",
|
|
62
|
+
label: "Tên hệ thống (hiển thị trên app)",
|
|
63
|
+
placeholder: "Bỏ trống = dùng tên công ty",
|
|
64
|
+
},
|
|
62
65
|
]
|
|
63
66
|
|
|
64
67
|
export function CompanyProfileForm({ initialData }: CompanyProfileFormProps) {
|
|
@@ -129,9 +132,15 @@ export function CompanyProfileForm({ initialData }: CompanyProfileFormProps) {
|
|
|
129
132
|
<div className="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-muted">
|
|
130
133
|
{logoUrl ? (
|
|
131
134
|
// eslint-disable-next-line @next/next/no-img-element -- ảnh động theo DB, không qua next/image
|
|
132
|
-
<img
|
|
135
|
+
<img
|
|
136
|
+
src={logoUrl}
|
|
137
|
+
alt="Logo công ty"
|
|
138
|
+
className="h-full w-full object-contain"
|
|
139
|
+
/>
|
|
133
140
|
) : (
|
|
134
|
-
<span className="text-[11px] text-muted-foreground">
|
|
141
|
+
<span className="text-[11px] text-muted-foreground">
|
|
142
|
+
Chưa có
|
|
143
|
+
</span>
|
|
135
144
|
)}
|
|
136
145
|
</div>
|
|
137
146
|
<div className="flex flex-col gap-2">
|
|
@@ -148,7 +157,9 @@ export function CompanyProfileForm({ initialData }: CompanyProfileFormProps) {
|
|
|
148
157
|
<button
|
|
149
158
|
type="button"
|
|
150
159
|
className="underline"
|
|
151
|
-
onClick={() =>
|
|
160
|
+
onClick={() =>
|
|
161
|
+
form.setValue("logoUrl", "", { shouldDirty: true })
|
|
162
|
+
}
|
|
152
163
|
>
|
|
153
164
|
Gỡ logo
|
|
154
165
|
</button>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { getSystemCompany } from "@/server/services/company-service"
|
|
2
|
+
|
|
1
3
|
import type { Metadata } from "next"
|
|
2
4
|
|
|
3
5
|
import { requirePageAccess } from "@/lib/page-guard"
|
|
4
|
-
import { getSystemCompany } from "@/server/services/company-service"
|
|
5
6
|
|
|
6
7
|
import { CompanyProfileForm } from "./company-profile-form"
|
|
7
8
|
|
|
@@ -2,8 +2,9 @@ import { EntityCrudPage } from "@goerp/core/crud"
|
|
|
2
2
|
|
|
3
3
|
import type { LocaleType } from "@goerp/core/types"
|
|
4
4
|
|
|
5
|
-
import { getEntityConfig } from "@/configs/entities"
|
|
6
5
|
import { dictionary } from "@/data/dictionary"
|
|
6
|
+
|
|
7
|
+
import { getEntityConfig } from "@/configs/entities"
|
|
7
8
|
import { getSession } from "@/lib/auth"
|
|
8
9
|
|
|
9
10
|
export const dynamic = "force-dynamic"
|
|
@@ -22,7 +23,8 @@ export default async function CrudEntityPage({
|
|
|
22
23
|
if (!config) {
|
|
23
24
|
return (
|
|
24
25
|
<div className="p-6 text-sm text-muted-foreground">
|
|
25
|
-
Không tìm thấy entity:
|
|
26
|
+
Không tìm thấy entity:{" "}
|
|
27
|
+
<code className="rounded bg-muted px-1">{entity}</code>
|
|
26
28
|
</div>
|
|
27
29
|
)
|
|
28
30
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { ReactNode } from "react"
|
|
2
2
|
|
|
3
|
-
import { MainLayoutWrapper } from "@/components/layout/main-layout-wrapper"
|
|
4
3
|
import { dictionary } from "@/data/dictionary"
|
|
5
4
|
import { navigations } from "@/data/navigations"
|
|
6
5
|
|
|
6
|
+
import { MainLayoutWrapper } from "@/components/layout/main-layout-wrapper"
|
|
7
|
+
|
|
7
8
|
export default function MainAreaLayout({ children }: { children: ReactNode }) {
|
|
8
9
|
return (
|
|
9
10
|
<MainLayoutWrapper dictionary={dictionary} navigation={navigations}>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { StatBar } from "@goerp/core/ui"
|
|
2
|
-
import { ArrowRight, Building2, ShieldCheck, Sparkles } from "lucide-react"
|
|
3
1
|
import Link from "next/link"
|
|
4
2
|
import { redirect } from "next/navigation"
|
|
3
|
+
import { StatBar } from "@goerp/core/ui"
|
|
4
|
+
import { ArrowRight, Building2, ShieldCheck, Sparkles } from "lucide-react"
|
|
5
5
|
|
|
6
6
|
import { i18n } from "@/configs/i18n"
|
|
7
7
|
import { getSession } from "@/lib/auth"
|
|
@@ -33,8 +33,8 @@ export default async function HomePage() {
|
|
|
33
33
|
</h1>
|
|
34
34
|
<p className="text-sm text-muted-foreground">
|
|
35
35
|
App mẫu chạy trên{" "}
|
|
36
|
-
<code className="rounded bg-muted px-1">@goerp/core</code> — auth,
|
|
37
|
-
CRUD engine và design system đều từ core.
|
|
36
|
+
<code className="rounded bg-muted px-1">@goerp/core</code> — auth,
|
|
37
|
+
RBAC, CRUD engine và design system đều từ core.
|
|
38
38
|
</p>
|
|
39
39
|
</div>
|
|
40
40
|
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { requirePageAccess } from "@/lib/page-guard"
|
|
2
|
-
import {
|
|
3
|
-
getActionsForPermissionMatrix,
|
|
4
|
-
getResourcesForPermissionMatrix,
|
|
5
|
-
} from "@goerp/core/rbac/resource-service"
|
|
6
1
|
// Trang tài nguyên = catalog TRA CỨU (đọc từ DB đã rbac-sync). Khai báo/chỉnh
|
|
7
2
|
// sửa nằm trong Permission Registry (src/configs/permissions) + rbac-sync,
|
|
8
3
|
// không sửa trực tiếp ở đây.
|
|
9
4
|
import { ResourceCatalogPage } from "@goerp/core/rbac/pages"
|
|
5
|
+
import {
|
|
6
|
+
getActionsForPermissionMatrix,
|
|
7
|
+
getResourcesForPermissionMatrix,
|
|
8
|
+
} from "@goerp/core/rbac/resource-service"
|
|
10
9
|
|
|
11
10
|
import { getActionMetaMap, getLookupPolicyMap } from "@/configs/permissions"
|
|
12
11
|
import { MENU_TREE } from "@/configs/permissions/menu-tree"
|
|
12
|
+
import { requirePageAccess } from "@/lib/page-guard"
|
|
13
13
|
import { db } from "@/lib/prisma"
|
|
14
14
|
|
|
15
15
|
export const metadata = { title: "Tài nguyên" }
|