@goplusvn/core 0.1.77 → 0.1.79

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.
@@ -42,9 +42,11 @@ import {
42
42
  ChevronDown,
43
43
  ChevronRight,
44
44
  Copy,
45
+ House,
45
46
  Info,
46
47
  LayoutTemplate,
47
48
  Save,
49
+ ShieldCheck,
48
50
  X,
49
51
  } from "lucide-react"
50
52
 
@@ -76,6 +78,49 @@ const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
76
78
  // Thứ tự hiển thị action trong một trang: CRUD chuẩn trước, nghiệp vụ sau.
77
79
  const STANDARD_ORDER = ["create", "update", "delete", "export", "import"]
78
80
 
81
+ /** Radix Select không nhận value rỗng — dùng mã riêng cho "tự suy ra". */
82
+ const LANDING_AUTO = "__auto__"
83
+
84
+ /** Cùng lý do: "" = theo mặc định của app (`subtree`). */
85
+ const DATA_SCOPE_DEFAULT = "__default__"
86
+
87
+ /**
88
+ * Thang 5 nấc của `@goerp/core/workspace` (Δ1). Chữ ở đây cố ý nói theo NGƯỜI
89
+ * DÙNG chứ không theo tên kỹ thuật: người tick quyền không biết "subtree" là gì.
90
+ */
91
+ const DATA_SCOPE_OPTIONS: { value: string; label: string; hint: string }[] = [
92
+ {
93
+ value: DATA_SCOPE_DEFAULT,
94
+ label: "Theo mặc định",
95
+ hint: "Workspace được gán + cấp dưới",
96
+ },
97
+ {
98
+ value: "all",
99
+ label: "Tất cả dữ liệu",
100
+ hint: "Không cô lập — thấy mọi workspace",
101
+ },
102
+ {
103
+ value: "subtree",
104
+ label: "Workspace + cấp dưới",
105
+ hint: "Đơn vị được gán và các bộ phận con",
106
+ },
107
+ {
108
+ value: "workspace",
109
+ label: "Chỉ workspace được gán",
110
+ hint: "Không thấy dữ liệu của bộ phận con",
111
+ },
112
+ {
113
+ value: "own",
114
+ label: "Chỉ bản ghi của mình",
115
+ hint: "Cần app khai cột chủ sở hữu; thiếu thì không thấy gì",
116
+ },
117
+ {
118
+ value: "none",
119
+ label: "Không thấy dữ liệu",
120
+ hint: "Vai trò chỉ thao tác, không đọc danh sách",
121
+ },
122
+ ]
123
+
79
124
  /** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
80
125
  export interface RoleTemplate {
81
126
  code: string
@@ -136,6 +181,10 @@ export interface RoleFormPageProps {
136
181
  description: string | undefined
137
182
  status: string
138
183
  permissions: string[]
184
+ /** Trang mở đầu của vai trò. Null/undefined = tự suy ra. */
185
+ landingPath?: string | null
186
+ /** Nấc cô lập dữ liệu. Null = mặc định của app (`subtree`). */
187
+ dataScope?: string | null
139
188
  }
140
189
  actionLabels?: Record<string, ActionLabelConfig>
141
190
  roleTemplates?: RoleTemplate[]
@@ -145,6 +194,24 @@ export interface RoleFormPageProps {
145
194
  menuTree?: MenuTreeSection[]
146
195
  /** Endpoint nhật ký đổi quyền (edit mode) — vd /api/roles/{id}/history. */
147
196
  historyEndpoint?: string
197
+ /**
198
+ * TRẦN QUYỀN của người đang tạo/sửa vai trò, dạng `"action:resource"`.
199
+ *
200
+ * 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
201
+ * trận chỉ hiện thứ họ tự có — cùng luật D2 mà API đang chặn, để người dùng
202
+ * không tick đủ thứ rồi mới ăn 403 lúc bấm lưu.
203
+ *
204
+ * 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ẽ
205
+ * từ chối lưu — thà chặn còn hơn âm thầm tước quyền của vai trò.
206
+ */
207
+ grantablePermissions?: string[]
208
+ /**
209
+ * Hiện ô CÔ LẬP DỮ LIỆU (`Role.dataScope`). Chỉ bật cho vai vận hành: quản
210
+ * 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
211
+ * 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
212
+ * 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ị.
213
+ */
214
+ showDataScope?: boolean
148
215
  }
149
216
 
150
217
  export function RoleFormPage({
@@ -161,9 +228,16 @@ export function RoleFormPage({
161
228
  resourcePages,
162
229
  menuTree,
163
230
  historyEndpoint,
231
+ grantablePermissions,
232
+ showDataScope,
164
233
  }: RoleFormPageProps) {
165
234
  const router = useRouter()
166
235
 
236
+ const grantable = React.useMemo(
237
+ () => (grantablePermissions ? new Set(grantablePermissions) : null),
238
+ [grantablePermissions]
239
+ )
240
+
167
241
  const mergedActionLabels = React.useMemo(
168
242
  () => ({ ...DEFAULT_ACTION_LABELS, ...actionLabels }),
169
243
  [actionLabels]
@@ -189,8 +263,16 @@ export function RoleFormPage({
189
263
  description: initialData?.description || "",
190
264
  status: (initialData?.status || "active") as "active" | "inactive",
191
265
  permissions: initialData?.permissions || ([] as string[]),
266
+ // "" = tự suy ra (mục đầu tiên trên menu mà vai trò này mở được).
267
+ landingPath: initialData?.landingPath || "",
192
268
  })
193
269
 
270
+ // Tách khỏi `formData` vì payload gửi thẳng cả object: vai trò không được
271
+ // 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.
272
+ const [dataScope, setDataScope] = React.useState<string>(
273
+ initialData?.dataScope ?? ""
274
+ )
275
+
194
276
  const getLocalizedName = React.useCallback(
195
277
  (key: string | null) => {
196
278
  if (!key) return ""
@@ -250,6 +332,17 @@ export function RoleFormPage({
250
332
  } catch {
251
333
  allowed = actions.map((a) => a.code).filter((c) => fallback.includes(c))
252
334
  }
335
+ // TRẦN QUYỀN (D2): người tạo vai trò không cấp được thứ chính mình không
336
+ // có. Cắt ngay ở `allowed` nên mọi thứ dựng trên nó — hàng thao tác, tick
337
+ // trang, mẫu vai trò, phạm vi chi nhánh — tự động tuân theo; lọc riêng ở
338
+ // từng chỗ vẽ là kiểu bỏ sót một chỗ rồi không ai biết.
339
+ if (grantable) {
340
+ allowed = allowed.filter((a) => grantable.has(`${a}:${resource.code}`))
341
+ }
342
+ // 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
343
+ // tick trang bị khoá chỉ tổ gây hiểu lầm là "đang bật".
344
+ if (grantable && allowed.length === 0) return
345
+
253
346
  map.set(resource.code, {
254
347
  name: getLocalizedName(resource.name),
255
348
  icon: resource.icon,
@@ -257,7 +350,7 @@ export function RoleFormPage({
257
350
  })
258
351
  })
259
352
  return map
260
- }, [resources, actions, getLocalizedName])
353
+ }, [resources, actions, getLocalizedName, grantable])
261
354
 
262
355
  /** Action hiển thị dưới một trang (bỏ view = chính checkbox trang; bỏ lookup
263
356
  * = combo tự động). CRUD chuẩn trước, nghiệp vụ sau. */
@@ -436,12 +529,44 @@ export function RoleFormPage({
436
529
  })
437
530
  }
438
531
 
532
+ // ── Trang mở đầu của vai trò ──────────────────────────────────────────
533
+ // Chỉ cho chọn trang mà vai trò này THẬT SỰ mở được: đặt trang chưa cấp
534
+ // quyền làm nơi hạ cánh là đá người ta vào tường ngay sau khi đăng nhập.
535
+ const landingOptions = React.useMemo(() => {
536
+ const byHref = new Map<string, { href: string; label: string }>()
537
+ tree.forEach((section) =>
538
+ section.items.forEach((i) => {
539
+ if (!i.href || i.note || !has(i.resource, "view")) return
540
+ // Hai mục cùng href (vd trang chi tiết gắn 2 chỗ) — giữ mục đầu.
541
+ if (!byHref.has(i.href))
542
+ byHref.set(i.href, {
543
+ href: i.href,
544
+ label: `${section.title} · ${i.title}`,
545
+ })
546
+ })
547
+ )
548
+ return Array.from(byHref.values())
549
+ }, [tree, has])
550
+
551
+ /** Đã cấu hình nhưng quyền bị gỡ sau đó — vẫn hiện để admin thấy mà sửa. */
552
+ const landingStale =
553
+ formData.landingPath !== "" &&
554
+ !landingOptions.some((o) => o.href === formData.landingPath)
555
+
439
556
  const applyTemplate = (templateCode: string) => {
440
557
  const tpl = roleTemplates?.find((t) => t.code === templateCode)
441
558
  if (!tpl) return
442
- setFormData((prev) => ({ ...prev, permissions: [...tpl.permissions] }))
559
+ // 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
560
+ // người đang tạo, không thì bấm một cái là vượt trần và API từ chối.
561
+ const applied = grantable
562
+ ? tpl.permissions.filter((p) => grantable.has(p))
563
+ : [...tpl.permissions]
564
+ setFormData((prev) => ({ ...prev, permissions: applied }))
443
565
  toast.success(`Đã áp bộ quyền mẫu "${tpl.label}"`, {
444
- description: `${tpl.permissions.length} quyền — chỉnh tiếp rồi bấm Lưu.`,
566
+ description:
567
+ applied.length === tpl.permissions.length
568
+ ? `${tpl.permissions.length} quyền — chỉnh tiếp rồi bấm Lưu.`
569
+ : `${applied.length}/${tpl.permissions.length} quyền (phần còn lại ngoài phạm vi của bạn).`,
445
570
  })
446
571
  }
447
572
 
@@ -455,7 +580,9 @@ export function RoleFormPage({
455
580
  const res = await fetch(url, {
456
581
  method,
457
582
  headers: { "Content-Type": "application/json" },
458
- body: JSON.stringify(formData),
583
+ body: JSON.stringify(
584
+ showDataScope ? { ...formData, dataScope: dataScope || null } : formData
585
+ ),
459
586
  })
460
587
  if (!res.ok) {
461
588
  await throwFetchError(res, `Failed to ${mode} role`)
@@ -854,6 +981,109 @@ export function RoleFormPage({
854
981
  </div>
855
982
  )}
856
983
 
984
+ {/* Cô lập dữ liệu — vai trò này nhìn thấy dữ liệu của những ai */}
985
+ {showDataScope && (
986
+ <div className="flex items-center gap-1.5">
987
+ <ShieldCheck className="h-3.5 w-3.5 text-muted-foreground" />
988
+ <span className="text-xs text-muted-foreground">
989
+ Cô lập dữ liệu:
990
+ </span>
991
+ <Select
992
+ value={dataScope || DATA_SCOPE_DEFAULT}
993
+ onValueChange={(v) =>
994
+ setDataScope(v === DATA_SCOPE_DEFAULT ? "" : v)
995
+ }
996
+ >
997
+ <SelectTrigger className="h-8 w-56 rounded-md border border-border bg-card text-sm">
998
+ <SelectValue>
999
+ {DATA_SCOPE_OPTIONS.find(
1000
+ (o) => o.value === (dataScope || DATA_SCOPE_DEFAULT)
1001
+ )?.label ?? dataScope}
1002
+ </SelectValue>
1003
+ </SelectTrigger>
1004
+ <SelectContent className="rounded-lg">
1005
+ {DATA_SCOPE_OPTIONS.map((o) => (
1006
+ <SelectItem key={o.value} value={o.value}>
1007
+ <span className="flex flex-col">
1008
+ <span>{o.label}</span>
1009
+ <span className="text-[11px] text-muted-foreground">
1010
+ {o.hint}
1011
+ </span>
1012
+ </span>
1013
+ </SelectItem>
1014
+ ))}
1015
+ </SelectContent>
1016
+ </Select>
1017
+ </div>
1018
+ )}
1019
+
1020
+ {/* Trang mở đầu — nơi vai trò này hạ cánh sau khi đăng nhập */}
1021
+ <div className="flex items-center gap-1.5">
1022
+ <House className="h-3.5 w-3.5 text-muted-foreground" />
1023
+ <span className="text-xs text-muted-foreground">
1024
+ Trang mở đầu:
1025
+ </span>
1026
+ <Select
1027
+ value={formData.landingPath || LANDING_AUTO}
1028
+ onValueChange={(v) =>
1029
+ setFormData({
1030
+ ...formData,
1031
+ landingPath: v === LANDING_AUTO ? "" : v,
1032
+ })
1033
+ }
1034
+ >
1035
+ <SelectTrigger
1036
+ className={cn(
1037
+ "h-8 w-52 rounded-md border border-border bg-card text-sm",
1038
+ landingStale && "border-amber-500 text-amber-700 dark:text-amber-400"
1039
+ )}
1040
+ >
1041
+ <SelectValue>
1042
+ {!formData.landingPath
1043
+ ? "Tự động"
1044
+ : landingStale
1045
+ ? `${formData.landingPath} · chưa cấp quyền`
1046
+ : landingOptions.find(
1047
+ (o) => o.href === formData.landingPath
1048
+ )?.label}
1049
+ </SelectValue>
1050
+ </SelectTrigger>
1051
+ <SelectContent className="rounded-lg">
1052
+ <SelectItem value={LANDING_AUTO}>
1053
+ <span className="flex flex-col">
1054
+ <span>Tự động</span>
1055
+ <span className="text-[11px] text-muted-foreground">
1056
+ Menu đầu tiên vai trò này mở được
1057
+ </span>
1058
+ </span>
1059
+ </SelectItem>
1060
+ {/* Cấu hình cũ trỏ tới trang vừa bị gỡ quyền — giữ lại trong
1061
+ danh sách, nếu không nó biến mất im lặng và admin tưởng
1062
+ mình chưa từng đặt. */}
1063
+ {landingStale && (
1064
+ <SelectItem value={formData.landingPath}>
1065
+ <span className="flex flex-col">
1066
+ <span>{formData.landingPath}</span>
1067
+ <span className="text-[11px] text-amber-600 dark:text-amber-400">
1068
+ Chưa cấp quyền vào trang này
1069
+ </span>
1070
+ </span>
1071
+ </SelectItem>
1072
+ )}
1073
+ {landingOptions.map((o) => (
1074
+ <SelectItem key={o.href} value={o.href}>
1075
+ <span className="flex flex-col">
1076
+ <span>{o.label}</span>
1077
+ <span className="text-[11px] text-muted-foreground">
1078
+ {o.href}
1079
+ </span>
1080
+ </span>
1081
+ </SelectItem>
1082
+ ))}
1083
+ </SelectContent>
1084
+ </Select>
1085
+ </div>
1086
+
857
1087
  <p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
858
1088
  <span className="font-semibold tabular-nums text-foreground">
859
1089
  {formData.permissions.length}
@@ -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 } },
@@ -154,6 +217,8 @@ export async function getRolesData(
154
217
  permissions: role.rolePermissions.map(
155
218
  (rp: any) => `${rp.actionCode}:${rp.resourceCode}`,
156
219
  ),
220
+ // Đếm theo danh sách ĐÃ kẹp: người quản trị đơn vị thấy "3 người dùng" là 3
221
+ // người trong đơn vị mình, không phải tổng toàn hệ thống.
157
222
  usersCount: role.userRoles.length,
158
223
  users: role.userRoles.map((ur: any) => ({
159
224
  id: ur.user.id,
@@ -166,6 +231,22 @@ export async function getRolesData(
166
231
  updatedAt: hasTimestamps && role.updatedAt ? role.updatedAt.toISOString() : "",
167
232
  createdBy: role.createdBy || undefined,
168
233
  updatedBy: role.updatedBy || undefined,
234
+ ...(hasWorkspaceField
235
+ ? {
236
+ workspaceId: role.workspaceId ?? null,
237
+ isSystem: Boolean(role.isSystem),
238
+ rank: typeof role.rank === "number" ? role.rank : undefined,
239
+ // Vai trò dùng chung / vai trò hệ thống / vai trò của đơn vị khác:
240
+ // xem được, không sửa được. Cùng luật với `canManageRole` bên
241
+ // `@goerp/core/workspace` — đây chỉ là bản chiếu xuống UI.
242
+ canManage: scope
243
+ ? scope.canManageAll ||
244
+ (!role.isSystem &&
245
+ Boolean(role.workspaceId) &&
246
+ adminIds.includes(role.workspaceId))
247
+ : undefined,
248
+ }
249
+ : {}),
169
250
  }));
170
251
 
171
252
  return { total, page, pageSize, items: transformedItems };