@goplusvn/core 0.1.22 → 0.1.24

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,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.24 — Canvas app tông TRẮNG + fix multi-select tràn nút đóng
4
+
5
+ - **Canvas toàn app hết ngả xám**: `SidebarInset` (layout dọc) và
6
+ `HorizontalLayout` bỏ lớp phủ `bg-muted/40` → dùng token `bg-background`
7
+ (app tự quyết tông nền; vinhhoa/wu chuộng trắng). Đây là lớp xám bao mọi
8
+ trang từng gây gotcha "khung xám bọc POS".
9
+ - **MultiSelect**: hàng "Chọn tất cả / Bỏ chọn / n/N / ✕" bị tràn — nút đóng
10
+ ✕ đẩy ra ngoài mép panel khi trigger hẹp. Redesign: panel `min-w-[15rem]`,
11
+ hàng nút gọn (h-7, px-1.5, nhóm phải `shrink-0`, nhóm trái `min-w-0`,
12
+ `overflow-hidden`), nút đóng đổi từ đỏ (dễ nhầm "xóa") → trung tính.
13
+
14
+ ## 0.1.23 — Trang RBAC hoàn chỉnh trong core (roles/resources/actions), restyle trắng
15
+
16
+ 3 trang quản trị RBAC trước đây mỗi app tự chép (vinhhoa/wu-vpbank…) nay là trang
17
+ core dùng chung, đồng thời restyle bỏ các mảng `bg-muted`/raw-slate xám → bề mặt
18
+ trắng `bg-card`, phân tách bằng border; nút sửa/xóa trên hàng LUÔN hiển thị
19
+ (trước ẩn sau hover → mobile không dùng được).
20
+
21
+ - `ResourceListPage` (viết lại): nhóm tài nguyên theo section, bung hàng sửa
22
+ action inline, Action Manager dialog, sync. API: `/api/resources`, `/api/actions`.
23
+ - `ActionListPage` (viết lại): danh sách hành động + ngữ cảnh sử dụng
24
+ (tài nguyên · vai trò · người dùng, cần `/api/actions/usage` — optional).
25
+ - `RoleFormPage` (MỚI): form tạo/sửa vai trò + ma trận phân quyền theo nhóm;
26
+ prop `actionLabels` cho app bổ sung nhãn action nghiệp vụ riêng.
27
+ - `RoleListPage`: thêm prop `importSlot` (nhét ImportDialog app-specific vào
28
+ toolbar); `RoleToolbar` viết lại theo `ListToolbar` + `CrudExportButton`
29
+ (bỏ raw hex, nút import/export chết); `RoleCard` restyle token semantic.
30
+ - Barrel client-safe mới: `@goerp/core/rbac/pages` (không kéo server code).
31
+
32
+ ⚠️ Breaking (không consumer nào dùng bản cũ): `ResourceListPage`/`ActionListPage`
33
+ đổi từ wrapper `EntityCrudPage` (props `{lang, session, dictionary, config}`)
34
+ sang trang đầy đủ (props không bắt buộc / `{permissions}`).
35
+
3
36
  ## 0.1.19 — applyBrandThemeTokens (gom logic áp theme, subtle dark-aware)
4
37
 
5
38
  Mỗi app copy y hệt logic áp bảng màu thương hiệu vào CSS token trong theme-provider
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.22",
4
+ "version": "0.1.24",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -1,18 +1,21 @@
1
1
  "use client";
2
2
 
3
+ // Thẻ vai trò trong grid danh sách — bề mặt trắng (bg-card), số liệu đóng
4
+ // khung border thay vì tô nền xám; token semantic thay raw slate.
5
+
3
6
  import { memo, useCallback } from "react";
4
- import { format } from "date-fns";
5
7
  import { useRouter } from "next/navigation";
8
+ import { Copy, Edit, Trash2 } from "lucide-react";
9
+
6
10
  import {
11
+ Badge,
12
+ Button,
7
13
  Card,
8
14
  CardContent,
9
15
  CardDescription,
10
16
  CardHeader,
11
17
  CardTitle,
12
- Button,
13
- Badge,
14
18
  } from "../../../ui";
15
- import { Edit, Copy, Trash2 } from "lucide-react";
16
19
  import type { Role } from "../../types";
17
20
 
18
21
  interface RoleCardProps {
@@ -60,18 +63,16 @@ export const RoleCard = memo(function RoleCard({
60
63
  }, [onDelete, role]);
61
64
 
62
65
  return (
63
- <Card className="group relative transition-all duration-300 border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 rounded-[12px] shadow-sm hover:shadow-md hover:border-primary/40 overflow-hidden">
66
+ <Card className="bg-card border border-border rounded-[12px] shadow-sm transition-all hover:shadow-md hover:border-primary/40 overflow-hidden">
64
67
  <CardHeader className="p-4 pb-2">
65
68
  <div className="flex items-start justify-between">
66
- <div className="flex items-center space-x-3">
67
- <div className="relative flex items-center justify-center">
68
- <div className={`w-3 h-3 rounded-full ${roleColor} ring-4 ring-slate-50 dark:ring-slate-800/50`} />
69
- </div>
69
+ <div className="flex items-center space-x-2.5">
70
+ <div className={`w-2.5 h-2.5 rounded-full ${roleColor}`} />
70
71
  <div>
71
- <CardTitle className="text-[15px] font-bold text-slate-900 dark:text-slate-100 leading-tight">
72
+ <CardTitle className="text-[15px] font-bold text-foreground leading-tight">
72
73
  {role.name}
73
74
  </CardTitle>
74
- <CardDescription className="text-[11px] font-mono text-slate-500 mt-0.5">
75
+ <CardDescription className="text-[11px] font-mono text-muted-foreground mt-0.5">
75
76
  {role.code}
76
77
  </CardDescription>
77
78
  </div>
@@ -86,7 +87,7 @@ export const RoleCard = memo(function RoleCard({
86
87
  ) : (
87
88
  <Badge
88
89
  variant="outline"
89
- className="bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700 text-[10px] px-2 py-0 h-5 font-semibold"
90
+ className="bg-muted text-muted-foreground border-border text-[10px] px-2 py-0 h-5 font-semibold"
90
91
  >
91
92
  Khóa
92
93
  </Badge>
@@ -94,24 +95,24 @@ export const RoleCard = memo(function RoleCard({
94
95
  </div>
95
96
  </CardHeader>
96
97
  <CardContent className="p-4 pt-0 space-y-3">
97
- <p className="text-[13px] text-slate-500 dark:text-slate-400 line-clamp-2 min-h-[2.5rem] leading-relaxed">
98
+ <p className="text-[13px] text-muted-foreground line-clamp-2 min-h-[2.5rem] leading-relaxed">
98
99
  {role.description || "Chưa có mô tả chi tiết cho vai trò này."}
99
100
  </p>
100
101
 
101
- <div className="flex items-center justify-between bg-slate-50 dark:bg-slate-800/50 rounded-lg p-2.5 border border-slate-100 dark:border-slate-800">
102
- <div className="flex flex-col items-center flex-1 border-r border-slate-200 dark:border-slate-700">
103
- <span className="text-[16px] font-black text-primary leading-none mb-1">
102
+ <div className="flex items-center justify-between rounded-lg p-2.5 border border-border bg-card">
103
+ <div className="flex flex-col items-center flex-1 border-r border-border">
104
+ <span className="text-[16px] font-black text-primary leading-none mb-1 tabular-nums">
104
105
  {permissionsCount}
105
106
  </span>
106
- <span className="text-[9px] text-slate-500 font-bold uppercase tracking-wider">
107
+ <span className="text-[9px] text-muted-foreground font-bold uppercase tracking-wider">
107
108
  Quyền hạn
108
109
  </span>
109
110
  </div>
110
111
  <div className="flex flex-col items-center flex-1">
111
- <span className="text-[16px] font-black text-emerald-600 dark:text-emerald-500 leading-none mb-1">
112
+ <span className="text-[16px] font-black text-emerald-600 dark:text-emerald-500 leading-none mb-1 tabular-nums">
112
113
  {role.usersCount}
113
114
  </span>
114
- <span className="text-[9px] text-slate-500 font-bold uppercase tracking-wider">
115
+ <span className="text-[9px] text-muted-foreground font-bold uppercase tracking-wider">
115
116
  Người dùng
116
117
  </span>
117
118
  </div>
@@ -122,10 +123,10 @@ export const RoleCard = memo(function RoleCard({
122
123
  <Button
123
124
  variant="outline"
124
125
  size="sm"
125
- className="flex-1 h-8 text-[12px] font-medium bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-100 transition-colors rounded-[8px] shadow-sm"
126
+ className="flex-1 h-8 text-[12px] font-medium bg-card hover:bg-muted/50 transition-colors rounded-[8px] shadow-sm"
126
127
  onClick={handleEdit}
127
128
  >
128
- <Edit className="mr-1.5 h-3.5 w-3.5 text-slate-400" />
129
+ <Edit className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
129
130
  Sửa
130
131
  </Button>
131
132
  )}
@@ -133,10 +134,10 @@ export const RoleCard = memo(function RoleCard({
133
134
  <Button
134
135
  variant="outline"
135
136
  size="sm"
136
- className="flex-1 h-8 text-[12px] font-medium bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-100 transition-colors rounded-[8px] shadow-sm"
137
+ className="flex-1 h-8 text-[12px] font-medium bg-card hover:bg-muted/50 transition-colors rounded-[8px] shadow-sm"
137
138
  onClick={handleDuplicate}
138
139
  >
139
- <Copy className="mr-1.5 h-3.5 w-3.5 text-slate-400" />
140
+ <Copy className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
140
141
  Chép
141
142
  </Button>
142
143
  )}
@@ -144,7 +145,7 @@ export const RoleCard = memo(function RoleCard({
144
145
  <Button
145
146
  variant="outline"
146
147
  size="sm"
147
- className="flex-1 h-8 text-[12px] font-medium border-slate-200 dark:border-slate-800 text-red-600 dark:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 hover:text-red-700 dark:hover:text-red-400 transition-colors bg-white dark:bg-slate-900 rounded-[8px] shadow-sm"
148
+ className="flex-1 h-8 text-[12px] font-medium bg-card text-destructive hover:bg-destructive/10 hover:text-destructive transition-colors rounded-[8px] shadow-sm"
148
149
  onClick={handleDelete}
149
150
  >
150
151
  <Trash2 className="mr-1.5 h-3.5 w-3.5" />
@@ -1,123 +1,109 @@
1
1
  "use client";
2
2
 
3
- import { memo, useState, useEffect } from "react";
3
+ // Toolbar trang vai trò — dùng ListToolbar chung (trắng, không raw hex).
4
+ // Import là phần app-specific (mỗi app một ImportDialog riêng) nên nhận qua
5
+ // slot `importSlot`; Export dùng CrudExportButton của core.
6
+
7
+ import { memo, useMemo } from "react";
4
8
  import { useRouter } from "next/navigation";
9
+ import type { ReactNode } from "react";
10
+
5
11
  import {
6
- Button,
7
- Input,
12
+ AdvancedField,
13
+ ListToolbar,
8
14
  Select,
9
15
  SelectContent,
10
16
  SelectItem,
11
17
  SelectTrigger,
12
18
  SelectValue,
13
19
  } from "../../../ui";
14
- import { Plus, Search, Download, Upload, X } from "lucide-react";
20
+ import { CrudExportButton } from "../../../crud/components/crud-export-button";
21
+ import type { ActiveFilter } from "../../../types";
15
22
 
16
23
  interface RoleToolbarProps {
17
- dictionary: any;
18
- totalRows: number;
19
24
  searchTerm: string;
20
25
  statusFilter: string;
21
26
  onSearchChange: (value: string) => void;
22
27
  onStatusFilterChange: (value: string) => void;
23
28
  lang: string;
29
+ dictionary?: any;
24
30
  canCreate?: boolean;
25
31
  canImport?: boolean;
26
32
  canExport?: boolean;
33
+ /** Nút Import app-specific (vd ImportDialog của vinhhoa). Chỉ render khi canImport. */
34
+ importSlot?: ReactNode;
35
+ /** Giữ tương thích chữ ký cũ — không còn hiển thị. */
36
+ totalRows?: number;
27
37
  }
28
38
 
29
39
  export const RoleToolbar = memo(function RoleToolbar({
30
- dictionary,
31
- totalRows,
32
40
  searchTerm,
33
41
  statusFilter,
34
42
  onSearchChange,
35
43
  onStatusFilterChange,
36
44
  lang,
45
+ dictionary,
37
46
  canCreate,
38
47
  canImport,
39
48
  canExport,
49
+ importSlot,
40
50
  }: RoleToolbarProps) {
41
51
  const router = useRouter();
42
- const [localSearchTerm, setLocalSearchTerm] = useState(searchTerm);
43
-
44
- // Sync local state when prop changes (e.g. clear filter from parent)
45
- useEffect(() => {
46
- setLocalSearchTerm(searchTerm);
47
- }, [searchTerm]);
48
52
 
49
- // Debounce search change
50
- useEffect(() => {
51
- const handler = setTimeout(() => {
52
- if (localSearchTerm !== searchTerm) {
53
- onSearchChange(localSearchTerm);
54
- }
55
- }, 500);
53
+ const exportFilters = useMemo(() => {
54
+ const filters: ActiveFilter[] = [];
55
+ if (statusFilter && statusFilter !== "all") {
56
+ filters.push({ name: "status", operator: "eq", value: statusFilter });
57
+ }
58
+ return filters;
59
+ }, [statusFilter]);
56
60
 
57
- return () => {
58
- clearTimeout(handler);
59
- };
60
- }, [localSearchTerm, onSearchChange, searchTerm]);
61
+ const advancedCount = statusFilter && statusFilter !== "all" ? 1 : 0;
62
+ const isFiltered = !!searchTerm || (!!statusFilter && statusFilter !== "all");
61
63
 
62
64
  return (
63
- <div className="flex items-center gap-2 flex-wrap">
64
- <div className="relative">
65
- <Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
66
- <Input
67
- placeholder="Tìm kiếm vai trò..."
68
- value={localSearchTerm}
69
- onChange={(e) => setLocalSearchTerm(e.target.value)}
70
- className="h-9 w-[220px] lg:w-[280px] pl-9 text-[13px] bg-[#ffffff] dark:bg-[#181d26] border-[#dddddd] dark:border-[#333840] hover:bg-slate-50 transition-colors shadow-sm rounded-[6px] focus-visible:ring-1 focus-visible:ring-primary"
71
- />
72
- {localSearchTerm && (
73
- <Button
74
- variant="ghost"
75
- size="sm"
76
- className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0 rounded-md hover:bg-muted"
77
- onClick={() => setLocalSearchTerm("")}
78
- >
79
- <X className="h-3.5 w-3.5" />
80
- </Button>
81
- )}
82
- </div>
83
-
84
- <Select value={statusFilter} onValueChange={onStatusFilterChange}>
85
- <SelectTrigger className="h-9 w-[160px] text-[13px] bg-[#ffffff] dark:bg-[#181d26] border-[#dddddd] dark:border-[#333840] hover:bg-slate-50 transition-colors shadow-sm rounded-[6px] focus-visible:ring-primary">
86
- <SelectValue placeholder="Trạng thái" />
87
- </SelectTrigger>
88
- <SelectContent className="rounded-lg">
89
- <SelectItem value="all">Tất cả</SelectItem>
90
- <SelectItem value="active">Hoạt động</SelectItem>
91
- <SelectItem value="inactive">Không hoạt động</SelectItem>
92
- </SelectContent>
93
- </Select>
94
-
95
- <div className="ml-auto flex items-center gap-2">
96
- {canImport && (
97
- <Button variant="outline" size="sm" className="h-9 px-3 rounded-[6px] shadow-sm bg-[#ffffff] dark:bg-[#181d26] border-[#dddddd] dark:border-[#333840] hover:bg-slate-50">
98
- <Upload className="h-4 w-4 lg:mr-2" />
99
- <span className="hidden lg:inline text-[13px]">Nhập</span>
100
- </Button>
101
- )}
102
-
103
- {canExport && (
104
- <Button variant="outline" size="sm" className="h-9 px-3 rounded-[6px] shadow-sm bg-[#ffffff] dark:bg-[#181d26] border-[#dddddd] dark:border-[#333840] hover:bg-slate-50">
105
- <Download className="h-4 w-4 lg:mr-2" />
106
- <span className="hidden lg:inline text-[13px]">Xuất</span>
107
- </Button>
108
- )}
109
-
110
- {canCreate && (
111
- <Button
112
- onClick={() => router.push(`/${lang}/roles/new`)}
113
- size="sm"
114
- className="h-9 px-4 text-[13px] bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm rounded-[6px] font-medium border-0"
115
- >
116
- <Plus className="mr-1.5 h-4 w-4" />
117
- <span>Thêm mới</span>
118
- </Button>
119
- )}
120
- </div>
121
- </div>
65
+ <ListToolbar
66
+ search={searchTerm}
67
+ onSearchChange={onSearchChange}
68
+ searchPlaceholder={dictionary?.common?.search || "Tìm kiếm vai trò..."}
69
+ isFiltered={isFiltered}
70
+ onReset={() => {
71
+ onSearchChange("");
72
+ onStatusFilterChange("all");
73
+ }}
74
+ onCreate={() => router.push(`/${lang}/roles/new`)}
75
+ canCreate={!!canCreate}
76
+ createLabel="Tạo mới"
77
+ advancedCount={advancedCount}
78
+ advanced={
79
+ <AdvancedField label="Trạng thái">
80
+ <Select value={statusFilter} onValueChange={onStatusFilterChange}>
81
+ <SelectTrigger className="h-9 w-full shadow-sm">
82
+ <SelectValue placeholder="Tất cả" />
83
+ </SelectTrigger>
84
+ <SelectContent>
85
+ <SelectItem value="all">Tất cả</SelectItem>
86
+ <SelectItem value="active">Hoạt động</SelectItem>
87
+ <SelectItem value="inactive">Không hoạt động</SelectItem>
88
+ </SelectContent>
89
+ </Select>
90
+ </AdvancedField>
91
+ }
92
+ tableControls={
93
+ canImport || canExport ? (
94
+ <>
95
+ {canImport && importSlot}
96
+ {canExport && (
97
+ <CrudExportButton
98
+ endpoint="/api/roles/export"
99
+ filters={exportFilters}
100
+ search={searchTerm}
101
+ canExport={true}
102
+ />
103
+ )}
104
+ </>
105
+ ) : undefined
106
+ }
107
+ />
122
108
  );
123
109
  });
package/src/rbac/index.ts CHANGED
@@ -136,4 +136,12 @@ export * from "./role-service";
136
136
  export * from "./resource-service";
137
137
  export * from "./resource-validator";
138
138
  export { RoleListPage } from "./pages/role-list-page";
139
+ export { RoleFormPage } from "./pages/role-form-page";
140
+ export type {
141
+ RoleFormPageProps,
142
+ ActionLabelConfig,
143
+ } from "./pages/role-form-page";
144
+ export { ResourceListPage } from "./pages/resource-list-page";
145
+ export { ActionListPage } from "./pages/action-list-page";
146
+ export type { ActionListPageProps } from "./pages/action-list-page";
139
147
 
@@ -0,0 +1,26 @@
1
+ // Small fetch-error helpers for the RBAC management pages. Apps with richer
2
+ // error glue (persisted error logs, i18n) can wrap these pages and keep their
3
+ // own handling; the defaults below only surface the server message via toast.
4
+
5
+ export function errorMessage(error: unknown, fallback: string): string {
6
+ if (typeof error === "string" && error) return error;
7
+ if (error && typeof error === "object") {
8
+ const e = error as { error?: unknown; message?: unknown };
9
+ if (typeof e.error === "string" && e.error) return e.error;
10
+ if (typeof e.message === "string" && e.message) return e.message;
11
+ }
12
+ return fallback;
13
+ }
14
+
15
+ export async function throwFetchError(
16
+ res: Response,
17
+ fallback: string,
18
+ ): Promise<never> {
19
+ let data: unknown = null;
20
+ try {
21
+ data = await res.json();
22
+ } catch {
23
+ // non-JSON body — fall back to the provided message
24
+ }
25
+ throw new Error(errorMessage(data, fallback));
26
+ }