@goplusvn/core 0.1.36 → 0.1.38
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
CHANGED
|
@@ -115,9 +115,14 @@ interface Permissions {
|
|
|
115
115
|
|
|
116
116
|
export interface ActionListPageProps {
|
|
117
117
|
permissions?: Permissions
|
|
118
|
+
/** Catalog hành động do Permission Registry trong code quản lý — ẩn mọi nút ghi. */
|
|
119
|
+
readOnly?: boolean
|
|
118
120
|
}
|
|
119
121
|
|
|
120
|
-
export function ActionListPage({
|
|
122
|
+
export function ActionListPage({
|
|
123
|
+
permissions = {},
|
|
124
|
+
readOnly = false,
|
|
125
|
+
}: ActionListPageProps) {
|
|
121
126
|
const [loading, setLoading] = useState(true)
|
|
122
127
|
const [actions, setActions] = useState<Action[]>([])
|
|
123
128
|
const [usage, setUsage] = useState<UsageMap>({})
|
|
@@ -140,9 +145,9 @@ export function ActionListPage({ permissions = {} }: ActionListPageProps) {
|
|
|
140
145
|
const [toDelete, setToDelete] = useState<Action | null>(null)
|
|
141
146
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
142
147
|
|
|
143
|
-
const canCreate = !!permissions.create
|
|
144
|
-
const canUpdate = !!permissions.update
|
|
145
|
-
const canDelete = !!permissions.delete
|
|
148
|
+
const canCreate = !readOnly && !!permissions.create
|
|
149
|
+
const canUpdate = !readOnly && !!permissions.update
|
|
150
|
+
const canDelete = !readOnly && !!permissions.delete
|
|
146
151
|
|
|
147
152
|
const fetchData = async () => {
|
|
148
153
|
try {
|
|
@@ -212,7 +212,20 @@ const normalize = (s: string) =>
|
|
|
212
212
|
.replace(/[\u0300-\u036f]/g, "")
|
|
213
213
|
.replace(/đ/g, "d")
|
|
214
214
|
|
|
215
|
-
export
|
|
215
|
+
export interface ResourceListPageProps {
|
|
216
|
+
/** Danh mục do Permission Registry trong code quản lý: ẩn nút Thêm/Sync/
|
|
217
|
+
* Action-manager, thao tác sửa/xóa hiện toast hướng dẫn thay vì mở dialog. */
|
|
218
|
+
readOnly?: boolean
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {}) {
|
|
222
|
+
const registryToast = () =>
|
|
223
|
+
toast.info("Danh mục do Permission Registry quản lý", {
|
|
224
|
+
description:
|
|
225
|
+
"Sửa file <feature>.permissions.ts trong code rồi chạy rbac-sync — không chỉnh tay ở đây.",
|
|
226
|
+
})
|
|
227
|
+
const guardWrite = <T extends (...args: any[]) => any>(fn: T): T =>
|
|
228
|
+
(readOnly ? ((() => registryToast()) as unknown as T) : fn)
|
|
216
229
|
const [loading, setLoading] = useState(true)
|
|
217
230
|
const [resources, setResources] = useState<Resource[]>([])
|
|
218
231
|
const [allActions, setAllActions] = useState<Action[]>([])
|
|
@@ -707,37 +720,57 @@ export function ResourceListPage() {
|
|
|
707
720
|
className={cn("h-4 w-4", isRefreshing && "animate-spin")}
|
|
708
721
|
/>
|
|
709
722
|
</Button>
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
723
|
+
{!readOnly && (
|
|
724
|
+
<>
|
|
725
|
+
<Button
|
|
726
|
+
variant="ghost"
|
|
727
|
+
size="icon"
|
|
728
|
+
onClick={handleSync}
|
|
729
|
+
disabled={isSyncing}
|
|
730
|
+
title="Đồng bộ tài nguyên"
|
|
731
|
+
className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
|
|
732
|
+
>
|
|
733
|
+
<DatabaseBackup
|
|
734
|
+
className={cn("h-4 w-4", isSyncing && "animate-pulse")}
|
|
735
|
+
/>
|
|
736
|
+
</Button>
|
|
737
|
+
<Button
|
|
738
|
+
variant="ghost"
|
|
739
|
+
size="icon"
|
|
740
|
+
onClick={openActionManager}
|
|
741
|
+
title="Quản lý hành động"
|
|
742
|
+
className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
|
|
743
|
+
>
|
|
744
|
+
<Zap className="h-4 w-4" />
|
|
745
|
+
</Button>
|
|
746
|
+
<Button
|
|
747
|
+
onClick={openNewResource}
|
|
748
|
+
className="h-8 shrink-0 bg-primary-foreground px-2.5 text-primary hover:bg-primary-foreground/90 sm:px-3"
|
|
749
|
+
>
|
|
750
|
+
<Plus className="h-4 w-4" />
|
|
751
|
+
<span className="ml-1.5 hidden sm:inline">Thêm tài nguyên</span>
|
|
752
|
+
<span className="ml-1.5 sm:hidden">Thêm</span>
|
|
753
|
+
</Button>
|
|
754
|
+
</>
|
|
755
|
+
)}
|
|
739
756
|
</div>
|
|
740
757
|
|
|
758
|
+
{readOnly && (
|
|
759
|
+
<div className="mt-3 flex items-start gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground lg:mx-3">
|
|
760
|
+
<Shield className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
761
|
+
<span>
|
|
762
|
+
Danh mục tài nguyên & hành động do{" "}
|
|
763
|
+
<span className="font-medium text-foreground">
|
|
764
|
+
Permission Registry
|
|
765
|
+
</span>{" "}
|
|
766
|
+
trong code quản lý (file{" "}
|
|
767
|
+
<code className="font-mono"><feature>.permissions.ts</code> +{" "}
|
|
768
|
+
<code className="font-mono">rbac-sync</code>). Trang này chỉ để tra
|
|
769
|
+
cứu; cấp quyền cho vai trò làm ở trang Vai trò.
|
|
770
|
+
</span>
|
|
771
|
+
</div>
|
|
772
|
+
)}
|
|
773
|
+
|
|
741
774
|
{/* ===== Khối trắng liền: toolbar sticky + nhóm + hàng tài nguyên ===== */}
|
|
742
775
|
<div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
|
|
743
776
|
{/* Toolbar sticky */}
|
|
@@ -868,9 +901,9 @@ export function ResourceListPage() {
|
|
|
868
901
|
categorized={categorizedActions}
|
|
869
902
|
expanded={expandedRows.has(resource.id)}
|
|
870
903
|
onToggleExpand={() => toggleRow(resource.id)}
|
|
871
|
-
onToggleAction={toggleAction}
|
|
872
|
-
onUpdateActions={handleUpdateActions}
|
|
873
|
-
onEditResource={openEditResource}
|
|
904
|
+
onToggleAction={guardWrite(toggleAction)}
|
|
905
|
+
onUpdateActions={guardWrite(handleUpdateActions)}
|
|
906
|
+
onEditResource={guardWrite(openEditResource)}
|
|
874
907
|
/>
|
|
875
908
|
))}
|
|
876
909
|
</ul>
|
|
@@ -886,10 +919,10 @@ export function ResourceListPage() {
|
|
|
886
919
|
categorized={categorizedActions}
|
|
887
920
|
expanded={expandedRows.has(resource.id)}
|
|
888
921
|
onToggleExpand={() => toggleRow(resource.id)}
|
|
889
|
-
onToggleAction={toggleAction}
|
|
890
|
-
onUpdateActions={handleUpdateActions}
|
|
891
|
-
onEditResource={openEditResource}
|
|
892
|
-
onDeleteResource={requestDeleteResource}
|
|
922
|
+
onToggleAction={guardWrite(toggleAction)}
|
|
923
|
+
onUpdateActions={guardWrite(handleUpdateActions)}
|
|
924
|
+
onEditResource={guardWrite(openEditResource)}
|
|
925
|
+
onDeleteResource={guardWrite(requestDeleteResource)}
|
|
893
926
|
/>
|
|
894
927
|
))}
|
|
895
928
|
</div>
|
|
@@ -2,15 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
// Trang tạo/sửa VAI TRÒ — bar navy 1 DÒNG kiểu chi tiết đơn bán hàng
|
|
4
4
|
// (← tên inline · chip mã + copy · CHIP TRẠNG THÁI màu bấm-đổi · mô tả ·
|
|
5
|
-
// Hủy/Lưu), full width
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// Hủy/Lưu), full width.
|
|
6
|
+
//
|
|
7
|
+
// Ma trận quyền (redesign 2026-07 — "Phase 3" chuẩn ERP):
|
|
8
|
+
// - TAB theo phân hệ (group) thay dropdown — cuộn ngang, đếm x/y realtime.
|
|
9
|
+
// - Mỗi phân hệ là BẢNG cột chuẩn: Xem/Tạo/Sửa/Xóa/Xuất/Nhập/Tra cứu
|
|
10
|
+
// (checkbox; "·" khi resource không khai action đó) + cột "Đặc thù" chứa
|
|
11
|
+
// chip action nghiệp vụ (duyệt, sửa giá, đồng bộ MISA…) + switch "Tất cả".
|
|
12
|
+
// - Bấm TIÊU ĐỀ CỘT để tick/bỏ cả cột trong phân hệ.
|
|
13
|
+
// - `roleTemplates` (tùy chọn, additive): bộ quyền mẫu khi TẠO role mới.
|
|
14
|
+
// API contract giữ nguyên: POST /api/roles, PUT /api/roles/:id, quyền dạng
|
|
15
|
+
// `${action}:${resource}`.
|
|
9
16
|
import * as React from "react"
|
|
10
17
|
import { useRouter } from "next/navigation"
|
|
11
18
|
import {
|
|
12
19
|
Badge,
|
|
13
20
|
Button,
|
|
21
|
+
Checkbox,
|
|
14
22
|
DynamicIcon,
|
|
15
23
|
Input,
|
|
16
24
|
Select,
|
|
@@ -28,11 +36,10 @@ import {
|
|
|
28
36
|
Ban,
|
|
29
37
|
Bot,
|
|
30
38
|
Check,
|
|
31
|
-
ChevronDown,
|
|
32
|
-
ChevronRight,
|
|
33
39
|
Copy,
|
|
34
40
|
Download,
|
|
35
41
|
Eye,
|
|
42
|
+
LayoutTemplate,
|
|
36
43
|
PenLine,
|
|
37
44
|
Pencil,
|
|
38
45
|
Plus,
|
|
@@ -73,8 +80,21 @@ const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
|
|
|
73
80
|
delete_items: { label: "Xóa chi tiết", icon: "❌", color: "text-rose-600" },
|
|
74
81
|
}
|
|
75
82
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
83
|
+
// Cột chuẩn của bảng quyền — thứ tự cố định, nhãn ngắn. Action ngoài danh
|
|
84
|
+
// sách này rơi vào cột "Đặc thù".
|
|
85
|
+
const STANDARD_COLUMNS: Array<{ code: string; label: string }> = [
|
|
86
|
+
{ code: "view", label: "Xem" },
|
|
87
|
+
{ code: "create", label: "Tạo" },
|
|
88
|
+
{ code: "update", label: "Sửa" },
|
|
89
|
+
{ code: "delete", label: "Xóa" },
|
|
90
|
+
{ code: "export", label: "Xuất" },
|
|
91
|
+
{ code: "import", label: "Nhập" },
|
|
92
|
+
{ code: "lookup", label: "Tra cứu" },
|
|
93
|
+
]
|
|
94
|
+
const STANDARD_CODES = new Set(STANDARD_COLUMNS.map((c) => c.code))
|
|
95
|
+
|
|
96
|
+
// --- Icon + màu theo LOẠI action (suy từ mã) — mọi action đặc thù đều có
|
|
97
|
+
// icon mà không cần cấu hình từng cái. ---
|
|
78
98
|
const ACTION_VISUAL_RULES: Array<{
|
|
79
99
|
re: RegExp
|
|
80
100
|
icon: LucideIcon
|
|
@@ -86,7 +106,7 @@ const ACTION_VISUAL_RULES: Array<{
|
|
|
86
106
|
{ re: /^(delete|remove)/, icon: Trash2, cls: "text-rose-500" },
|
|
87
107
|
{ re: /^(cancel|void|reject)/, icon: Ban, cls: "text-rose-500" },
|
|
88
108
|
{
|
|
89
|
-
re: /^(approve|confirm|verify|accept)/,
|
|
109
|
+
re: /^(approve|confirm|verify|accept|complete)/,
|
|
90
110
|
icon: BadgeCheck,
|
|
91
111
|
cls: "text-violet-500",
|
|
92
112
|
},
|
|
@@ -94,8 +114,8 @@ const ACTION_VISUAL_RULES: Array<{
|
|
|
94
114
|
{ re: /^import/, icon: Download, cls: "text-purple-500" },
|
|
95
115
|
{ re: /^export/, icon: Upload, cls: "text-indigo-500" },
|
|
96
116
|
{ re: /^print/, icon: Printer, cls: "text-cyan-600" },
|
|
97
|
-
{ re: /^sync/, icon: RefreshCw, cls: "text-cyan-600" },
|
|
98
|
-
{ re: /^(manage|config)/, icon: Settings2, cls: "text-orange-500" },
|
|
117
|
+
{ re: /^(sync|unlink)/, icon: RefreshCw, cls: "text-cyan-600" },
|
|
118
|
+
{ re: /^(manage|config|close|reopen)/, icon: Settings2, cls: "text-orange-500" },
|
|
99
119
|
{ re: /^auto/, icon: Bot, cls: "text-purple-500" },
|
|
100
120
|
]
|
|
101
121
|
|
|
@@ -105,6 +125,15 @@ const getActionVisual = (code: string) =>
|
|
|
105
125
|
cls: "text-slate-400",
|
|
106
126
|
}
|
|
107
127
|
|
|
128
|
+
/** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa, vd từ
|
|
129
|
+
* Permission Registry của app). permissions dạng `${action}:${resource}`. */
|
|
130
|
+
export interface RoleTemplate {
|
|
131
|
+
code: string
|
|
132
|
+
label: string
|
|
133
|
+
description?: string
|
|
134
|
+
permissions: string[]
|
|
135
|
+
}
|
|
136
|
+
|
|
108
137
|
export interface RoleFormPageProps {
|
|
109
138
|
dictionary: any
|
|
110
139
|
permissions?: CrudPermissions
|
|
@@ -136,6 +165,8 @@ export interface RoleFormPageProps {
|
|
|
136
165
|
permissions: string[]
|
|
137
166
|
}
|
|
138
167
|
actionLabels?: Record<string, ActionLabelConfig>
|
|
168
|
+
/** Bộ quyền mẫu hiện ở chế độ tạo mới (additive — không truyền thì ẩn). */
|
|
169
|
+
roleTemplates?: RoleTemplate[]
|
|
139
170
|
}
|
|
140
171
|
|
|
141
172
|
export function RoleFormPage({
|
|
@@ -147,6 +178,7 @@ export function RoleFormPage({
|
|
|
147
178
|
mode,
|
|
148
179
|
initialData,
|
|
149
180
|
actionLabels,
|
|
181
|
+
roleTemplates,
|
|
150
182
|
}: RoleFormPageProps) {
|
|
151
183
|
const router = useRouter()
|
|
152
184
|
|
|
@@ -166,17 +198,7 @@ export function RoleFormPage({
|
|
|
166
198
|
permissions: initialData?.permissions || ([] as string[]),
|
|
167
199
|
})
|
|
168
200
|
|
|
169
|
-
const [activeGroup, setActiveGroup] = React.useState<string
|
|
170
|
-
const [collapsedGroups, setCollapsedGroups] = React.useState<
|
|
171
|
-
Record<string, boolean>
|
|
172
|
-
>({})
|
|
173
|
-
|
|
174
|
-
const toggleGroupCollapse = (groupName: string) => {
|
|
175
|
-
setCollapsedGroups((prev) => ({
|
|
176
|
-
...prev,
|
|
177
|
-
[groupName]: !prev[groupName],
|
|
178
|
-
}))
|
|
179
|
-
}
|
|
201
|
+
const [activeGroup, setActiveGroup] = React.useState<string>("all")
|
|
180
202
|
|
|
181
203
|
const getLocalizedName = React.useCallback(
|
|
182
204
|
(key: string | null) => {
|
|
@@ -197,6 +219,14 @@ export function RoleFormPage({
|
|
|
197
219
|
[dictionary]
|
|
198
220
|
)
|
|
199
221
|
|
|
222
|
+
const actionName = React.useCallback(
|
|
223
|
+
(code: string) => {
|
|
224
|
+
const fromDb = actions.find((a) => a.code === code)?.name
|
|
225
|
+
return fromDb || mergedActionLabels[code]?.label || code
|
|
226
|
+
},
|
|
227
|
+
[actions, mergedActionLabels]
|
|
228
|
+
)
|
|
229
|
+
|
|
200
230
|
const permissionMatrix = React.useMemo(() => {
|
|
201
231
|
return resources.map((resource) => {
|
|
202
232
|
let allowedActions: string[] = []
|
|
@@ -243,6 +273,7 @@ export function RoleFormPage({
|
|
|
243
273
|
group: getLocalizedName(resource.group) || "Khác",
|
|
244
274
|
description: resource.description,
|
|
245
275
|
actions: allowedActions,
|
|
276
|
+
extraActions: allowedActions.filter((a) => !STANDARD_CODES.has(a)),
|
|
246
277
|
}
|
|
247
278
|
})
|
|
248
279
|
}, [resources, actions, getLocalizedName])
|
|
@@ -276,19 +307,23 @@ export function RoleFormPage({
|
|
|
276
307
|
return groups
|
|
277
308
|
}, [filteredMatrix])
|
|
278
309
|
|
|
310
|
+
// Tab dựng theo TOÀN BỘ matrix (không theo search) để thanh tab ổn định.
|
|
311
|
+
const allGroups = React.useMemo(() => {
|
|
312
|
+
const seen: string[] = []
|
|
313
|
+
permissionMatrix.forEach((r) => {
|
|
314
|
+
if (!seen.includes(r.group)) seen.push(r.group)
|
|
315
|
+
})
|
|
316
|
+
return seen
|
|
317
|
+
}, [permissionMatrix])
|
|
318
|
+
|
|
279
319
|
React.useEffect(() => {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (
|
|
283
|
-
activeGroup !== "all" &&
|
|
284
|
-
(!activeGroup || !groups.includes(activeGroup))
|
|
285
|
-
) {
|
|
286
|
-
setActiveGroup("all")
|
|
287
|
-
}
|
|
288
|
-
} else {
|
|
289
|
-
setActiveGroup(null)
|
|
320
|
+
if (activeGroup !== "all" && !allGroups.includes(activeGroup)) {
|
|
321
|
+
setActiveGroup("all")
|
|
290
322
|
}
|
|
291
|
-
}, [
|
|
323
|
+
}, [allGroups, activeGroup])
|
|
324
|
+
|
|
325
|
+
// Đang search → hiện mọi nhóm khớp, bỏ qua tab đang chọn.
|
|
326
|
+
const searching = searchTerm.trim().length > 0
|
|
292
327
|
|
|
293
328
|
const hasPermission = (resource: string, action: string): boolean => {
|
|
294
329
|
return formData.permissions.includes(`${action}:${resource}`)
|
|
@@ -310,12 +345,10 @@ export function RoleFormPage({
|
|
|
310
345
|
(groupName: string) => {
|
|
311
346
|
const groupResources = groupedMatrix[groupName] || []
|
|
312
347
|
return groupResources.flatMap((r) =>
|
|
313
|
-
actions
|
|
314
|
-
.filter((a) => r.actions.includes(a.code))
|
|
315
|
-
.map((a) => `${a.code}:${r.resource}`)
|
|
348
|
+
r.actions.map((a) => `${a}:${r.resource}`)
|
|
316
349
|
)
|
|
317
350
|
},
|
|
318
|
-
[groupedMatrix
|
|
351
|
+
[groupedMatrix]
|
|
319
352
|
)
|
|
320
353
|
|
|
321
354
|
const toggleAllForGroup = (groupName: string, enable: boolean) => {
|
|
@@ -334,12 +367,57 @@ export function RoleFormPage({
|
|
|
334
367
|
setFormData({ ...formData, permissions: newPermissions })
|
|
335
368
|
}
|
|
336
369
|
|
|
337
|
-
const groupSelectedCount = (
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
370
|
+
const groupSelectedCount = React.useCallback(
|
|
371
|
+
(groupName: string) => {
|
|
372
|
+
const codes = groupPermissionCodes(groupName)
|
|
373
|
+
return {
|
|
374
|
+
selected: codes.filter((p) => formData.permissions.includes(p)).length,
|
|
375
|
+
total: codes.length,
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
[groupPermissionCodes, formData.permissions]
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
// Đếm theo nhóm cho TAB (không phụ thuộc search — dùng permissionMatrix).
|
|
382
|
+
const groupTabCount = React.useCallback(
|
|
383
|
+
(groupName: string) => {
|
|
384
|
+
const rs = permissionMatrix.filter((r) => r.group === groupName)
|
|
385
|
+
const codes = rs.flatMap((r) => r.actions.map((a) => `${a}:${r.resource}`))
|
|
386
|
+
return {
|
|
387
|
+
selected: codes.filter((p) => formData.permissions.includes(p)).length,
|
|
388
|
+
total: codes.length,
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
[permissionMatrix, formData.permissions]
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
// Tick/bỏ nguyên MỘT CỘT action trong một nhóm (chỉ resource có khai action).
|
|
395
|
+
const columnState = (groupName: string, actionCode: string) => {
|
|
396
|
+
const rows = (groupedMatrix[groupName] || []).filter((r) =>
|
|
397
|
+
r.actions.includes(actionCode)
|
|
398
|
+
)
|
|
399
|
+
if (rows.length === 0) return { applicable: 0, selected: 0 }
|
|
400
|
+
const selected = rows.filter((r) =>
|
|
401
|
+
formData.permissions.includes(`${actionCode}:${r.resource}`)
|
|
402
|
+
).length
|
|
403
|
+
return { applicable: rows.length, selected }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const toggleColumnForGroup = (groupName: string, actionCode: string) => {
|
|
407
|
+
const rows = (groupedMatrix[groupName] || []).filter((r) =>
|
|
408
|
+
r.actions.includes(actionCode)
|
|
409
|
+
)
|
|
410
|
+
if (rows.length === 0) return
|
|
411
|
+
const codes = rows.map((r) => `${actionCode}:${r.resource}`)
|
|
412
|
+
const allOn = codes.every((c) => formData.permissions.includes(c))
|
|
413
|
+
let newPermissions = [...formData.permissions]
|
|
414
|
+
if (allOn) {
|
|
415
|
+
newPermissions = newPermissions.filter((p) => !codes.includes(p))
|
|
416
|
+
} else {
|
|
417
|
+
const toAdd = codes.filter((c) => !newPermissions.includes(c))
|
|
418
|
+
newPermissions = [...newPermissions, ...toAdd]
|
|
342
419
|
}
|
|
420
|
+
setFormData({ ...formData, permissions: newPermissions })
|
|
343
421
|
}
|
|
344
422
|
|
|
345
423
|
const toggleAllForResource = (
|
|
@@ -371,6 +449,15 @@ export function RoleFormPage({
|
|
|
371
449
|
)
|
|
372
450
|
}
|
|
373
451
|
|
|
452
|
+
const applyTemplate = (templateCode: string) => {
|
|
453
|
+
const tpl = roleTemplates?.find((t) => t.code === templateCode)
|
|
454
|
+
if (!tpl) return
|
|
455
|
+
setFormData((prev) => ({ ...prev, permissions: [...tpl.permissions] }))
|
|
456
|
+
toast.success(`Đã áp bộ quyền mẫu "${tpl.label}"`, {
|
|
457
|
+
description: `${tpl.permissions.length} quyền — chỉnh tiếp rồi bấm Lưu.`,
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
|
|
374
461
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
375
462
|
e.preventDefault()
|
|
376
463
|
setIsSubmitting(true)
|
|
@@ -409,7 +496,7 @@ export function RoleFormPage({
|
|
|
409
496
|
const visibleGroups = Object.entries(groupedMatrix).filter(
|
|
410
497
|
([group, groupResources]) =>
|
|
411
498
|
groupResources.length > 0 &&
|
|
412
|
-
|
|
499
|
+
(searching || activeGroup === "all" || activeGroup === group)
|
|
413
500
|
)
|
|
414
501
|
|
|
415
502
|
return (
|
|
@@ -526,44 +613,105 @@ export function RoleFormPage({
|
|
|
526
613
|
</div>
|
|
527
614
|
|
|
528
615
|
<div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
|
|
529
|
-
{/* ===== 2. Toolbar sticky ===== */}
|
|
530
|
-
<div className="sticky top-0 z-20
|
|
531
|
-
<div className="
|
|
532
|
-
<
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
616
|
+
{/* ===== 2. Toolbar sticky: search · bộ quyền mẫu (create) · đếm ===== */}
|
|
617
|
+
<div className="sticky top-0 z-20 border-b border-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/85">
|
|
618
|
+
<div className="flex flex-wrap items-center gap-2 px-3 py-2 sm:px-4">
|
|
619
|
+
<div className="relative min-w-[140px] flex-1 sm:max-w-64">
|
|
620
|
+
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
621
|
+
<Input
|
|
622
|
+
placeholder="Tìm kiếm tài nguyên..."
|
|
623
|
+
className="h-8 rounded-md border border-border bg-card pl-8 text-sm"
|
|
624
|
+
value={searchTerm}
|
|
625
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
626
|
+
/>
|
|
627
|
+
</div>
|
|
628
|
+
{mode === "create" && (roleTemplates?.length ?? 0) > 0 && (
|
|
629
|
+
<Select onValueChange={applyTemplate}>
|
|
630
|
+
<SelectTrigger className="h-8 w-44 rounded-md border border-border bg-card text-sm sm:w-52">
|
|
631
|
+
<span className="flex items-center gap-1.5 truncate">
|
|
632
|
+
<LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
633
|
+
<SelectValue placeholder="Bộ quyền mẫu..." />
|
|
634
|
+
</span>
|
|
635
|
+
</SelectTrigger>
|
|
636
|
+
<SelectContent className="rounded-lg">
|
|
637
|
+
{roleTemplates!.map((tpl) => (
|
|
638
|
+
<SelectItem key={tpl.code} value={tpl.code}>
|
|
639
|
+
<span className="flex flex-col">
|
|
640
|
+
<span>{tpl.label}</span>
|
|
641
|
+
{tpl.description && (
|
|
642
|
+
<span className="text-[11px] text-muted-foreground">
|
|
643
|
+
{tpl.description}
|
|
644
|
+
</span>
|
|
645
|
+
)}
|
|
646
|
+
</span>
|
|
647
|
+
</SelectItem>
|
|
648
|
+
))}
|
|
649
|
+
</SelectContent>
|
|
650
|
+
</Select>
|
|
651
|
+
)}
|
|
652
|
+
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
|
|
653
|
+
<span className="font-semibold tabular-nums text-foreground">
|
|
654
|
+
{formData.permissions.length}
|
|
655
|
+
</span>
|
|
656
|
+
<span className="tabular-nums">/{totalAvailablePermissions}</span>{" "}
|
|
657
|
+
quyền
|
|
658
|
+
</p>
|
|
539
659
|
</div>
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
660
|
+
|
|
661
|
+
{/* Thanh TAB phân hệ — cuộn ngang, đếm realtime */}
|
|
662
|
+
<div className="flex items-center gap-1 overflow-x-auto px-3 pb-2 sm:px-4 [scrollbar-width:thin]">
|
|
663
|
+
<button
|
|
664
|
+
type="button"
|
|
665
|
+
onClick={() => setActiveGroup("all")}
|
|
666
|
+
className={cn(
|
|
667
|
+
"shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
|
668
|
+
activeGroup === "all" && !searching
|
|
669
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
670
|
+
: "border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
|
671
|
+
)}
|
|
672
|
+
>
|
|
673
|
+
Tất cả
|
|
674
|
+
</button>
|
|
675
|
+
{allGroups.map((group) => {
|
|
676
|
+
const { selected, total } = groupTabCount(group)
|
|
677
|
+
const active = activeGroup === group && !searching
|
|
678
|
+
return (
|
|
679
|
+
<button
|
|
680
|
+
key={group}
|
|
681
|
+
type="button"
|
|
682
|
+
onClick={() => {
|
|
683
|
+
setActiveGroup(group)
|
|
684
|
+
setSearchTerm("")
|
|
685
|
+
}}
|
|
686
|
+
className={cn(
|
|
687
|
+
"flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
|
688
|
+
active
|
|
689
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
690
|
+
: "border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
|
691
|
+
)}
|
|
692
|
+
>
|
|
551
693
|
{group}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
694
|
+
<span
|
|
695
|
+
className={cn(
|
|
696
|
+
"rounded-full px-1 text-[10px] tabular-nums",
|
|
697
|
+
active
|
|
698
|
+
? "bg-primary-foreground/20"
|
|
699
|
+
: selected > 0
|
|
700
|
+
? selected === total
|
|
701
|
+
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
|
702
|
+
: "bg-muted text-foreground"
|
|
703
|
+
: "bg-muted"
|
|
704
|
+
)}
|
|
705
|
+
>
|
|
706
|
+
{selected}/{total}
|
|
707
|
+
</span>
|
|
708
|
+
</button>
|
|
709
|
+
)
|
|
710
|
+
})}
|
|
711
|
+
</div>
|
|
564
712
|
</div>
|
|
565
713
|
|
|
566
|
-
{/* ===== 3.
|
|
714
|
+
{/* ===== 3. Bảng quyền theo phân hệ ===== */}
|
|
567
715
|
{visibleGroups.length === 0 ? (
|
|
568
716
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
569
717
|
<Search className="mb-3 h-6 w-6 text-muted-foreground" />
|
|
@@ -573,30 +721,13 @@ export function RoleFormPage({
|
|
|
573
721
|
</div>
|
|
574
722
|
) : (
|
|
575
723
|
visibleGroups.map(([group, groupResources]) => {
|
|
576
|
-
const collapsed = !!collapsedGroups[group]
|
|
577
724
|
const { selected, total } = groupSelectedCount(group)
|
|
578
725
|
const isFull = total > 0 && selected === total
|
|
579
726
|
|
|
580
727
|
return (
|
|
581
728
|
<section key={group}>
|
|
582
|
-
{/*
|
|
583
|
-
<div
|
|
584
|
-
role="button"
|
|
585
|
-
tabIndex={0}
|
|
586
|
-
onClick={() => toggleGroupCollapse(group)}
|
|
587
|
-
onKeyDown={(e) => {
|
|
588
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
589
|
-
e.preventDefault()
|
|
590
|
-
toggleGroupCollapse(group)
|
|
591
|
-
}
|
|
592
|
-
}}
|
|
593
|
-
className="flex cursor-pointer select-none items-center gap-2 border-b border-border bg-card px-4 py-2 transition-colors hover:bg-muted/40"
|
|
594
|
-
>
|
|
595
|
-
{collapsed ? (
|
|
596
|
-
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
597
|
-
) : (
|
|
598
|
-
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
599
|
-
)}
|
|
729
|
+
{/* Tiêu đề nhóm */}
|
|
730
|
+
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
|
|
600
731
|
<h3 className="truncate text-xs font-semibold uppercase tracking-wide text-foreground">
|
|
601
732
|
{group}
|
|
602
733
|
</h3>
|
|
@@ -611,10 +742,7 @@ export function RoleFormPage({
|
|
|
611
742
|
{groupResources.length} tài nguyên · {selected}/{total}
|
|
612
743
|
{isFull && <Check className="ml-0.5 inline h-3 w-3" />}
|
|
613
744
|
</span>
|
|
614
|
-
<div
|
|
615
|
-
className="ml-auto flex shrink-0 items-center gap-1"
|
|
616
|
-
onClick={(e) => e.stopPropagation()}
|
|
617
|
-
>
|
|
745
|
+
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
618
746
|
<Button
|
|
619
747
|
type="button"
|
|
620
748
|
variant="ghost"
|
|
@@ -638,26 +766,172 @@ export function RoleFormPage({
|
|
|
638
766
|
</div>
|
|
639
767
|
</div>
|
|
640
768
|
|
|
641
|
-
{/*
|
|
642
|
-
|
|
643
|
-
<
|
|
644
|
-
|
|
645
|
-
<
|
|
646
|
-
<
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
769
|
+
{/* Bảng cột chuẩn */}
|
|
770
|
+
<div className="overflow-x-auto border-b border-border">
|
|
771
|
+
<table className="w-full min-w-[760px] border-collapse text-sm">
|
|
772
|
+
<thead>
|
|
773
|
+
<tr className="border-b border-border/70">
|
|
774
|
+
<th className="w-56 px-4 py-1.5 text-left text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
775
|
+
Tài nguyên
|
|
776
|
+
</th>
|
|
777
|
+
{STANDARD_COLUMNS.map((col) => {
|
|
778
|
+
const { applicable, selected: colSel } = columnState(
|
|
779
|
+
group,
|
|
780
|
+
col.code
|
|
781
|
+
)
|
|
782
|
+
const colFull =
|
|
783
|
+
applicable > 0 && colSel === applicable
|
|
784
|
+
return (
|
|
785
|
+
<th key={col.code} className="w-14 px-1 py-1.5">
|
|
786
|
+
<button
|
|
787
|
+
type="button"
|
|
788
|
+
onClick={() =>
|
|
789
|
+
toggleColumnForGroup(group, col.code)
|
|
790
|
+
}
|
|
791
|
+
disabled={applicable === 0}
|
|
792
|
+
title={
|
|
793
|
+
applicable === 0
|
|
794
|
+
? "Không tài nguyên nào có quyền này"
|
|
795
|
+
: `Bấm để ${colFull ? "bỏ" : "chọn"} cả cột (${colSel}/${applicable})`
|
|
796
|
+
}
|
|
797
|
+
className={cn(
|
|
798
|
+
"mx-auto flex flex-col items-center gap-0.5 rounded px-1 py-0.5 text-[11px] font-medium transition-colors",
|
|
799
|
+
applicable === 0
|
|
800
|
+
? "cursor-default text-muted-foreground/40"
|
|
801
|
+
: colFull
|
|
802
|
+
? "text-primary hover:bg-primary/10"
|
|
803
|
+
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
804
|
+
)}
|
|
805
|
+
>
|
|
806
|
+
{col.label}
|
|
807
|
+
{applicable > 0 && (
|
|
808
|
+
<span className="text-[9px] tabular-nums opacity-70">
|
|
809
|
+
{colSel}/{applicable}
|
|
810
|
+
</span>
|
|
811
|
+
)}
|
|
812
|
+
</button>
|
|
813
|
+
</th>
|
|
814
|
+
)
|
|
815
|
+
})}
|
|
816
|
+
<th className="px-2 py-1.5 text-left text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
817
|
+
Đặc thù
|
|
818
|
+
</th>
|
|
819
|
+
<th className="w-16 px-2 py-1.5 text-right text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
820
|
+
Tất cả
|
|
821
|
+
</th>
|
|
822
|
+
</tr>
|
|
823
|
+
</thead>
|
|
824
|
+
<tbody className="divide-y divide-border/60">
|
|
825
|
+
{groupResources.map((resource) => (
|
|
826
|
+
<tr
|
|
827
|
+
key={resource.resource}
|
|
828
|
+
className="transition-colors hover:bg-muted/30"
|
|
829
|
+
>
|
|
830
|
+
<td className="px-4 py-2">
|
|
831
|
+
<div className="flex items-center gap-2">
|
|
832
|
+
<DynamicIcon
|
|
833
|
+
name={resource.icon as any}
|
|
834
|
+
className="h-4 w-4 shrink-0 text-muted-foreground"
|
|
835
|
+
/>
|
|
836
|
+
<div className="min-w-0">
|
|
837
|
+
<div className="truncate font-medium text-foreground">
|
|
838
|
+
{resource.name}
|
|
839
|
+
</div>
|
|
840
|
+
<code className="block truncate font-mono text-[10px] text-muted-foreground">
|
|
841
|
+
{resource.resource}
|
|
842
|
+
</code>
|
|
843
|
+
</div>
|
|
844
|
+
</div>
|
|
845
|
+
</td>
|
|
846
|
+
{STANDARD_COLUMNS.map((col) => {
|
|
847
|
+
const declared = resource.actions.includes(col.code)
|
|
848
|
+
if (!declared) {
|
|
849
|
+
return (
|
|
850
|
+
<td
|
|
851
|
+
key={col.code}
|
|
852
|
+
className="px-1 py-2 text-center text-muted-foreground/30"
|
|
853
|
+
>
|
|
854
|
+
·
|
|
855
|
+
</td>
|
|
856
|
+
)
|
|
857
|
+
}
|
|
858
|
+
return (
|
|
859
|
+
<td key={col.code} className="px-1 py-2 text-center">
|
|
860
|
+
<Checkbox
|
|
861
|
+
checked={hasPermission(
|
|
862
|
+
resource.resource,
|
|
863
|
+
col.code
|
|
864
|
+
)}
|
|
865
|
+
onCheckedChange={() =>
|
|
866
|
+
togglePermission(
|
|
867
|
+
resource.resource,
|
|
868
|
+
col.code
|
|
869
|
+
)
|
|
870
|
+
}
|
|
871
|
+
aria-label={`${actionName(col.code)} ${resource.name}`}
|
|
872
|
+
className="mx-auto"
|
|
873
|
+
/>
|
|
874
|
+
</td>
|
|
875
|
+
)
|
|
876
|
+
})}
|
|
877
|
+
<td className="px-2 py-2">
|
|
878
|
+
{resource.extraActions.length === 0 ? (
|
|
879
|
+
<span className="text-muted-foreground/30">·</span>
|
|
880
|
+
) : (
|
|
881
|
+
<div className="flex max-w-72 flex-wrap gap-1">
|
|
882
|
+
{resource.extraActions.map((code) => {
|
|
883
|
+
const isChecked = hasPermission(
|
|
884
|
+
resource.resource,
|
|
885
|
+
code
|
|
886
|
+
)
|
|
887
|
+
const visual = getActionVisual(code)
|
|
888
|
+
const ActionIcon = visual.icon
|
|
889
|
+
return (
|
|
890
|
+
<button
|
|
891
|
+
key={code}
|
|
892
|
+
type="button"
|
|
893
|
+
onClick={() =>
|
|
894
|
+
togglePermission(
|
|
895
|
+
resource.resource,
|
|
896
|
+
code
|
|
897
|
+
)
|
|
898
|
+
}
|
|
899
|
+
title={code}
|
|
900
|
+
className={cn(
|
|
901
|
+
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium transition-colors",
|
|
902
|
+
isChecked
|
|
903
|
+
? "border-primary/40 bg-primary/10 text-primary"
|
|
904
|
+
: "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
|
|
905
|
+
)}
|
|
906
|
+
>
|
|
907
|
+
<span
|
|
908
|
+
className={cn(
|
|
909
|
+
"flex h-3 w-3 shrink-0 items-center justify-center rounded-[3px] border transition-colors",
|
|
910
|
+
isChecked
|
|
911
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
912
|
+
: "border-border bg-card text-transparent"
|
|
913
|
+
)}
|
|
914
|
+
>
|
|
915
|
+
<Check
|
|
916
|
+
className="h-2 w-2"
|
|
917
|
+
strokeWidth={3}
|
|
918
|
+
/>
|
|
919
|
+
</span>
|
|
920
|
+
<ActionIcon
|
|
921
|
+
className={cn(
|
|
922
|
+
"h-3 w-3 shrink-0",
|
|
923
|
+
visual.cls,
|
|
924
|
+
!isChecked && "opacity-55"
|
|
925
|
+
)}
|
|
926
|
+
/>
|
|
927
|
+
{actionName(code)}
|
|
928
|
+
</button>
|
|
929
|
+
)
|
|
930
|
+
})}
|
|
931
|
+
</div>
|
|
932
|
+
)}
|
|
933
|
+
</td>
|
|
934
|
+
<td className="px-2 py-2 text-right">
|
|
661
935
|
<Switch
|
|
662
936
|
checked={isResourceFullPermission(
|
|
663
937
|
resource.resource,
|
|
@@ -671,73 +945,14 @@ export function RoleFormPage({
|
|
|
671
945
|
)
|
|
672
946
|
}
|
|
673
947
|
className="origin-right scale-75"
|
|
948
|
+
aria-label={`Toàn quyền ${resource.name}`}
|
|
674
949
|
/>
|
|
675
|
-
</
|
|
676
|
-
</
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
if (!resource.actions.includes(action.code))
|
|
682
|
-
return null
|
|
683
|
-
const isChecked = hasPermission(
|
|
684
|
-
resource.resource,
|
|
685
|
-
action.code
|
|
686
|
-
)
|
|
687
|
-
const label =
|
|
688
|
-
action.name ||
|
|
689
|
-
mergedActionLabels[action.code]?.label ||
|
|
690
|
-
action.code
|
|
691
|
-
const visual = getActionVisual(action.code)
|
|
692
|
-
const ActionIcon = visual.icon
|
|
693
|
-
|
|
694
|
-
return (
|
|
695
|
-
<button
|
|
696
|
-
key={action.code}
|
|
697
|
-
type="button"
|
|
698
|
-
onClick={() =>
|
|
699
|
-
togglePermission(
|
|
700
|
-
resource.resource,
|
|
701
|
-
action.code
|
|
702
|
-
)
|
|
703
|
-
}
|
|
704
|
-
className={cn(
|
|
705
|
-
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors",
|
|
706
|
-
isChecked
|
|
707
|
-
? "border-primary/40 bg-primary/10 text-primary"
|
|
708
|
-
: "border-border bg-card text-muted-foreground hover:border-primary/30 hover:text-foreground"
|
|
709
|
-
)}
|
|
710
|
-
>
|
|
711
|
-
{/* Ô tick tường minh — nhìn là biết đã cấp hay chưa */}
|
|
712
|
-
<span
|
|
713
|
-
className={cn(
|
|
714
|
-
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-[4px] border transition-colors",
|
|
715
|
-
isChecked
|
|
716
|
-
? "border-primary bg-primary text-primary-foreground"
|
|
717
|
-
: "border-border bg-card text-transparent"
|
|
718
|
-
)}
|
|
719
|
-
>
|
|
720
|
-
<Check
|
|
721
|
-
className="h-2.5 w-2.5"
|
|
722
|
-
strokeWidth={3}
|
|
723
|
-
/>
|
|
724
|
-
</span>
|
|
725
|
-
<ActionIcon
|
|
726
|
-
className={cn(
|
|
727
|
-
"h-3.5 w-3.5 shrink-0",
|
|
728
|
-
visual.cls,
|
|
729
|
-
!isChecked && "opacity-55"
|
|
730
|
-
)}
|
|
731
|
-
/>
|
|
732
|
-
{label}
|
|
733
|
-
</button>
|
|
734
|
-
)
|
|
735
|
-
})}
|
|
736
|
-
</div>
|
|
737
|
-
</li>
|
|
738
|
-
))}
|
|
739
|
-
</ul>
|
|
740
|
-
)}
|
|
950
|
+
</td>
|
|
951
|
+
</tr>
|
|
952
|
+
))}
|
|
953
|
+
</tbody>
|
|
954
|
+
</table>
|
|
955
|
+
</div>
|
|
741
956
|
</section>
|
|
742
957
|
)
|
|
743
958
|
})
|
|
@@ -8,6 +8,7 @@ import type { LocaleType } from "../../types";
|
|
|
8
8
|
|
|
9
9
|
import { ensureLocalizedPathname, cn } from "../../utils";
|
|
10
10
|
import { LanguageDropdown } from "../layout";
|
|
11
|
+
import { useOptionalTenant } from "../../providers/tenant-provider";
|
|
11
12
|
|
|
12
13
|
import type { StaticImageData } from "next/image";
|
|
13
14
|
|
|
@@ -32,6 +33,9 @@ export function Auth({
|
|
|
32
33
|
}: AuthProps) {
|
|
33
34
|
const params = useParams();
|
|
34
35
|
const locale = (params.lang as LocaleType) || "vi";
|
|
36
|
+
// Branding của tenant (nếu app mount TenantProvider). Fallback về logo/tên mặc
|
|
37
|
+
// định để các app chưa cấu hình branding giữ nguyên hành vi cũ.
|
|
38
|
+
const branding = useOptionalTenant()?.branding;
|
|
35
39
|
|
|
36
40
|
return (
|
|
37
41
|
<section
|
|
@@ -47,14 +51,25 @@ export function Auth({
|
|
|
47
51
|
href={ensureLocalizedPathname("/", locale)}
|
|
48
52
|
className="flex items-center gap-2 text-foreground font-black"
|
|
49
53
|
>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
{branding?.logo ? (
|
|
55
|
+
<Image
|
|
56
|
+
src={branding.logo}
|
|
57
|
+
alt={branding.companyName || "Logo"}
|
|
58
|
+
height={24}
|
|
59
|
+
width={24}
|
|
60
|
+
className="object-contain"
|
|
61
|
+
unoptimized
|
|
62
|
+
/>
|
|
63
|
+
) : (
|
|
64
|
+
<Image
|
|
65
|
+
src="/images/icons/shadboard.svg"
|
|
66
|
+
alt="Logo"
|
|
67
|
+
height={24}
|
|
68
|
+
width={24}
|
|
69
|
+
className="dark:invert"
|
|
70
|
+
/>
|
|
71
|
+
)}
|
|
72
|
+
<span>{branding?.companyName || "GoERP"}</span>
|
|
58
73
|
</Link>
|
|
59
74
|
<LanguageDropdown dictionary={dictionary} />
|
|
60
75
|
</div>
|