@goplusvn/core 0.1.78 → 0.1.81

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.
@@ -45,7 +45,9 @@ import {
45
45
  House,
46
46
  Info,
47
47
  LayoutTemplate,
48
+ Network,
48
49
  Save,
50
+ ShieldCheck,
49
51
  X,
50
52
  } from "lucide-react"
51
53
 
@@ -80,6 +82,49 @@ const STANDARD_ORDER = ["create", "update", "delete", "export", "import"]
80
82
  /** Radix Select không nhận value rỗng — dùng mã riêng cho "tự suy ra". */
81
83
  const LANDING_AUTO = "__auto__"
82
84
 
85
+ /** Cùng lý do: "" = theo mặc định của app (`subtree`). */
86
+ const DATA_SCOPE_DEFAULT = "__default__"
87
+
88
+ /** Cùng lý do: "" = vai trò DÙNG CHUNG (`workspaceId = null`). */
89
+ const WORKSPACE_SHARED = "__shared__"
90
+
91
+ /**
92
+ * Thang 5 nấc của `@goerp/core/workspace` (Δ1). Chữ ở đây cố ý nói theo NGƯỜI
93
+ * DÙNG chứ không theo tên kỹ thuật: người tick quyền không biết "subtree" là gì.
94
+ */
95
+ const DATA_SCOPE_OPTIONS: { value: string; label: string; hint: string }[] = [
96
+ {
97
+ value: DATA_SCOPE_DEFAULT,
98
+ label: "Theo mặc định",
99
+ hint: "Workspace được gán + cấp dưới",
100
+ },
101
+ {
102
+ value: "all",
103
+ label: "Tất cả dữ liệu",
104
+ hint: "Không cô lập — thấy mọi workspace",
105
+ },
106
+ {
107
+ value: "subtree",
108
+ label: "Workspace + cấp dưới",
109
+ hint: "Đơn vị được gán và các bộ phận con",
110
+ },
111
+ {
112
+ value: "workspace",
113
+ label: "Chỉ workspace được gán",
114
+ hint: "Không thấy dữ liệu của bộ phận con",
115
+ },
116
+ {
117
+ value: "own",
118
+ label: "Chỉ bản ghi của mình",
119
+ hint: "Cần app khai cột chủ sở hữu; thiếu thì không thấy gì",
120
+ },
121
+ {
122
+ value: "none",
123
+ label: "Không thấy dữ liệu",
124
+ hint: "Vai trò chỉ thao tác, không đọc danh sách",
125
+ },
126
+ ]
127
+
83
128
  /** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
84
129
  export interface RoleTemplate {
85
130
  code: string
@@ -142,6 +187,10 @@ export interface RoleFormPageProps {
142
187
  permissions: string[]
143
188
  /** Trang mở đầu của vai trò. Null/undefined = tự suy ra. */
144
189
  landingPath?: string | null
190
+ /** Nấc cô lập dữ liệu. Null = mặc định của app (`subtree`). */
191
+ dataScope?: string | null
192
+ /** Đơn vị sở hữu vai trò. Null = vai trò dùng chung. */
193
+ workspaceId?: string | null
145
194
  }
146
195
  actionLabels?: Record<string, ActionLabelConfig>
147
196
  roleTemplates?: RoleTemplate[]
@@ -151,6 +200,40 @@ export interface RoleFormPageProps {
151
200
  menuTree?: MenuTreeSection[]
152
201
  /** Endpoint nhật ký đổi quyền (edit mode) — vd /api/roles/{id}/history. */
153
202
  historyEndpoint?: string
203
+ /**
204
+ * TRẦN QUYỀN của người đang tạo/sửa vai trò, dạng `"action:resource"`.
205
+ *
206
+ * Bỏ trống = không giới hạn (app một tổ chức, hành vi cũ). Truyền vào thì ma
207
+ * trận chỉ hiện thứ họ tự có — cùng luật D2 mà API đang chặn, để người dùng
208
+ * không tick đủ thứ rồi mới ăn 403 lúc bấm lưu.
209
+ *
210
+ * Quyền đã cấp sẵn nằm ngoài trần thì KHÔNG hiện và cũng KHÔNG bị xoá; API sẽ
211
+ * từ chối lưu — thà chặn còn hơn âm thầm tước quyền của vai trò.
212
+ */
213
+ grantablePermissions?: string[]
214
+ /**
215
+ * Hiện ô CÔ LẬP DỮ LIỆU (`Role.dataScope`). Chỉ bật cho vai vận hành: quản
216
+ * trị viên đơn vị mà tự đặt được nấc này thì họ chỉ cần tạo một vai trò "Tất
217
+ * cả dữ liệu" rồi tự gán, và việc cô lập thành ra tuỳ chọn. API cũng bỏ qua
218
+ * trường này nếu người gửi không phải vai vận hành — đây chỉ là lớp hiển thị.
219
+ */
220
+ showDataScope?: boolean
221
+ /**
222
+ * Các ĐƠN VỊ được phép đặt làm chủ sở hữu vai trò (`Role.workspaceId`). Bỏ
223
+ * trống ⇒ không hiện ô, payload không mang trường này, engine tự đóng dấu theo
224
+ * nhánh người tạo — đúng hành vi trước khi có ô.
225
+ *
226
+ * Hai trường dễ lẫn, đừng gộp:
227
+ * - `workspaceId` = vai trò này CỦA ĐƠN VỊ NÀO, tức AI ĐƯỢC SỬA nó;
228
+ * - `dataScope` = người MANG vai trò này nhìn thấy dữ liệu tới đâu.
229
+ * Một vai trò dùng chung (`null`) vẫn có thể cô lập chặt, và ngược lại.
230
+ *
231
+ * `allowShared` chỉ bật cho vai vận hành: quản trị viên đơn vị mà tạo được vai
232
+ * trò dùng chung là họ vừa đẻ ra thứ mọi đơn vị khác cũng gán được. API chặn
233
+ * sẵn (D1), đây là lớp hiển thị cho khớp.
234
+ */
235
+ workspaceOptions?: Array<{ id: string; name: string; depth?: number }>
236
+ allowSharedWorkspace?: boolean
154
237
  }
155
238
 
156
239
  export function RoleFormPage({
@@ -167,9 +250,18 @@ export function RoleFormPage({
167
250
  resourcePages,
168
251
  menuTree,
169
252
  historyEndpoint,
253
+ grantablePermissions,
254
+ showDataScope,
255
+ workspaceOptions,
256
+ allowSharedWorkspace,
170
257
  }: RoleFormPageProps) {
171
258
  const router = useRouter()
172
259
 
260
+ const grantable = React.useMemo(
261
+ () => (grantablePermissions ? new Set(grantablePermissions) : null),
262
+ [grantablePermissions]
263
+ )
264
+
173
265
  const mergedActionLabels = React.useMemo(
174
266
  () => ({ ...DEFAULT_ACTION_LABELS, ...actionLabels }),
175
267
  [actionLabels]
@@ -199,6 +291,19 @@ export function RoleFormPage({
199
291
  landingPath: initialData?.landingPath || "",
200
292
  })
201
293
 
294
+ // Tách khỏi `formData` vì payload gửi thẳng cả object: vai trò không được
295
+ // phép đặt nấc này thì trường phải KHÔNG có mặt, chứ không phải gửi rỗng.
296
+ const [dataScope, setDataScope] = React.useState<string>(
297
+ initialData?.dataScope ?? ""
298
+ )
299
+
300
+ // Cùng lý do tách khỏi `formData`: không hiện ô thì payload phải KHÔNG có
301
+ // trường `workspaceId`, để cổng uỷ quyền tự đóng dấu theo nhánh người tạo.
302
+ const showWorkspace = Boolean(workspaceOptions?.length)
303
+ const [workspaceId, setWorkspaceId] = React.useState<string>(
304
+ initialData?.workspaceId ?? ""
305
+ )
306
+
202
307
  const getLocalizedName = React.useCallback(
203
308
  (key: string | null) => {
204
309
  if (!key) return ""
@@ -258,6 +363,17 @@ export function RoleFormPage({
258
363
  } catch {
259
364
  allowed = actions.map((a) => a.code).filter((c) => fallback.includes(c))
260
365
  }
366
+ // TRẦN QUYỀN (D2): người tạo vai trò không cấp được thứ chính mình không
367
+ // có. Cắt ngay ở `allowed` nên mọi thứ dựng trên nó — hàng thao tác, tick
368
+ // trang, mẫu vai trò, phạm vi chi nhánh — tự động tuân theo; lọc riêng ở
369
+ // từng chỗ vẽ là kiểu bỏ sót một chỗ rồi không ai biết.
370
+ if (grantable) {
371
+ allowed = allowed.filter((a) => grantable.has(`${a}:${resource.code}`))
372
+ }
373
+ // Trang không cấp được gì thì biến khỏi ma trận luôn: một dòng có mỗi cái
374
+ // tick trang bị khoá chỉ tổ gây hiểu lầm là "đang bật".
375
+ if (grantable && allowed.length === 0) return
376
+
261
377
  map.set(resource.code, {
262
378
  name: getLocalizedName(resource.name),
263
379
  icon: resource.icon,
@@ -265,7 +381,7 @@ export function RoleFormPage({
265
381
  })
266
382
  })
267
383
  return map
268
- }, [resources, actions, getLocalizedName])
384
+ }, [resources, actions, getLocalizedName, grantable])
269
385
 
270
386
  /** Action hiển thị dưới một trang (bỏ view = chính checkbox trang; bỏ lookup
271
387
  * = combo tự động). CRUD chuẩn trước, nghiệp vụ sau. */
@@ -471,9 +587,17 @@ export function RoleFormPage({
471
587
  const applyTemplate = (templateCode: string) => {
472
588
  const tpl = roleTemplates?.find((t) => t.code === templateCode)
473
589
  if (!tpl) return
474
- setFormData((prev) => ({ ...prev, permissions: [...tpl.permissions] }))
590
+ // Mẫu bộ quyền dựng sẵn cho TOÀN hệ thống — kẹp lại theo trần quyền của
591
+ // người đang tạo, không thì bấm một cái là vượt trần và API từ chối.
592
+ const applied = grantable
593
+ ? tpl.permissions.filter((p) => grantable.has(p))
594
+ : [...tpl.permissions]
595
+ setFormData((prev) => ({ ...prev, permissions: applied }))
475
596
  toast.success(`Đã áp bộ quyền mẫu "${tpl.label}"`, {
476
- description: `${tpl.permissions.length} quyền — chỉnh tiếp rồi bấm Lưu.`,
597
+ description:
598
+ applied.length === tpl.permissions.length
599
+ ? `${tpl.permissions.length} quyền — chỉnh tiếp rồi bấm Lưu.`
600
+ : `${applied.length}/${tpl.permissions.length} quyền (phần còn lại ngoài phạm vi của bạn).`,
477
601
  })
478
602
  }
479
603
 
@@ -487,7 +611,11 @@ export function RoleFormPage({
487
611
  const res = await fetch(url, {
488
612
  method,
489
613
  headers: { "Content-Type": "application/json" },
490
- body: JSON.stringify(formData),
614
+ body: JSON.stringify({
615
+ ...formData,
616
+ ...(showDataScope ? { dataScope: dataScope || null } : {}),
617
+ ...(showWorkspace ? { workspaceId: workspaceId || null } : {}),
618
+ }),
491
619
  })
492
620
  if (!res.ok) {
493
621
  await throwFetchError(res, `Failed to ${mode} role`)
@@ -847,6 +975,56 @@ export function RoleFormPage({
847
975
  </Select>
848
976
  )}
849
977
 
978
+ {/* Đơn vị sở hữu — vai trò này CỦA AI, tức ai được sửa nó. Khác hẳn
979
+ ô "Cô lập dữ liệu" ngay bên cạnh (người MANG vai trò thấy gì). */}
980
+ {showWorkspace && (
981
+ <div className="flex items-center gap-1.5">
982
+ <Network className="h-3.5 w-3.5 text-muted-foreground" />
983
+ <span className="text-xs text-muted-foreground">
984
+ Đơn vị sở hữu:
985
+ </span>
986
+ <Select
987
+ value={workspaceId || WORKSPACE_SHARED}
988
+ onValueChange={(v) =>
989
+ setWorkspaceId(v === WORKSPACE_SHARED ? "" : v)
990
+ }
991
+ >
992
+ <SelectTrigger className="h-8 w-56 rounded-md border border-border bg-card text-sm">
993
+ <SelectValue>
994
+ {workspaceId
995
+ ? (workspaceOptions?.find((w) => w.id === workspaceId)
996
+ ?.name ?? workspaceId)
997
+ : "Dùng chung (mọi đơn vị)"}
998
+ </SelectValue>
999
+ </SelectTrigger>
1000
+ <SelectContent className="rounded-lg">
1001
+ {allowSharedWorkspace !== false && (
1002
+ <SelectItem value={WORKSPACE_SHARED}>
1003
+ <span className="flex flex-col">
1004
+ <span>Dùng chung (mọi đơn vị)</span>
1005
+ <span className="text-[11px] text-muted-foreground">
1006
+ Đơn vị nào cũng gán được, chỉ cấp vận hành sửa
1007
+ </span>
1008
+ </span>
1009
+ </SelectItem>
1010
+ )}
1011
+ {workspaceOptions?.map((w) => (
1012
+ <SelectItem key={w.id} value={w.id}>
1013
+ <span
1014
+ className="flex flex-col"
1015
+ // Thụt theo cấp để nhìn ra bộ phận con của đơn vị nào —
1016
+ // danh sách phẳng thì "Kho" của hai khách hàng trông y hệt.
1017
+ style={{ paddingLeft: `${((w.depth ?? 1) - 1) * 12}px` }}
1018
+ >
1019
+ <span>{w.name}</span>
1020
+ </span>
1021
+ </SelectItem>
1022
+ ))}
1023
+ </SelectContent>
1024
+ </Select>
1025
+ </div>
1026
+ )}
1027
+
850
1028
  {branchScopeResources.length > 0 && (
851
1029
  <div className="flex items-center gap-1.5">
852
1030
  <Building2 className="h-3.5 w-3.5 text-muted-foreground" />
@@ -886,6 +1064,42 @@ export function RoleFormPage({
886
1064
  </div>
887
1065
  )}
888
1066
 
1067
+ {/* Cô lập dữ liệu — vai trò này nhìn thấy dữ liệu của những ai */}
1068
+ {showDataScope && (
1069
+ <div className="flex items-center gap-1.5">
1070
+ <ShieldCheck className="h-3.5 w-3.5 text-muted-foreground" />
1071
+ <span className="text-xs text-muted-foreground">
1072
+ Cô lập dữ liệu:
1073
+ </span>
1074
+ <Select
1075
+ value={dataScope || DATA_SCOPE_DEFAULT}
1076
+ onValueChange={(v) =>
1077
+ setDataScope(v === DATA_SCOPE_DEFAULT ? "" : v)
1078
+ }
1079
+ >
1080
+ <SelectTrigger className="h-8 w-56 rounded-md border border-border bg-card text-sm">
1081
+ <SelectValue>
1082
+ {DATA_SCOPE_OPTIONS.find(
1083
+ (o) => o.value === (dataScope || DATA_SCOPE_DEFAULT)
1084
+ )?.label ?? dataScope}
1085
+ </SelectValue>
1086
+ </SelectTrigger>
1087
+ <SelectContent className="rounded-lg">
1088
+ {DATA_SCOPE_OPTIONS.map((o) => (
1089
+ <SelectItem key={o.value} value={o.value}>
1090
+ <span className="flex flex-col">
1091
+ <span>{o.label}</span>
1092
+ <span className="text-[11px] text-muted-foreground">
1093
+ {o.hint}
1094
+ </span>
1095
+ </span>
1096
+ </SelectItem>
1097
+ ))}
1098
+ </SelectContent>
1099
+ </Select>
1100
+ </div>
1101
+ )}
1102
+
889
1103
  {/* Trang mở đầu — nơi vai trò này hạ cánh sau khi đăng nhập */}
890
1104
  <div className="flex items-center gap-1.5">
891
1105
  <House className="h-3.5 w-3.5 text-muted-foreground" />
@@ -174,11 +174,14 @@ function RolePermissionHeatmap({
174
174
  {groups.map((g) => {
175
175
  const { granted, total } = cellFor(role, g)
176
176
  const ratio = total > 0 ? granted / total : 0
177
+ // Ô nhiệt là lối tắt sang trang sửa — vai trò không sửa được
178
+ // thì đừng mời bấm rồi để trang sửa từ chối.
179
+ const editable = canUpdate && role.canManage !== false
177
180
  return (
178
181
  <td key={g} className="px-1 py-1 text-center">
179
182
  <button
180
183
  type="button"
181
- disabled={!canUpdate}
184
+ disabled={!editable}
182
185
  onClick={() =>
183
186
  router.push(`/${lang}/roles/${role.id}/edit`)
184
187
  }
@@ -186,7 +189,7 @@ function RolePermissionHeatmap({
186
189
  className={cn(
187
190
  "mx-auto block h-5 w-10 rounded",
188
191
  shade(ratio),
189
- canUpdate && "cursor-pointer hover:ring-2 hover:ring-primary/40"
192
+ editable && "cursor-pointer hover:ring-2 hover:ring-primary/40"
190
193
  )}
191
194
  aria-label={`${role.name} — ${g}: ${granted}/${total} quyền`}
192
195
  />
@@ -35,6 +35,32 @@ export type RoleServiceSchema = {
35
35
  roleTimestamps?: boolean;
36
36
  };
37
37
 
38
+ /**
39
+ * PHẠM VI vai trò — chỉ dành cho app đã bật multi-tenant (cột `Role.workspaceId`).
40
+ *
41
+ * Không truyền thì hàm chạy y như trước: mọi vai trò, mọi người dùng nhúng bên
42
+ * trong. App một-đơn-vị (vinhhoa) cứ để trống.
43
+ *
44
+ * Vai trò `workspaceId = null` là vai trò DÙNG CHUNG do cấp vận hành dựng
45
+ * ("Nhân viên", "Bếp"): quản trị viên đơn vị vẫn THẤY để biết mình đang gán gì,
46
+ * nhưng `canManage` trả `false` nên nút sửa/xoá tắt.
47
+ */
48
+ export type RoleQueryScope = {
49
+ /** Nhóm vận hành — thấy tất cả, sửa tất cả. */
50
+ canManageAll?: boolean;
51
+ /** Các workspace người này quản trị (`scope.adminIds`). */
52
+ adminWorkspaceIds?: readonly string[];
53
+ /** Có kèm vai trò dùng chung (`workspaceId = null`) không. Mặc định có. */
54
+ includeShared?: boolean;
55
+ /**
56
+ * Điều kiện kẹp danh sách NGƯỜI DÙNG nhúng trong mỗi vai trò — dạng where của
57
+ * bảng User (thường là `memberScopeWhere(scope)`). Thiếu nó thì trang vai trò
58
+ * là cửa hậu xem toàn bộ danh bạ: mỗi thẻ vai trò in kèm avatar + email của
59
+ * mọi người mang vai trò đó, kể cả đơn vị khác.
60
+ */
61
+ userWhere?: any;
62
+ };
63
+
38
64
  export type RoleData = {
39
65
  id: string;
40
66
  name: string;
@@ -54,6 +80,12 @@ export type RoleData = {
54
80
  updatedAt: string;
55
81
  createdBy?: string;
56
82
  updatedBy?: string;
83
+ /** Chỉ có ở app multi-tenant. `null` = vai trò dùng chung. */
84
+ workspaceId?: string | null;
85
+ isSystem?: boolean;
86
+ rank?: number;
87
+ /** Người đang xem có sửa/xoá được vai trò này không. `undefined` = không xét. */
88
+ canManage?: boolean;
57
89
  };
58
90
 
59
91
  // Interface for the DB client injected into the service
@@ -68,6 +100,12 @@ export interface RolePrismaClient {
68
100
  take: number;
69
101
  include: any;
70
102
  }) => Promise<any[]>;
103
+ /**
104
+ * Prisma sinh sẵn — dùng để dò cột tuỳ chọn (workspaceId/rank/isSystem).
105
+ * Kiểu `object` chứ không `Record<string, unknown>`: `RoleFieldRefs` của
106
+ * Prisma không có index signature nên app truyền thẳng `db` sẽ đỏ.
107
+ */
108
+ fields?: object;
71
109
  };
72
110
  }
73
111
 
@@ -75,6 +113,11 @@ export interface RolePrismaClient {
75
113
  // Service Logic
76
114
  // ============================================================================
77
115
 
116
+ /** App chưa có cột thì bỏ qua điều kiện tương ứng — hỏi cột không tồn tại là Prisma ném. */
117
+ const hasRoleField = (db: RolePrismaClient, field: string) =>
118
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
119
+ Boolean((db as any)?.role?.fields?.[field]);
120
+
78
121
  /**
79
122
  * Get roles data with pagination and filtering
80
123
  * Generic core implementation using injected DB client
@@ -86,6 +129,7 @@ export async function getRolesData(
86
129
  db: RolePrismaClient,
87
130
  params: RoleFilters = {},
88
131
  schema: RoleServiceSchema = {},
132
+ scope?: RoleQueryScope,
89
133
  ): Promise<{
90
134
  total: number;
91
135
  page: number;
@@ -117,6 +161,22 @@ export async function getRolesData(
117
161
  whereConditions.push({ status });
118
162
  }
119
163
 
164
+ // Kẹp phạm vi — chỉ khi app THẬT SỰ có cột `workspaceId` trên Role. Dò bằng
165
+ // `role.fields` (Prisma sinh sẵn) thay vì đoán theo tên app.
166
+ const hasWorkspaceField = hasRoleField(db, "workspaceId");
167
+ const adminIds = scope?.adminWorkspaceIds ?? [];
168
+ const scoped = Boolean(scope) && !scope?.canManageAll && hasWorkspaceField;
169
+
170
+ if (scoped) {
171
+ const branches: any[] = [];
172
+ if (adminIds.length > 0) branches.push({ workspaceId: { in: [...adminIds] } });
173
+ if (scope?.includeShared !== false) branches.push({ workspaceId: null });
174
+ // Không quản trị đơn vị nào và không lấy vai trò dùng chung ⇒ không thấy gì.
175
+ // Viết tường minh chứ không để `OR: []` — dựa vào cách Prisma diễn giải mảng
176
+ // rỗng là dựa vào chi tiết cài đặt.
177
+ whereConditions.push(branches.length > 0 ? { OR: branches } : { id: { in: [] } });
178
+ }
179
+
120
180
  const where: any = whereConditions.length > 0 ? { AND: whereConditions } : {};
121
181
 
122
182
  // Build the user select from the (possibly overridden) field names.
@@ -132,7 +192,10 @@ export async function getRolesData(
132
192
  skip: (page - 1) * pageSize,
133
193
  take: pageSize,
134
194
  include: {
135
- userRoles: { include: { user: { select: userSelect } } },
195
+ userRoles: {
196
+ ...(scope?.userWhere ? { where: { user: scope.userWhere } } : {}),
197
+ include: { user: { select: userSelect } },
198
+ },
136
199
  rolePermissions: {
137
200
  include: {
138
201
  resource: { select: { code: true, name: true, icon: true } },
@@ -143,6 +206,30 @@ export async function getRolesData(
143
206
  }),
144
207
  ]);
145
208
 
209
+ // Tên đơn vị sở hữu: MỘT câu cho cả trang, không phải `include` lồng — quan hệ
210
+ // `workspace` có thể không được khai trên `Role` ở app chỉ thêm cột thô, mà
211
+ // `include` sai tên quan hệ là Prisma ném lỗi cả trang danh sách.
212
+ const workspaceNames = new Map<string, string>();
213
+ if (hasWorkspaceField) {
214
+ const ids = [
215
+ ...new Set(items.map((r: any) => r.workspaceId).filter(Boolean)),
216
+ ] as string[];
217
+ // `db.workspace` chỉ tồn tại ở app đã chạy migration workspace. Có cột mà
218
+ // chưa có bảng là chuyện có thật giữa hai lần deploy — im lặng bỏ tên còn
219
+ // hơn cho nổ trang.
220
+ if (ids.length > 0 && (db as any).workspace?.findMany) {
221
+ try {
222
+ const rows = await (db as any).workspace.findMany({
223
+ where: { id: { in: ids } },
224
+ select: { id: true, name: true },
225
+ });
226
+ for (const w of rows) workspaceNames.set(w.id, w.name);
227
+ } catch {
228
+ /* không có bảng workspaces — bỏ qua tên, danh sách vẫn chạy */
229
+ }
230
+ }
231
+ }
232
+
146
233
  // Transform raw DB items to RoleData type
147
234
  const transformedItems = items.map((role: any) => ({
148
235
  id: role.id,
@@ -154,6 +241,8 @@ export async function getRolesData(
154
241
  permissions: role.rolePermissions.map(
155
242
  (rp: any) => `${rp.actionCode}:${rp.resourceCode}`,
156
243
  ),
244
+ // Đếm theo danh sách ĐÃ kẹp: người quản trị đơn vị thấy "3 người dùng" là 3
245
+ // người trong đơn vị mình, không phải tổng toàn hệ thống.
157
246
  usersCount: role.userRoles.length,
158
247
  users: role.userRoles.map((ur: any) => ({
159
248
  id: ur.user.id,
@@ -166,6 +255,25 @@ export async function getRolesData(
166
255
  updatedAt: hasTimestamps && role.updatedAt ? role.updatedAt.toISOString() : "",
167
256
  createdBy: role.createdBy || undefined,
168
257
  updatedBy: role.updatedBy || undefined,
258
+ ...(hasWorkspaceField
259
+ ? {
260
+ workspaceId: role.workspaceId ?? null,
261
+ workspaceName: role.workspaceId
262
+ ? (workspaceNames.get(role.workspaceId) ?? null)
263
+ : null,
264
+ isSystem: Boolean(role.isSystem),
265
+ rank: typeof role.rank === "number" ? role.rank : undefined,
266
+ // Vai trò dùng chung / vai trò hệ thống / vai trò của đơn vị khác:
267
+ // xem được, không sửa được. Cùng luật với `canManageRole` bên
268
+ // `@goerp/core/workspace` — đây chỉ là bản chiếu xuống UI.
269
+ canManage: scope
270
+ ? scope.canManageAll ||
271
+ (!role.isSystem &&
272
+ Boolean(role.workspaceId) &&
273
+ adminIds.includes(role.workspaceId))
274
+ : undefined,
275
+ }
276
+ : {}),
169
277
  }));
170
278
 
171
279
  return { total, page, pageSize, items: transformedItems };