@goplusvn/core 0.1.15 → 0.1.17

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/CHANGELOG.md CHANGED
@@ -1,59 +1,83 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.17 — Fix: khôi phục nền màu tab (page-tabs) theo golden vinhhoa
4
+
5
+ Bản "neutral single-style" trước đó bỏ gradient nền tab NHƯNG vẫn để icon/chữ
6
+ `text-white` (thiết kế cho nền tối) → tab inactive: chữ/icon trắng trên nền xám
7
+ nhạt = mất màu/mờ (không lấy theo --primary). Khôi phục `getTabBackgroundColor`
8
+ bản golden vinhhoa: tab inactive = 1 gradient tối theo hash path (bộ 21 màu), chữ
9
+ `text-white`; tab active = nền trang (trắng) + viền `border-primary`. Mọi app hiện
10
+ màu nền tab như vinhhoa.
11
+
12
+
13
+ ## 0.1.16 — RBAC roles route-handler factory
14
+
15
+ Slice ⑧b. Mỗi app hand-write /api/roles + /api/roles/[id] (list users[], create/update
16
+ thay RolePermission set, delete guard theo user count). Nay ở core:
17
+ `@goerp/core/rbac/route-handlers` — `createRolesCollectionHandlers` (GET list
18
+ schema-tolerant qua getRolesData + POST create+writePermissions) +
19
+ `createRoleItemHandlers` (GET/PUT/DELETE). App inject prisma+getSession+
20
+ getCrudPermissions + schema field-map. Additive. wu thay ~90 LOC route tay → factory;
21
+ /api/roles 200 (6 roles), unauth 401, /vi/roles 200.
22
+
23
+
3
24
  ## 0.1.15 — Shared UI: ConfirmDialog, StatBar, ListToolbar
4
25
 
5
- Tiếp 0.1.14: promote 3 component dùng chung nữa vào `@goerp/core/ui` (ConfirmDialog, StatBar, ListToolbar + AdvancedField) — import từ sub-barrel tránh circular. Additive; wu shim + verify render 200.
26
+ Tiếp 0.1.14: promote 3 component dùng chung nữa vào `@goerp/core/ui` (ConfirmDialog,
27
+ StatBar, ListToolbar + AdvancedField) — import từ sub-barrel tránh circular.
28
+ Additive; wu shim + verify render 200.
29
+
6
30
 
7
- ## 0.1.14 — Init-completion: server-CRUD engine, auth gate, schema-tolerant RBAC, shared UI
31
+ ## 0.1.14 Init-completion: server-CRUD engine, auth gate, schema-tolerant RBAC, shared UI
8
32
 
9
- Đúc kết từ việc dựng app mới (wu-vpbank): những thứ MỖI app phải tự viết lại nay
10
- đưa vào core (xem `docs/CORE-INIT-COMPLETION-PLAN.md`). Toàn bộ **additive, backward-
11
- compatible** — 4 app (vinhhoa/thingtodo/wu ^0.1.13, tanloc ^0.1.3) an toàn.
33
+ Đúc kết từ việc dựng app mới (wu-vpbank): những thứ MỖI app phải tự viết lại nay
34
+ đưa vào core (xem `docs/CORE-INIT-COMPLETION-PLAN.md`). Toàn bộ **additive, backward-
35
+ compatible** 4 app (vinhhoa/thingtodo/wu ^0.1.13, tanloc ^0.1.3) an toàn.
12
36
 
13
37
  - **Server-CRUD engine** (`@goerp/core/crud/server`): `createServerCrudService`
14
38
  (Prisma-agnostic qua DI), `getModelName`, `createAuditUserNameResolver` (schema-
15
- tolerant tên user), `createCrudCollectionHandlers`/`createCrudItemHandlers` (route
16
- Next.js tự gác quyền). App bỏ ~350 LOC engine tự chế.
39
+ tolerant tên user), `createCrudCollectionHandlers`/`createCrudItemHandlers` (route
40
+ Next.js tự gác quyền). App bỏ ~350 LOC engine tự chế.
17
41
  - **Auth request-gate** (`@goerp/core/auth/proxy-gate`): `createAuthProxy` (default-
18
- deny) + `unauthorizedResponse`. proxy.ts app còn ~10 LOC.
19
- - **RBAC schema-tolerant**: `getRolesData(db, params, schema?)` nhận field-map
20
- (`userNameField`/`userActiveField`/`userImageField`/`roleTimestamps`) → app schema
21
- khác (User.fullName/active, Role không timestamps) không còn crash/tự chế.
42
+ deny) + `unauthorizedResponse`. proxy.ts app còn ~10 LOC.
43
+ - **RBAC schema-tolerant**: `getRolesData(db, params, schema?)` nhận field-map
44
+ (`userNameField`/`userActiveField`/`userImageField`/`roleTimestamps`) app schema
45
+ khác (User.fullName/active, Role không timestamps) không còn crash/tự chế.
22
46
  - **Shared UI** (`@goerp/core/ui`): PageHeader, StatusIndicator + getStatusMeta,
23
- SumFooterCell + sumBy, table-styles (mẫu bảng đơn bán hàng, subpath server-safe
47
+ SumFooterCell + sumBy, table-styles (mẫu bảng đơn bán hàng, subpath server-safe
24
48
  `@goerp/core/ui/shared/table-styles`); export DynamicIcon.
25
49
 
26
- Verify: core tsc 0 lỗi; wu consume toàn bộ (tsc 0/0 + render/CRUD/auth chạy đúng);
50
+ Verify: core tsc 0 lỗi; wu consume toàn bộ (tsc 0/0 + render/CRUD/auth chạy đúng);
27
51
  golden vinhhoa tsc 20=20 (0 regression).
28
52
 
29
- ## 0.1.13 — Customizer: make radius, density & inset/floating actually apply
53
+ ## 0.1.13 Customizer: make radius, density & inset/floating actually apply
30
54
 
31
55
  The Customizer exposed controls that stored a value but changed nothing on
32
56
  screen. Wired them up at the source so every app using core gets them working:
33
57
 
34
- - **Radius ("Bo góc") now applies.** `SettingsProvider` drives the real
58
+ - **Radius ("Bo góc") now applies.** `SettingsProvider` drives the real
35
59
  `--radius` token from `settings.radius`; Tailwind's `@theme inline` derives
36
- `rounded-sm/md/lg/xl` from it, so the whole UI re-rounds live (0 → square,
37
- 1rem → fully rounded). Previously nothing read `settings.radius`.
38
- - **Density ("Mật độ") now applies.** `SettingsProvider` sets
60
+ `rounded-sm/md/lg/xl` from it, so the whole UI re-rounds live (0 square,
61
+ 1rem fully rounded). Previously nothing read `settings.radius`.
62
+ - **Density ("Mật độ") now applies.** `SettingsProvider` sets
39
63
  `data-density="compact"` on `<html>`; `base.css` tightens the global
40
- `--spacing` scale (0.25rem → 0.215rem) so every spacing utility packs in
64
+ `--spacing` scale (0.25rem 0.215rem) so every spacing utility packs in
41
65
  compact mode. Previously `useDensity()` was read nowhere.
42
66
  - **Inset / floating sidebar no longer renders a harsh frame.** Core's default
43
67
  sidebar is deep navy, and shadcn's inset/floating paints the page frame with
44
- that sidebar colour — reading as a broken-looking block. The frame is now the
68
+ that sidebar colour reading as a broken-looking block. The frame is now the
45
69
  soft `--muted` neutral, so the content floats as a card on a calm page
46
70
  (adapts to dark mode; the rounded-xl + shadow core already applies stay).
47
71
  - **Inset / floating collapsed rail no longer looks squeezed.** In icon mode the
48
72
  floating/inset content box is `--sidebar-width-icon + 8px` (56px) but had
49
- `px-2.5` padding, leaving the pill only 36px — narrower than the default 48px
73
+ `px-2.5` padding, leaving the pill only 36px narrower than the default 48px
50
74
  rail. Reduced to `px-1` so the pill is a comfortable 48px with a 4px floating
51
75
  gutter.
52
76
  - **Inset / floating sidebar corners are softened.** The floating panel was
53
77
  `rounded-lg` and the inset panel had NO radius (a sharp navy block whose
54
- top-right corner met the header as a hard 90°). Both now use `rounded-xl`
78
+ top-right corner met the header as a hard 90°). Both now use `rounded-xl`
55
79
  (matching the inset content card) so the sidebar reads as a soft floating
56
- panel — the header/sidebar junction is a gentle curve, not a rough corner.
80
+ panel the header/sidebar junction is a gentle curve, not a rough corner.
57
81
  The floating content area also rounds both left corners
58
82
  (`rounded-l-xl`, clipping the sticky header + footer via overflow-hidden) so
59
83
  its whole left edge hugs the floating sidebar with soft curves; the right
@@ -64,20 +88,20 @@ screen. Wired them up at the source so every app using core gets them working:
64
88
  And the content no longer touches the sidebar: inset drops its `ml-0` (so the
65
89
  uniform `m-2` leaves a left gap) and floating gains `ml-2`, giving a small
66
90
  gap between the sidebar and the content/header.
67
- - **"Ẩn ngoài" (`collapsible="offcanvas"`) can finally hide the sidebar.**
68
- `AppSidebar` force-remapped `offcanvas → icon`, so the option collapsed to an
69
- icon rail instead of hiding — identical to "Biểu tượng" and impossible to fully
91
+ - **"Ẩn ngoài" (`collapsible="offcanvas"`) can finally hide the sidebar.**
92
+ `AppSidebar` force-remapped `offcanvas icon`, so the option collapsed to an
93
+ icon rail instead of hiding identical to "Biểu tượng" and impossible to fully
70
94
  hide the sidebar. The choice is now honoured as-is: offcanvas slides the
71
- sidebar off-screen (reopen via the header toggle), icon → rail.
72
- - **Dropped the "none" (Tắt) collapsible option from the Customizer.** Locking
73
- the sidebar expanded made the header collapse-toggle a no-op — a
95
+ sidebar off-screen (reopen via the header toggle), icon rail.
96
+ - **Dropped the "none" (Tắt) collapsible option from the Customizer.** Locking
97
+ the sidebar expanded made the header collapse-toggle a no-op a
74
98
  conflicting/dead control. The picker now offers only offcanvas + icon, both of
75
99
  which the toggle actually drives. (`none` remains a valid type for any code
76
100
  that sets it directly; it's just no longer offered in the UI.)
77
- - **Dropped the "Bố cục" (layout: horizontal/vertical) control from the
101
+ - **Dropped the "Bố cục" (layout: horizontal/vertical) control from the
78
102
  Customizer.** The horizontal layout swaps the whole nav for a top menubar,
79
- which turns every sidebar option ("Kiểu thanh bên", "Thu gọn thanh bên") into
80
- a no-op — the same dead-control problem. The customizer now locks to the
103
+ which turns every sidebar option ("Kiểu thanh bên", "Thu gọn thanh bên") into
104
+ a no-op the same dead-control problem. The customizer now locks to the
81
105
  vertical sidebar the apps are designed around (`defaultSettings.layout` stays
82
106
  `vertical`; `HorizontalLayout` is untouched for code that sets it directly).
83
107
  - **Non-collapsing sidebar (`collapsible="none"`) is now readable.** It rendered
@@ -85,9 +109,9 @@ screen. Wired them up at the source so every app using core gets them working:
85
109
  active item are `text-primary`, giving ~1.9:1 contrast (unreadable) on that
86
110
  navy. A non-collapsing sidebar is permanently *expanded*, so it now uses the
87
111
  expanded surface (`bg-background` / `text-foreground`), matching the rest of
88
- the design (labels-on-light) — contrast jumps to ~9:1.
112
+ the design (labels-on-light) contrast jumps to ~9:1.
89
113
 
90
- ## 0.1.11 — print: force light colours on the print surface
114
+ ## 0.1.11 print: force light colours on the print surface
91
115
 
92
116
  - **Fix faded print output in dark mode.** Print pages render under the app's
93
117
  shared `<html class="dark">`, so the print surface inherited dark mode's light
@@ -96,34 +120,34 @@ screen. Wired them up at the source so every app using core gets them working:
96
120
  `.print-container, #print-content`, keeping every shared print template
97
121
  black-on-white regardless of the active theme.
98
122
 
99
- ## 0.1.8 — branding wiring + auth/rbac unification + clean typecheck
123
+ ## 0.1.8 branding wiring + auth/rbac unification + clean typecheck
100
124
 
101
125
  - **Single source for permission checks (security fix).** `@goerp/core/rbac`
102
126
  used to ship its own copy of `checkPermission` / `getUserPermissions` /
103
- `getCrudPermissionsFromSession` / … which had drifted from `@goerp/core/auth`
104
- — it still allowed `BYPASS_AUTH` in production and denied admins with no
127
+ `getCrudPermissionsFromSession` / which had drifted from `@goerp/core/auth`
128
+ it still allowed `BYPASS_AUTH` in production and denied admins with no
105
129
  explicit permission rows. `rbac` now re-exports these from the hardened `auth`
106
130
  module (bypass dev-only, admin-before-empty-guard), so the two can't diverge.
107
131
 
108
132
 
109
133
  - **Branding via `TenantProvider`.** `Logo`/`LogoCompact` now resolve their image,
110
- company name and tagline from `props → TenantProvider branding → defaults`, so a
134
+ company name and tagline from `props TenantProvider branding defaults`, so a
111
135
  new app re-skins by wrapping with `<TenantProvider tenant={{ branding }}>` (or
112
136
  passing `logoSrc`/`companyName`/`tagline` props) instead of hardcoding. Added a
113
137
  non-throwing `useOptionalTenant()` hook and a `tagline` field to `TenantBranding`.
114
138
  `TenantProvider` also applies `branding.primaryColor` (an HSL triple) to
115
139
  `--primary`/`--accent-primary` at runtime, so the brand color is config-driven
116
- too. Fully backward compatible (no provider/props → previous GoERP defaults).
140
+ too. Fully backward compatible (no provider/props previous GoERP defaults).
117
141
  - **Core now type-checks clean (0 errors).** Fixed the `system/categories`
118
142
  components that imported the consumer alias `@goerp/core/ui` from inside the
119
- package (unresolvable in core's own tsconfig → cascaded into implicit-any in
143
+ package (unresolvable in core's own tsconfig cascaded into implicit-any in
120
144
  their event handlers); switched to relative imports.
121
145
  - **Standard entity configs.** `@goerp/core/configs/entities` now ships canonical
122
146
  `EntityConfig`s (starting with `departmentsConfig`, plus the previously
123
147
  unexported `materialCategoriesConfig`) so apps import + spread-to-override
124
148
  instead of copy-pasting near-identical configs.
125
149
 
126
- ## 0.1.7 — auth hardening
150
+ ## 0.1.7 auth hardening
127
151
 
128
152
  Hardened `@goerp/core/auth` so the Vinh Hoa app (and others) can consolidate
129
153
  their hand-copied RBAC clones onto core without regressing two safety behaviors:
@@ -135,46 +159,46 @@ their hand-copied RBAC clones onto core without regressing two safety behaviors:
135
159
  admin-role bypass BEFORE the empty-permissions guard, so an admin who relies on
136
160
  their role (no explicit permission rows) is no longer denied everything.
137
161
 
138
- ## 0.1.6 — platform foundation extraction
162
+ ## 0.1.6 platform foundation extraction
139
163
 
140
164
  Promoted the cross-cutting platform layer out of the Vinh Hoa reference app into
141
165
  core so every app inherits the same design system + infrastructure and only
142
- builds its own business logic. All additive — existing subpaths are unchanged.
166
+ builds its own business logic. All additive existing subpaths are unchanged.
143
167
 
144
168
  ### Added
145
169
 
146
- - **`@goerp/core/styles/base.css`** — the canonical design-system tokens (Radix
147
- scale → shadcn semantic → Plane semantic; status, charts, sidebar, scrollbar,
170
+ - **`@goerp/core/styles/base.css`** the canonical design-system tokens (Radix
171
+ scale shadcn semantic Plane semantic; status, charts, sidebar, scrollbar,
148
172
  animations). Apps `@import` this from their Tailwind v4 entry stylesheet and
149
173
  override only brand tokens (`--primary`, `--sidebar-*`). Brand default: indigo.
150
174
  Also ships `@goerp/core/styles/radix-themes.css` (the HSL Radix scale).
151
175
 
152
- - **`@goerp/core/errors`** — `AppError` + `toAppError` (maps Prisma
153
- P2002/P2025/P2003/P2014 → friendly message + stable `errorId`),
176
+ - **`@goerp/core/errors`** `AppError` + `toAppError` (maps Prisma
177
+ P2002/P2025/P2003/P2014 friendly message + stable `errorId`),
154
178
  `withErrorHandler` / `createErrorResponse` / `errorResponse`, and the
155
179
  db-injected factories `createErrorLogger(db)` (fingerprint-deduped persistence
156
180
  to an `error_logs` table) + `buildServerError({ logServerError })` /
157
181
  `createServerError({ db })`. Granular client-safe paths:
158
182
  `@goerp/core/errors/{app-error,error-handler,server-error}`.
159
183
 
160
- - **`@goerp/core/export`** — `useExport()` (the unified "Xuất Excel" hook),
184
+ - **`@goerp/core/export`** `useExport()` (the unified "Xuất Excel" hook),
161
185
  `triggerBlobDownload` / `filenameFromContentDisposition`, and `toCsv` /
162
186
  `escapeCsvField` (RFC 4180). The crud `exportToCSV` now delegates to `toCsv`,
163
187
  fixing unescaped commas/quotes/newlines and falsy `0`/`false`. Granular paths:
164
188
  `@goerp/core/export/{to-csv,download-file,use-export}`.
165
189
 
166
- - **`@goerp/core/print`** — `DirectPrintProvider` (document-agnostic print device
190
+ - **`@goerp/core/print`** `DirectPrintProvider` (document-agnostic print device
167
191
  layer: hidden-iframe URL printing with a mobile same-tab fallback, one-page
168
192
  sizing, loading overlay) driven by a `renderContent` render-prop, the
169
193
  `useDirectPrint` hook, and `PrintStyles` (A4/A5 `@media print`). Granular paths:
170
194
  `@goerp/core/print/{direct-print-provider,print-styles}`.
171
195
 
172
- - **`@goerp/core/repository`** — `casUpdate` / `casUpdateById`: the compare-and-
196
+ - **`@goerp/core/repository`** `casUpdate` / `casUpdateById`: the compare-and-
173
197
  swap invariant for money/stock mutations (`updateMany` on expected state,
174
198
  throw on zero rows so an enclosing transaction rolls back).
175
199
 
176
- - **`@goerp/core/utils/{serialize,fetcher,date-utils,cccd-parser}`** —
177
- `serializeDecimalFields` (Prisma Decimal → string for Client Components),
200
+ - **`@goerp/core/utils/{serialize,fetcher,date-utils,cccd-parser}`**
201
+ `serializeDecimalFields` (Prisma Decimal string for Client Components),
178
202
  `swrFetcher` / `swrListFetcher` / `unwrapList`, Vietnam-timezone date helpers,
179
203
  and the CCCD (VN ID-card) QR parser.
180
204
 
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.15",
4
+ "version": "0.1.17",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -37,6 +37,7 @@
37
37
  "./assets/*": "./src/assets/*",
38
38
  "./styles/*": "./src/styles/*",
39
39
  "./auth/proxy-gate": "./src/auth/proxy-gate.ts",
40
+ "./rbac/route-handlers": "./src/rbac/route-handlers.ts",
40
41
  "./ui/shared/table-styles": "./src/ui/shared/table-styles.ts",
41
42
  "./errors/app-error": "./src/errors/app-error.ts",
42
43
  "./errors/error-handler": "./src/errors/error-handler.ts",
@@ -0,0 +1,182 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // Next.js route-handler factories for the RBAC "roles" API. Every app hand-wrote
3
+ // /api/roles + /api/roles/[id] (list with users[], create/update replacing the
4
+ // RolePermission set, delete guarded by user count). Promoted to core.
5
+ //
6
+ // Permissions are the canonical "action:resource" string[] (matches RoleListPage
7
+ // + getRolesData). Schema-tolerant via RoleServiceSchema (see role-service).
8
+ //
9
+ // Usage (app side):
10
+ // // src/app/api/roles/route.ts
11
+ // import { createRolesCollectionHandlers } from "@goerp/core/rbac/route-handlers";
12
+ // export const { GET, POST } = createRolesCollectionHandlers({
13
+ // prisma, getSession, getCrudPermissions, schema: { userNameField: "fullName", ... },
14
+ // });
15
+
16
+ import { getRolesData, type RoleServiceSchema } from "./role-service";
17
+
18
+ type MaybePromise<T> = T | Promise<T>;
19
+
20
+ export interface RbacHandlerDeps {
21
+ prisma: any;
22
+ getSession: () => MaybePromise<any | null>;
23
+ /** Permission gate for resource "role". Usually getCrudPermissions from @goerp/core/crud/server. */
24
+ getCrudPermissions: (session: any, resource: string) => Promise<{ read?: boolean; create?: boolean; update?: boolean; delete?: boolean }>;
25
+ /** Schema field-map when User/Role diverge from defaults. */
26
+ schema?: RoleServiceSchema;
27
+ onError?: (error: unknown, req: Request) => Response | Promise<Response>;
28
+ }
29
+
30
+ const json = (data: unknown, status = 200) =>
31
+ new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
32
+
33
+ // permissions "action:resource" → RolePermission rows {roleCode,resourceCode,actionCode}.
34
+ async function writePermissions(tx: any, roleCode: string, permissions: string[]) {
35
+ await tx.rolePermission.deleteMany({ where: { roleCode } });
36
+ const rows = (permissions || [])
37
+ .map((p) => {
38
+ const [actionCode, resourceCode] = p.split(":");
39
+ return actionCode && resourceCode ? { roleCode, resourceCode, actionCode } : null;
40
+ })
41
+ .filter(Boolean);
42
+ if (rows.length) await tx.rolePermission.createMany({ data: rows, skipDuplicates: true });
43
+ }
44
+
45
+ // GET (list, schema-tolerant) + POST (create + permissions) for /api/roles.
46
+ export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
47
+ const { prisma, getSession, getCrudPermissions, schema, onError } = deps;
48
+ const fail = (e: unknown, req: Request) => (onError ? onError(e, req) : json({ error: "Internal error" }, 500));
49
+
50
+ async function GET(req: Request) {
51
+ try {
52
+ const session = await getSession();
53
+ if (!session) return json({ error: "Unauthorized" }, 401);
54
+ const perms = await getCrudPermissions(session, "role");
55
+ if (!perms.read) return json({ error: "Forbidden" }, 403);
56
+ const sp = new URL(req.url).searchParams;
57
+ const result = await getRolesData(
58
+ prisma,
59
+ {
60
+ page: Number(sp.get("page") || 1),
61
+ pageSize: Math.min(Number(sp.get("pageSize") || 20), 200),
62
+ search: sp.get("search")?.trim() || undefined,
63
+ status: sp.get("status")?.trim() || undefined,
64
+ },
65
+ schema,
66
+ );
67
+ return json(result);
68
+ } catch (e) {
69
+ return fail(e, req);
70
+ }
71
+ }
72
+
73
+ async function POST(req: Request) {
74
+ try {
75
+ const session = await getSession();
76
+ if (!session) return json({ error: "Unauthorized" }, 401);
77
+ const perms = await getCrudPermissions(session, "role");
78
+ if (!perms.create) return json({ error: "Forbidden" }, 403);
79
+ const body = await req.json();
80
+ const code = (body.code ?? "").trim();
81
+ const name = (body.name ?? "").trim();
82
+ if (!code || !name) return json({ error: "Thiếu mã hoặc tên vai trò" }, 400);
83
+ const permissions: string[] = Array.isArray(body.permissions) ? body.permissions : [];
84
+ const role = await prisma.$transaction(async (tx: any) => {
85
+ const created = await tx.role.create({
86
+ data: { code, name, description: body.description ?? null, status: body.status ?? "active" },
87
+ });
88
+ await writePermissions(tx, created.code, permissions);
89
+ return created;
90
+ });
91
+ return json({ id: role.id, code: role.code, name: role.name }, 201);
92
+ } catch (e) {
93
+ return fail(e, req);
94
+ }
95
+ }
96
+
97
+ return { GET, POST };
98
+ }
99
+
100
+ // GET + PUT (update + replace permissions) + DELETE (guarded by user count) for /api/roles/[id].
101
+ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
102
+ const { prisma, getSession, getCrudPermissions, onError } = deps;
103
+ const fail = (e: unknown, req: Request) => (onError ? onError(e, req) : json({ error: "Internal error" }, 500));
104
+ type Ctx = { params: Promise<{ id: string }> };
105
+
106
+ async function GET(_req: Request, ctx: Ctx) {
107
+ try {
108
+ const { id } = await ctx.params;
109
+ const session = await getSession();
110
+ if (!session) return json({ error: "Unauthorized" }, 401);
111
+ const perms = await getCrudPermissions(session, "role");
112
+ if (!perms.read) return json({ error: "Forbidden" }, 403);
113
+ const role = await prisma.role.findUnique({
114
+ where: { id },
115
+ include: { rolePermissions: { select: { resourceCode: true, actionCode: true } } },
116
+ });
117
+ if (!role) return json({ error: "Not found" }, 404);
118
+ return json({
119
+ id: role.id,
120
+ code: role.code,
121
+ name: role.name,
122
+ description: role.description ?? "",
123
+ status: role.status,
124
+ permissions: role.rolePermissions.map((p: any) => `${p.actionCode}:${p.resourceCode}`),
125
+ });
126
+ } catch (e) {
127
+ return fail(e, _req);
128
+ }
129
+ }
130
+
131
+ async function PUT(req: Request, ctx: Ctx) {
132
+ try {
133
+ const { id } = await ctx.params;
134
+ const session = await getSession();
135
+ if (!session) return json({ error: "Unauthorized" }, 401);
136
+ const perms = await getCrudPermissions(session, "role");
137
+ if (!perms.update) return json({ error: "Forbidden" }, 403);
138
+ const body = await req.json();
139
+ const existing = await prisma.role.findUnique({ where: { id } });
140
+ if (!existing) return json({ error: "Not found" }, 404);
141
+ const permissions: string[] = Array.isArray(body.permissions) ? body.permissions : [];
142
+ const role = await prisma.$transaction(async (tx: any) => {
143
+ const updated = await tx.role.update({
144
+ where: { id },
145
+ data: {
146
+ name: (body.name ?? existing.name).trim(),
147
+ description: body.description ?? existing.description,
148
+ status: body.status ?? existing.status,
149
+ },
150
+ });
151
+ await writePermissions(tx, updated.code, permissions);
152
+ return updated;
153
+ });
154
+ return json({ id: role.id, code: role.code, name: role.name });
155
+ } catch (e) {
156
+ return fail(e, req);
157
+ }
158
+ }
159
+
160
+ async function DELETE(req: Request, ctx: Ctx) {
161
+ try {
162
+ const { id } = await ctx.params;
163
+ const session = await getSession();
164
+ if (!session) return json({ error: "Unauthorized" }, 401);
165
+ const perms = await getCrudPermissions(session, "role");
166
+ if (!perms.delete) return json({ error: "Forbidden" }, 403);
167
+ const role = await prisma.role.findUnique({ where: { id }, include: { _count: { select: { userRoles: true } } } });
168
+ if (!role) return json({ error: "Not found" }, 404);
169
+ if (role._count.userRoles > 0)
170
+ return json({ error: `Vai trò còn ${role._count.userRoles} người dùng, không thể xóa` }, 400);
171
+ await prisma.$transaction(async (tx: any) => {
172
+ await tx.rolePermission.deleteMany({ where: { roleCode: role.code } });
173
+ await tx.role.delete({ where: { id } });
174
+ });
175
+ return new Response(null, { status: 204 });
176
+ } catch (e) {
177
+ return fail(e, req);
178
+ }
179
+ }
180
+
181
+ return { GET, PUT, DELETE };
182
+ }
@@ -85,10 +85,42 @@ export function PageTabs({
85
85
  currentTabIndex >= 0 && currentTabIndex < sortedTabs.length - 1;
86
86
  const hasOtherTabs = sortedTabs.length > 1;
87
87
 
88
- // Neutral, single-style tab background (was a per-path rainbow gradient).
89
- // Active tabs sit on the page surface; inactive tabs are a muted chip.
90
- const getTabBackgroundColor = (_path: string, isActive: boolean) => {
91
- return isActive ? "bg-background" : "bg-muted/60";
88
+ // Nền tab: active = mặt phẳng trang (trắng); inactive = 1 gradient tối theo hash
89
+ // của path (bộ 21 màu cố định) bám thiết kế golden vinhhoa (icon/chữ trắng đọc
90
+ // trên nền tối). KHÔNG dùng --primary để mỗi tab có màu riêng, dễ phân biệt.
91
+ const getTabBackgroundColor = (path: string, isActive: boolean) => {
92
+ if (isActive) return "bg-background";
93
+
94
+ const normalizedPath = path.replace(/^\/[a-z]{2}(\/|$)/, "/");
95
+ const hash = normalizedPath.split("").reduce((acc, char) => {
96
+ return (acc << 5) - acc + char.charCodeAt(0);
97
+ }, 0);
98
+
99
+ const gradients = [
100
+ "bg-gradient-to-r from-slate-700 to-slate-600",
101
+ "bg-gradient-to-r from-zinc-700 to-zinc-600",
102
+ "bg-gradient-to-r from-stone-700 to-stone-600",
103
+ "bg-gradient-to-r from-neutral-700 to-neutral-600",
104
+ "bg-gradient-to-r from-blue-700 to-blue-600",
105
+ "bg-gradient-to-r from-indigo-700 to-indigo-600",
106
+ "bg-gradient-to-r from-purple-700 to-purple-600",
107
+ "bg-gradient-to-r from-violet-700 to-violet-600",
108
+ "bg-gradient-to-r from-fuchsia-700 to-fuchsia-600",
109
+ "bg-gradient-to-r from-pink-700 to-pink-600",
110
+ "bg-gradient-to-r from-rose-700 to-rose-600",
111
+ "bg-gradient-to-r from-red-700 to-red-600",
112
+ "bg-gradient-to-r from-orange-700 to-orange-600",
113
+ "bg-gradient-to-r from-amber-700 to-amber-600",
114
+ "bg-gradient-to-r from-yellow-700 to-yellow-600",
115
+ "bg-gradient-to-r from-lime-700 to-lime-600",
116
+ "bg-gradient-to-r from-green-700 to-green-600",
117
+ "bg-gradient-to-r from-emerald-700 to-emerald-600",
118
+ "bg-gradient-to-r from-teal-700 to-teal-600",
119
+ "bg-gradient-to-r from-cyan-700 to-cyan-600",
120
+ "bg-gradient-to-r from-sky-700 to-sky-600",
121
+ ];
122
+
123
+ return gradients[Math.abs(hash) % gradients.length];
92
124
  };
93
125
 
94
126
  return (
@@ -135,11 +167,11 @@ export function PageTabs({
135
167
  variant === "default" &&
136
168
  (isActive
137
169
  ? "bg-background text-foreground border-b-2 border-primary shadow-sm shadow-primary/10 border-t border-x border-b-0 rounded-t-md -mb-px z-10"
138
- : "text-muted-foreground hover:bg-muted hover:text-foreground border-b border-border rounded-t-md"),
170
+ : "text-white hover:text-white border-b border-white/10 rounded-t-md"),
139
171
  variant === "header" &&
140
172
  (isActive
141
173
  ? "bg-background text-foreground border-b-2 border-primary shadow-sm shadow-primary/10 border-t border-x rounded-t-md z-10"
142
- : "text-muted-foreground hover:bg-muted hover:text-foreground border-b border-border rounded-t-md"),
174
+ : "text-white hover:text-white border-b border-white/10 rounded-t-md"),
143
175
  !isLast &&
144
176
  !isActive &&
145
177
  variant === "default" &&