@goplusvn/core 0.1.46 → 0.1.48
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 +2 -1
- package/src/auth/__tests__/api-handler.test.ts +141 -0
- package/src/auth/api-handler.ts +141 -0
- package/src/rbac/pages/action-list-page.tsx +181 -256
- package/src/rbac/pages/index.ts +5 -0
- package/src/rbac/pages/lib/action-api.ts +38 -0
- package/src/rbac/pages/lib/group-taxonomy.ts +110 -0
- package/src/rbac/pages/lib/rbac-page-shell.tsx +148 -0
- package/src/rbac/pages/resource-list-page.tsx +350 -499
- package/src/schemas/branch.schema.ts +35 -0
- package/src/schemas/user.schema.ts +2 -0
- package/src/schemas/warehouse.schema.ts +2 -0
- package/src/user/components/unified-profile-dialog.tsx +81 -81
- package/src/user/components/users-card-view.tsx +12 -12
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// vai trò là MẶC ĐỊNH (toggle List/Grid). Bấm thẻ bung editor quyền
|
|
6
6
|
// (ResourceActionsEditor dùng chung dòng/thẻ); xóa trực tiếp từ thẻ.
|
|
7
7
|
// API contract: /api/resources (+/sync, /:id), /api/actions.
|
|
8
|
-
import { useEffect, useMemo, useState } from "react"
|
|
8
|
+
import { useEffect, useMemo, useState } from "react";
|
|
9
9
|
import {
|
|
10
10
|
Badge,
|
|
11
11
|
Button,
|
|
@@ -26,8 +26,8 @@ import {
|
|
|
26
26
|
SelectValue,
|
|
27
27
|
Skeleton,
|
|
28
28
|
Textarea,
|
|
29
|
-
} from "../../ui"
|
|
30
|
-
import { toast } from "sonner"
|
|
29
|
+
} from "../../ui";
|
|
30
|
+
import { toast } from "sonner";
|
|
31
31
|
import {
|
|
32
32
|
Box,
|
|
33
33
|
Boxes,
|
|
@@ -37,8 +37,6 @@ import {
|
|
|
37
37
|
ChevronRight,
|
|
38
38
|
ClipboardList,
|
|
39
39
|
DatabaseBackup,
|
|
40
|
-
LayoutGrid,
|
|
41
|
-
List,
|
|
42
40
|
Pencil,
|
|
43
41
|
Plus,
|
|
44
42
|
RefreshCw,
|
|
@@ -50,111 +48,37 @@ import {
|
|
|
50
48
|
Truck,
|
|
51
49
|
Users,
|
|
52
50
|
Wallet,
|
|
53
|
-
X,
|
|
54
51
|
Zap,
|
|
55
|
-
} from "lucide-react"
|
|
56
|
-
|
|
57
|
-
import { errorMessage, throwFetchError } from "../lib/fetch-error"
|
|
58
|
-
import { cn } from "../../utils"
|
|
52
|
+
} from "lucide-react";
|
|
59
53
|
|
|
54
|
+
import { errorMessage, throwFetchError } from "../lib/fetch-error";
|
|
55
|
+
import { deleteActionById, saveAction } from "./lib/action-api";
|
|
56
|
+
import { RbacPageBar, RbacStickyToolbar } from "./lib/rbac-page-shell";
|
|
57
|
+
import { cn } from "../../utils";
|
|
58
|
+
import { getGroupMeta, getGroupOrder } from "./lib/group-taxonomy";
|
|
60
59
|
|
|
61
60
|
// --- Types ---
|
|
62
61
|
interface Resource {
|
|
63
|
-
id: string
|
|
64
|
-
code: string
|
|
65
|
-
name: string
|
|
66
|
-
group: string
|
|
67
|
-
description?: string
|
|
68
|
-
icon?: string
|
|
69
|
-
actions: string[]
|
|
70
|
-
config?: any
|
|
62
|
+
id: string;
|
|
63
|
+
code: string;
|
|
64
|
+
name: string;
|
|
65
|
+
group: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
icon?: string;
|
|
68
|
+
actions: string[];
|
|
69
|
+
config?: any;
|
|
71
70
|
}
|
|
72
71
|
|
|
73
72
|
interface Action {
|
|
74
|
-
id: string
|
|
75
|
-
code: string
|
|
76
|
-
name: string
|
|
77
|
-
description?: string
|
|
73
|
+
id: string;
|
|
74
|
+
code: string;
|
|
75
|
+
name: string;
|
|
76
|
+
description?: string;
|
|
78
77
|
}
|
|
79
78
|
|
|
80
79
|
// --- Group metadata: one muted hue per group ---
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
{ icon: any; label: string; iconCls: string }
|
|
84
|
-
> = {
|
|
85
|
-
sales: {
|
|
86
|
-
icon: ShoppingBag,
|
|
87
|
-
label: "Bán hàng",
|
|
88
|
-
iconCls: "text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/15",
|
|
89
|
-
},
|
|
90
|
-
purchase: {
|
|
91
|
-
icon: ClipboardList,
|
|
92
|
-
label: "Mua hàng",
|
|
93
|
-
iconCls:
|
|
94
|
-
"text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/15",
|
|
95
|
-
},
|
|
96
|
-
inventory: {
|
|
97
|
-
icon: Truck,
|
|
98
|
-
label: "Kho",
|
|
99
|
-
iconCls:
|
|
100
|
-
"text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/15",
|
|
101
|
-
},
|
|
102
|
-
finance: {
|
|
103
|
-
icon: Wallet,
|
|
104
|
-
label: "Tài chính",
|
|
105
|
-
iconCls:
|
|
106
|
-
"text-violet-600 bg-violet-100 dark:text-violet-400 dark:bg-violet-500/15",
|
|
107
|
-
},
|
|
108
|
-
hr: {
|
|
109
|
-
icon: Users,
|
|
110
|
-
label: "Nhân sự",
|
|
111
|
-
iconCls: "text-rose-600 bg-rose-100 dark:text-rose-400 dark:bg-rose-500/15",
|
|
112
|
-
},
|
|
113
|
-
master: {
|
|
114
|
-
icon: Boxes,
|
|
115
|
-
label: "Danh mục",
|
|
116
|
-
iconCls: "text-cyan-600 bg-cyan-100 dark:text-cyan-400 dark:bg-cyan-500/15",
|
|
117
|
-
},
|
|
118
|
-
users: {
|
|
119
|
-
icon: Users,
|
|
120
|
-
label: "Người dùng",
|
|
121
|
-
iconCls:
|
|
122
|
-
"text-fuchsia-600 bg-fuchsia-100 dark:text-fuchsia-400 dark:bg-fuchsia-500/15",
|
|
123
|
-
},
|
|
124
|
-
system: {
|
|
125
|
-
icon: Settings,
|
|
126
|
-
label: "Hệ thống",
|
|
127
|
-
iconCls:
|
|
128
|
-
"text-slate-600 bg-slate-200 dark:text-slate-300 dark:bg-slate-500/20",
|
|
129
|
-
},
|
|
130
|
-
other: {
|
|
131
|
-
icon: Box,
|
|
132
|
-
label: "Khác",
|
|
133
|
-
iconCls:
|
|
134
|
-
"text-slate-600 bg-slate-200 dark:text-slate-300 dark:bg-slate-500/20",
|
|
135
|
-
},
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const GROUP_ORDER = [
|
|
139
|
-
"sales",
|
|
140
|
-
"purchase",
|
|
141
|
-
"inventory",
|
|
142
|
-
"finance",
|
|
143
|
-
"hr",
|
|
144
|
-
"master",
|
|
145
|
-
"users",
|
|
146
|
-
"system",
|
|
147
|
-
"other",
|
|
148
|
-
]
|
|
149
|
-
|
|
150
|
-
const getGroupMeta = (group: string) =>
|
|
151
|
-
GROUP_META[group?.toLowerCase()] || {
|
|
152
|
-
icon: Box,
|
|
153
|
-
label: group || "Khác",
|
|
154
|
-
iconCls:
|
|
155
|
-
"text-slate-600 bg-slate-200 dark:text-slate-300 dark:bg-slate-500/20",
|
|
156
|
-
}
|
|
157
|
-
|
|
80
|
+
// Taxonomy nhóm resource: default + registerResourceGroupMeta cho app —
|
|
81
|
+
// sống ở ./lib/group-taxonomy (OCP: app thêm nhóm không cần sửa core).
|
|
158
82
|
// --- Action chips colored by permission category ---
|
|
159
83
|
const ACTION_TONES: Record<string, string> = {
|
|
160
84
|
read: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-300",
|
|
@@ -167,16 +91,16 @@ const ACTION_TONES: Record<string, string> = {
|
|
|
167
91
|
approve:
|
|
168
92
|
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300",
|
|
169
93
|
neutral: "border-border bg-muted text-foreground/80",
|
|
170
|
-
}
|
|
94
|
+
};
|
|
171
95
|
|
|
172
96
|
const getActionTone = (code: string): string => {
|
|
173
|
-
if (/^(view|read|get|list|export|print|sync)/.test(code)) return "read"
|
|
174
|
-
if (/^(create|add|new)/.test(code)) return "create"
|
|
175
|
-
if (/^(update|edit|change|set)/.test(code)) return "update"
|
|
176
|
-
if (/^(delete|remove)/.test(code)) return "delete"
|
|
177
|
-
if (/^(approve|confirm|sign|verify|accept)/.test(code)) return "approve"
|
|
178
|
-
return "neutral"
|
|
179
|
-
}
|
|
97
|
+
if (/^(view|read|get|list|export|print|sync)/.test(code)) return "read";
|
|
98
|
+
if (/^(create|add|new)/.test(code)) return "create";
|
|
99
|
+
if (/^(update|edit|change|set)/.test(code)) return "update";
|
|
100
|
+
if (/^(delete|remove)/.test(code)) return "delete";
|
|
101
|
+
if (/^(approve|confirm|sign|verify|accept)/.test(code)) return "approve";
|
|
102
|
+
return "neutral";
|
|
103
|
+
};
|
|
180
104
|
|
|
181
105
|
// 4 hành động CRUD chuẩn → nhóm "Cơ bản"; còn lại vào "Chức năng" (A→Z).
|
|
182
106
|
const BASIC_ORDER = [
|
|
@@ -188,21 +112,21 @@ const BASIC_ORDER = [
|
|
|
188
112
|
"edit",
|
|
189
113
|
"delete",
|
|
190
114
|
"remove",
|
|
191
|
-
]
|
|
115
|
+
];
|
|
192
116
|
|
|
193
117
|
const categorizeActions = (actions: Action[]) => {
|
|
194
|
-
const basic: Action[] = []
|
|
195
|
-
const functional: Action[] = []
|
|
118
|
+
const basic: Action[] = [];
|
|
119
|
+
const functional: Action[] = [];
|
|
196
120
|
for (const a of actions) {
|
|
197
|
-
if (BASIC_ORDER.includes(a.code)) basic.push(a)
|
|
198
|
-
else functional.push(a)
|
|
121
|
+
if (BASIC_ORDER.includes(a.code)) basic.push(a);
|
|
122
|
+
else functional.push(a);
|
|
199
123
|
}
|
|
200
124
|
basic.sort(
|
|
201
|
-
(x, y) => BASIC_ORDER.indexOf(x.code) - BASIC_ORDER.indexOf(y.code)
|
|
202
|
-
)
|
|
203
|
-
functional.sort((x, y) => x.name.localeCompare(y.name, "vi"))
|
|
204
|
-
return { basic, functional }
|
|
205
|
-
}
|
|
125
|
+
(x, y) => BASIC_ORDER.indexOf(x.code) - BASIC_ORDER.indexOf(y.code),
|
|
126
|
+
);
|
|
127
|
+
functional.sort((x, y) => x.name.localeCompare(y.name, "vi"));
|
|
128
|
+
return { basic, functional };
|
|
129
|
+
};
|
|
206
130
|
|
|
207
131
|
// Diacritic-insensitive normalize (so "duyet" matches "Duyệt")
|
|
208
132
|
const normalize = (s: string) =>
|
|
@@ -210,249 +134,253 @@ const normalize = (s: string) =>
|
|
|
210
134
|
.toLowerCase()
|
|
211
135
|
.normalize("NFD")
|
|
212
136
|
.replace(/[\u0300-\u036f]/g, "")
|
|
213
|
-
.replace(/đ/g, "d")
|
|
137
|
+
.replace(/đ/g, "d");
|
|
214
138
|
|
|
215
139
|
export interface ResourceListPageProps {
|
|
216
140
|
/** Danh mục do Permission Registry trong code quản lý: ẩn nút Thêm/Sync/
|
|
217
141
|
* Action-manager, thao tác sửa/xóa hiện toast hướng dẫn thay vì mở dialog. */
|
|
218
|
-
readOnly?: boolean
|
|
142
|
+
readOnly?: boolean;
|
|
219
143
|
}
|
|
220
144
|
|
|
221
|
-
export function ResourceListPage({
|
|
145
|
+
export function ResourceListPage({
|
|
146
|
+
readOnly = false,
|
|
147
|
+
}: ResourceListPageProps = {}) {
|
|
222
148
|
const registryToast = () =>
|
|
223
149
|
toast.info("Danh mục do Permission Registry quản lý", {
|
|
224
150
|
description:
|
|
225
151
|
"Sửa file <feature>.permissions.ts trong code rồi chạy rbac-sync — không chỉnh tay ở đây.",
|
|
226
|
-
})
|
|
152
|
+
});
|
|
227
153
|
const guardWrite = <T extends (...args: any[]) => any>(fn: T): T =>
|
|
228
|
-
|
|
229
|
-
const [loading, setLoading] = useState(true)
|
|
230
|
-
const [resources, setResources] = useState<Resource[]>([])
|
|
231
|
-
const [allActions, setAllActions] = useState<Action[]>([])
|
|
232
|
-
const [searchTerm, setSearchTerm] = useState("")
|
|
233
|
-
const [isRefreshing, setIsRefreshing] = useState(false)
|
|
234
|
-
const [isSyncing, setIsSyncing] = useState(false)
|
|
235
|
-
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
|
|
236
|
-
|
|
154
|
+
readOnly ? ((() => registryToast()) as unknown as T) : fn;
|
|
155
|
+
const [loading, setLoading] = useState(true);
|
|
156
|
+
const [resources, setResources] = useState<Resource[]>([]);
|
|
157
|
+
const [allActions, setAllActions] = useState<Action[]>([]);
|
|
158
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
159
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
160
|
+
const [isSyncing, setIsSyncing] = useState(false);
|
|
161
|
+
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
|
|
162
|
+
new Set(),
|
|
163
|
+
);
|
|
164
|
+
const [view, setView] = useState<"list" | "grid">("grid");
|
|
237
165
|
|
|
238
166
|
// Hàng nào đang bung để xem/sửa toàn bộ action
|
|
239
|
-
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
|
167
|
+
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
|
240
168
|
|
|
241
169
|
// Resource Dialog State
|
|
242
|
-
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
|
243
|
-
const [editingResource, setEditingResource] = useState<Resource | null>(null)
|
|
170
|
+
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
171
|
+
const [editingResource, setEditingResource] = useState<Resource | null>(null);
|
|
244
172
|
const [formData, setFormData] = useState({
|
|
245
173
|
code: "",
|
|
246
174
|
name: "",
|
|
247
175
|
group: "",
|
|
248
176
|
description: "",
|
|
249
|
-
})
|
|
177
|
+
});
|
|
250
178
|
|
|
251
179
|
// Action Manager State
|
|
252
|
-
const [isActionManagerOpen, setIsActionManagerOpen] = useState(false)
|
|
180
|
+
const [isActionManagerOpen, setIsActionManagerOpen] = useState(false);
|
|
253
181
|
const [actionFormMode, setActionFormMode] = useState<
|
|
254
182
|
"list" | "create" | "edit"
|
|
255
|
-
>("list")
|
|
256
|
-
const [editingAction, setEditingAction] = useState<Action | null>(null)
|
|
183
|
+
>("list");
|
|
184
|
+
const [editingAction, setEditingAction] = useState<Action | null>(null);
|
|
257
185
|
const [actionFormData, setActionFormData] = useState({
|
|
258
186
|
code: "",
|
|
259
187
|
name: "",
|
|
260
188
|
description: "",
|
|
261
|
-
})
|
|
189
|
+
});
|
|
262
190
|
|
|
263
191
|
// Confirmation state
|
|
264
|
-
const [confirmOpen, setConfirmOpen] = useState(false)
|
|
192
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
265
193
|
const [confirmType, setConfirmType] = useState<"action" | "resource" | null>(
|
|
266
|
-
null
|
|
267
|
-
)
|
|
194
|
+
null,
|
|
195
|
+
);
|
|
268
196
|
const [itemToDelete, setItemToDelete] = useState<{
|
|
269
|
-
id: string
|
|
270
|
-
name: string
|
|
271
|
-
} | null>(null)
|
|
272
|
-
const [isDeleting, setIsDeleting] = useState(false)
|
|
197
|
+
id: string;
|
|
198
|
+
name: string;
|
|
199
|
+
} | null>(null);
|
|
200
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
273
201
|
|
|
274
202
|
// --- Data Fetching ---
|
|
275
203
|
const fetchData = async () => {
|
|
276
204
|
try {
|
|
277
|
-
setLoading(true)
|
|
205
|
+
setLoading(true);
|
|
278
206
|
const [resResources, resActions] = await Promise.all([
|
|
279
207
|
fetch("/api/resources?pageSize=1000"),
|
|
280
208
|
fetch("/api/actions?pageSize=1000"),
|
|
281
|
-
])
|
|
209
|
+
]);
|
|
282
210
|
|
|
283
211
|
if (!resResources.ok || !resActions.ok)
|
|
284
|
-
throw new Error("Failed to fetch data")
|
|
212
|
+
throw new Error("Failed to fetch data");
|
|
285
213
|
|
|
286
|
-
const resourcesData = await resResources.json()
|
|
287
|
-
const actionsData = await resActions.json()
|
|
214
|
+
const resourcesData = await resResources.json();
|
|
215
|
+
const actionsData = await resActions.json();
|
|
288
216
|
|
|
289
217
|
const mappedResources = (resourcesData.items || []).map((r: any) => {
|
|
290
|
-
let actions: string[] = []
|
|
218
|
+
let actions: string[] = [];
|
|
291
219
|
try {
|
|
292
220
|
if (r.config) {
|
|
293
221
|
const configObj =
|
|
294
|
-
typeof r.config === "string" ? JSON.parse(r.config) : r.config
|
|
222
|
+
typeof r.config === "string" ? JSON.parse(r.config) : r.config;
|
|
295
223
|
if (configObj.actions && Array.isArray(configObj.actions)) {
|
|
296
|
-
actions = configObj.actions
|
|
224
|
+
actions = configObj.actions;
|
|
297
225
|
}
|
|
298
226
|
}
|
|
299
227
|
} catch (_catchError) {
|
|
300
228
|
// Ignore invalid config JSON
|
|
301
229
|
}
|
|
302
|
-
return { ...r, actions }
|
|
303
|
-
})
|
|
230
|
+
return { ...r, actions };
|
|
231
|
+
});
|
|
304
232
|
|
|
305
|
-
setResources(mappedResources)
|
|
306
|
-
setAllActions(actionsData.items || [])
|
|
233
|
+
setResources(mappedResources);
|
|
234
|
+
setAllActions(actionsData.items || []);
|
|
307
235
|
} catch (error) {
|
|
308
|
-
toast.error(errorMessage(error, "Lỗi tải dữ liệu"))
|
|
236
|
+
toast.error(errorMessage(error, "Lỗi tải dữ liệu"));
|
|
309
237
|
} finally {
|
|
310
|
-
setLoading(false)
|
|
311
|
-
setIsRefreshing(false)
|
|
238
|
+
setLoading(false);
|
|
239
|
+
setIsRefreshing(false);
|
|
312
240
|
}
|
|
313
|
-
}
|
|
241
|
+
};
|
|
314
242
|
|
|
315
243
|
useEffect(() => {
|
|
316
|
-
fetchData()
|
|
317
|
-
}, [])
|
|
244
|
+
fetchData();
|
|
245
|
+
}, []);
|
|
318
246
|
|
|
319
247
|
const handleRefresh = () => {
|
|
320
|
-
setIsRefreshing(true)
|
|
321
|
-
fetchData()
|
|
322
|
-
}
|
|
248
|
+
setIsRefreshing(true);
|
|
249
|
+
fetchData();
|
|
250
|
+
};
|
|
323
251
|
|
|
324
252
|
// --- Sync Resource ---
|
|
325
253
|
const handleSync = async () => {
|
|
326
254
|
try {
|
|
327
|
-
setIsSyncing(true)
|
|
255
|
+
setIsSyncing(true);
|
|
328
256
|
const res = await fetch("/api/resources/sync", {
|
|
329
257
|
method: "POST",
|
|
330
|
-
})
|
|
258
|
+
});
|
|
331
259
|
|
|
332
260
|
if (!res.ok) {
|
|
333
|
-
await throwFetchError(res, "Lỗi khi đồng bộ tài nguyên")
|
|
261
|
+
await throwFetchError(res, "Lỗi khi đồng bộ tài nguyên");
|
|
334
262
|
}
|
|
335
263
|
|
|
336
|
-
const data = await res.json()
|
|
337
|
-
toast.success(data.message || "Đồng bộ tài nguyên thành công")
|
|
264
|
+
const data = await res.json();
|
|
265
|
+
toast.success(data.message || "Đồng bộ tài nguyên thành công");
|
|
338
266
|
|
|
339
|
-
handleRefresh()
|
|
267
|
+
handleRefresh();
|
|
340
268
|
} catch (error: any) {
|
|
341
|
-
toast.error(errorMessage(error, "Không thể đồng bộ tài nguyên"))
|
|
269
|
+
toast.error(errorMessage(error, "Không thể đồng bộ tài nguyên"));
|
|
342
270
|
} finally {
|
|
343
|
-
setIsSyncing(false)
|
|
271
|
+
setIsSyncing(false);
|
|
344
272
|
}
|
|
345
|
-
}
|
|
273
|
+
};
|
|
346
274
|
|
|
347
275
|
// Quick lookup: action code -> action
|
|
348
276
|
const actionMap = useMemo(() => {
|
|
349
|
-
const map = new Map<string, Action>()
|
|
350
|
-
for (const a of allActions) map.set(a.code, a)
|
|
351
|
-
return map
|
|
352
|
-
}, [allActions])
|
|
277
|
+
const map = new Map<string, Action>();
|
|
278
|
+
for (const a of allActions) map.set(a.code, a);
|
|
279
|
+
return map;
|
|
280
|
+
}, [allActions]);
|
|
353
281
|
|
|
354
282
|
const categorizedActions = useMemo(
|
|
355
283
|
() => categorizeActions(allActions),
|
|
356
|
-
[allActions]
|
|
357
|
-
)
|
|
284
|
+
[allActions],
|
|
285
|
+
);
|
|
358
286
|
|
|
359
287
|
// --- Search: match resource text OR granted action name ---
|
|
360
288
|
const { sections, filteredResourceTotal, isEmpty } = useMemo(() => {
|
|
361
|
-
const q = normalize(searchTerm.trim())
|
|
289
|
+
const q = normalize(searchTerm.trim());
|
|
362
290
|
|
|
363
291
|
const matches = (r: Resource) => {
|
|
364
|
-
if (!q) return true
|
|
292
|
+
if (!q) return true;
|
|
365
293
|
if (
|
|
366
294
|
normalize(r.name).includes(q) ||
|
|
367
295
|
normalize(r.code).includes(q) ||
|
|
368
296
|
normalize(r.group || "").includes(q) ||
|
|
369
297
|
normalize(getGroupMeta(r.group).label).includes(q)
|
|
370
298
|
)
|
|
371
|
-
return true
|
|
299
|
+
return true;
|
|
372
300
|
return (r.actions || []).some((code) => {
|
|
373
|
-
const a = actionMap.get(code)
|
|
301
|
+
const a = actionMap.get(code);
|
|
374
302
|
return (
|
|
375
303
|
normalize(a?.name || code).includes(q) || normalize(code).includes(q)
|
|
376
|
-
)
|
|
377
|
-
})
|
|
378
|
-
}
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
};
|
|
379
307
|
|
|
380
|
-
const rows = resources.filter(matches)
|
|
308
|
+
const rows = resources.filter(matches);
|
|
381
309
|
|
|
382
310
|
if (q && rows.length === 0) {
|
|
383
311
|
return {
|
|
384
312
|
sections: [] as {
|
|
385
|
-
group: string
|
|
386
|
-
meta: { icon: any; label: string; iconCls: string }
|
|
387
|
-
items: Resource[]
|
|
313
|
+
group: string;
|
|
314
|
+
meta: { icon: any; label: string; iconCls: string };
|
|
315
|
+
items: Resource[];
|
|
388
316
|
}[],
|
|
389
317
|
filteredResourceTotal: 0,
|
|
390
318
|
isEmpty: true,
|
|
391
|
-
}
|
|
319
|
+
};
|
|
392
320
|
}
|
|
393
321
|
|
|
394
|
-
const grouped: Record<string, Resource[]> = {}
|
|
322
|
+
const grouped: Record<string, Resource[]> = {};
|
|
395
323
|
for (const r of rows) {
|
|
396
|
-
const g = (r.group || "other").toLowerCase()
|
|
397
|
-
|
|
324
|
+
const g = (r.group || "other").toLowerCase();
|
|
325
|
+
(grouped[g] ||= []).push(r);
|
|
398
326
|
}
|
|
399
327
|
|
|
400
328
|
const orderedSections = Object.keys(grouped)
|
|
401
329
|
.sort((a, b) => {
|
|
402
|
-
const ia =
|
|
403
|
-
const ib =
|
|
404
|
-
if (ia !== -1 && ib !== -1) return ia - ib
|
|
405
|
-
if (ia !== -1) return -1
|
|
406
|
-
if (ib !== -1) return 1
|
|
407
|
-
return a.localeCompare(b)
|
|
330
|
+
const ia = getGroupOrder().indexOf(a);
|
|
331
|
+
const ib = getGroupOrder().indexOf(b);
|
|
332
|
+
if (ia !== -1 && ib !== -1) return ia - ib;
|
|
333
|
+
if (ia !== -1) return -1;
|
|
334
|
+
if (ib !== -1) return 1;
|
|
335
|
+
return a.localeCompare(b);
|
|
408
336
|
})
|
|
409
337
|
.map((group) => ({
|
|
410
338
|
group,
|
|
411
339
|
meta: getGroupMeta(group),
|
|
412
340
|
items: grouped[group].sort((x, y) => x.name.localeCompare(y.name)),
|
|
413
|
-
}))
|
|
341
|
+
}));
|
|
414
342
|
|
|
415
343
|
return {
|
|
416
344
|
sections: orderedSections,
|
|
417
345
|
filteredResourceTotal: rows.length,
|
|
418
346
|
isEmpty: false,
|
|
419
|
-
}
|
|
420
|
-
}, [resources, actionMap, searchTerm])
|
|
347
|
+
};
|
|
348
|
+
}, [resources, actionMap, searchTerm]);
|
|
421
349
|
|
|
422
350
|
const toggleGroup = (group: string) => {
|
|
423
351
|
setCollapsedGroups((prev) => {
|
|
424
|
-
const next = new Set(prev)
|
|
425
|
-
if (next.has(group)) next.delete(group)
|
|
426
|
-
else next.add(group)
|
|
427
|
-
return next
|
|
428
|
-
})
|
|
429
|
-
}
|
|
352
|
+
const next = new Set(prev);
|
|
353
|
+
if (next.has(group)) next.delete(group);
|
|
354
|
+
else next.add(group);
|
|
355
|
+
return next;
|
|
356
|
+
});
|
|
357
|
+
};
|
|
430
358
|
|
|
431
359
|
// --- Resource Actions ---
|
|
432
360
|
const handleUpdateActions = async (
|
|
433
361
|
resource: Resource,
|
|
434
|
-
newActions: string[]
|
|
362
|
+
newActions: string[],
|
|
435
363
|
) => {
|
|
436
364
|
try {
|
|
437
365
|
// Optimistic Update
|
|
438
366
|
setResources((prev) =>
|
|
439
367
|
prev.map((r) =>
|
|
440
|
-
r.id === resource.id ? { ...r, actions: newActions } : r
|
|
441
|
-
)
|
|
442
|
-
)
|
|
368
|
+
r.id === resource.id ? { ...r, actions: newActions } : r,
|
|
369
|
+
),
|
|
370
|
+
);
|
|
443
371
|
|
|
444
|
-
let currentConfig: any = {}
|
|
372
|
+
let currentConfig: any = {};
|
|
445
373
|
try {
|
|
446
374
|
if (typeof resource.config === "string") {
|
|
447
|
-
currentConfig = JSON.parse(resource.config)
|
|
375
|
+
currentConfig = JSON.parse(resource.config);
|
|
448
376
|
} else if (typeof resource.config === "object") {
|
|
449
|
-
currentConfig = resource.config || {}
|
|
377
|
+
currentConfig = resource.config || {};
|
|
450
378
|
}
|
|
451
379
|
} catch (_catchError) {
|
|
452
380
|
// Ignore invalid config JSON
|
|
453
381
|
}
|
|
454
382
|
|
|
455
|
-
const newConfig = { ...currentConfig, actions: newActions }
|
|
383
|
+
const newConfig = { ...currentConfig, actions: newActions };
|
|
456
384
|
|
|
457
385
|
const res = await fetch(`/api/resources/${resource.id}`, {
|
|
458
386
|
method: "PUT",
|
|
@@ -461,253 +389,232 @@ export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {
|
|
|
461
389
|
...resource,
|
|
462
390
|
config: JSON.stringify(newConfig),
|
|
463
391
|
}),
|
|
464
|
-
})
|
|
392
|
+
});
|
|
465
393
|
|
|
466
394
|
if (!res.ok) {
|
|
467
|
-
await throwFetchError(res, "Update failed")
|
|
395
|
+
await throwFetchError(res, "Update failed");
|
|
468
396
|
}
|
|
469
397
|
} catch (error) {
|
|
470
|
-
toast.error(errorMessage(error, "Không thể cập nhật quyền"))
|
|
398
|
+
toast.error(errorMessage(error, "Không thể cập nhật quyền"));
|
|
471
399
|
}
|
|
472
|
-
}
|
|
400
|
+
};
|
|
473
401
|
|
|
474
402
|
const toggleAction = (resource: Resource, actionCode: string) => {
|
|
475
|
-
const currentActions = resource.actions || []
|
|
403
|
+
const currentActions = resource.actions || [];
|
|
476
404
|
const newActions = currentActions.includes(actionCode)
|
|
477
405
|
? currentActions.filter((a) => a !== actionCode)
|
|
478
|
-
: [...currentActions, actionCode]
|
|
479
|
-
handleUpdateActions(resource, newActions)
|
|
480
|
-
}
|
|
406
|
+
: [...currentActions, actionCode];
|
|
407
|
+
handleUpdateActions(resource, newActions);
|
|
408
|
+
};
|
|
481
409
|
|
|
482
410
|
const toggleRow = (id: string) => {
|
|
483
411
|
setExpandedRows((prev) => {
|
|
484
|
-
const next = new Set(prev)
|
|
485
|
-
if (next.has(id)) next.delete(id)
|
|
486
|
-
else next.add(id)
|
|
487
|
-
return next
|
|
488
|
-
})
|
|
489
|
-
}
|
|
412
|
+
const next = new Set(prev);
|
|
413
|
+
if (next.has(id)) next.delete(id);
|
|
414
|
+
else next.add(id);
|
|
415
|
+
return next;
|
|
416
|
+
});
|
|
417
|
+
};
|
|
490
418
|
|
|
491
419
|
// --- Resource Dialog Handlers ---
|
|
492
420
|
const openNewResource = () => {
|
|
493
|
-
setEditingResource(null)
|
|
494
|
-
setFormData({ code: "", name: "", group: "", description: "" })
|
|
495
|
-
setIsDialogOpen(true)
|
|
496
|
-
}
|
|
421
|
+
setEditingResource(null);
|
|
422
|
+
setFormData({ code: "", name: "", group: "", description: "" });
|
|
423
|
+
setIsDialogOpen(true);
|
|
424
|
+
};
|
|
497
425
|
|
|
498
426
|
const openEditResource = (resource: Resource) => {
|
|
499
|
-
setEditingResource(resource)
|
|
427
|
+
setEditingResource(resource);
|
|
500
428
|
setFormData({
|
|
501
429
|
code: resource.code,
|
|
502
430
|
name: resource.name,
|
|
503
431
|
group: resource.group || "",
|
|
504
432
|
description: resource.description || "",
|
|
505
|
-
})
|
|
506
|
-
setIsDialogOpen(true)
|
|
507
|
-
}
|
|
433
|
+
});
|
|
434
|
+
setIsDialogOpen(true);
|
|
435
|
+
};
|
|
508
436
|
|
|
509
437
|
const handleSubmitResource = async () => {
|
|
510
438
|
if (!formData.code || !formData.name) {
|
|
511
|
-
toast.error("Vui lòng nhập Mã và Tên tài nguyên")
|
|
512
|
-
return
|
|
439
|
+
toast.error("Vui lòng nhập Mã và Tên tài nguyên");
|
|
440
|
+
return;
|
|
513
441
|
}
|
|
514
442
|
|
|
515
443
|
try {
|
|
516
444
|
const payload = {
|
|
517
445
|
...formData,
|
|
518
446
|
status: "active",
|
|
519
|
-
}
|
|
447
|
+
};
|
|
520
448
|
|
|
521
|
-
let res
|
|
449
|
+
let res;
|
|
522
450
|
if (editingResource) {
|
|
523
451
|
res = await fetch(`/api/resources/${editingResource.id}`, {
|
|
524
452
|
method: "PUT",
|
|
525
453
|
headers: { "Content-Type": "application/json" },
|
|
526
454
|
body: JSON.stringify({ id: editingResource.id, ...payload }),
|
|
527
|
-
})
|
|
455
|
+
});
|
|
528
456
|
} else {
|
|
529
457
|
res = await fetch("/api/resources", {
|
|
530
458
|
method: "POST",
|
|
531
459
|
headers: { "Content-Type": "application/json" },
|
|
532
460
|
body: JSON.stringify(payload),
|
|
533
|
-
})
|
|
461
|
+
});
|
|
534
462
|
}
|
|
535
463
|
|
|
536
464
|
if (!res.ok) {
|
|
537
|
-
await throwFetchError(res, "Operation failed")
|
|
465
|
+
await throwFetchError(res, "Operation failed");
|
|
538
466
|
}
|
|
539
467
|
|
|
540
|
-
const savedResource = await res.json()
|
|
468
|
+
const savedResource = await res.json();
|
|
541
469
|
toast.success(
|
|
542
|
-
editingResource ? "Cập nhật thành công" : "Tạo mới thành công"
|
|
543
|
-
)
|
|
470
|
+
editingResource ? "Cập nhật thành công" : "Tạo mới thành công",
|
|
471
|
+
);
|
|
544
472
|
|
|
545
473
|
if (editingResource) {
|
|
546
474
|
setResources((prev) =>
|
|
547
475
|
prev.map((r) =>
|
|
548
476
|
r.id === savedResource.id
|
|
549
477
|
? { ...savedResource, actions: r.actions }
|
|
550
|
-
: r
|
|
551
|
-
)
|
|
552
|
-
)
|
|
478
|
+
: r,
|
|
479
|
+
),
|
|
480
|
+
);
|
|
553
481
|
} else {
|
|
554
|
-
setResources((prev) => [{ ...savedResource, actions: [] }, ...prev])
|
|
482
|
+
setResources((prev) => [{ ...savedResource, actions: [] }, ...prev]);
|
|
555
483
|
}
|
|
556
484
|
|
|
557
|
-
setIsDialogOpen(false)
|
|
485
|
+
setIsDialogOpen(false);
|
|
558
486
|
} catch (error: any) {
|
|
559
|
-
toast.error(errorMessage(error, "Có lỗi xảy ra"))
|
|
487
|
+
toast.error(errorMessage(error, "Có lỗi xảy ra"));
|
|
560
488
|
}
|
|
561
|
-
}
|
|
489
|
+
};
|
|
562
490
|
|
|
563
491
|
// --- Action Manager Handlers ---
|
|
564
492
|
const openActionManager = () => {
|
|
565
|
-
setIsActionManagerOpen(true)
|
|
566
|
-
setActionFormMode("list")
|
|
567
|
-
}
|
|
493
|
+
setIsActionManagerOpen(true);
|
|
494
|
+
setActionFormMode("list");
|
|
495
|
+
};
|
|
568
496
|
|
|
569
497
|
const openNewAction = () => {
|
|
570
|
-
setEditingAction(null)
|
|
571
|
-
setActionFormData({ code: "", name: "", description: "" })
|
|
572
|
-
setActionFormMode("create")
|
|
573
|
-
}
|
|
498
|
+
setEditingAction(null);
|
|
499
|
+
setActionFormData({ code: "", name: "", description: "" });
|
|
500
|
+
setActionFormMode("create");
|
|
501
|
+
};
|
|
574
502
|
|
|
575
503
|
const openEditAction = (action: Action) => {
|
|
576
|
-
setEditingAction(action)
|
|
504
|
+
setEditingAction(action);
|
|
577
505
|
setActionFormData({
|
|
578
506
|
code: action.code,
|
|
579
507
|
name: action.name,
|
|
580
508
|
description: action.description || "",
|
|
581
|
-
})
|
|
582
|
-
setActionFormMode("edit")
|
|
583
|
-
}
|
|
509
|
+
});
|
|
510
|
+
setActionFormMode("edit");
|
|
511
|
+
};
|
|
584
512
|
|
|
585
513
|
const handleSubmitAction = async () => {
|
|
586
514
|
if (!actionFormData.code || !actionFormData.name) {
|
|
587
|
-
toast.error("Vui lòng nhập Mã và Tên hành động")
|
|
588
|
-
return
|
|
515
|
+
toast.error("Vui lòng nhập Mã và Tên hành động");
|
|
516
|
+
return;
|
|
589
517
|
}
|
|
590
518
|
|
|
591
519
|
try {
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
let res
|
|
599
|
-
if (actionFormMode === "edit" && editingAction) {
|
|
600
|
-
res = await fetch("/api/actions", {
|
|
601
|
-
method: "PUT",
|
|
602
|
-
headers: { "Content-Type": "application/json" },
|
|
603
|
-
body: JSON.stringify({ id: editingAction.id, ...payload }),
|
|
604
|
-
})
|
|
605
|
-
} else {
|
|
606
|
-
res = await fetch("/api/actions", {
|
|
607
|
-
method: "POST",
|
|
608
|
-
headers: { "Content-Type": "application/json" },
|
|
609
|
-
body: JSON.stringify(payload),
|
|
610
|
-
})
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
if (!res.ok) {
|
|
614
|
-
await throwFetchError(res, "Operation failed")
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
const savedAction = await res.json()
|
|
520
|
+
const savedAction = await saveAction<Action>(
|
|
521
|
+
actionFormData,
|
|
522
|
+
actionFormMode === "edit" && editingAction
|
|
523
|
+
? editingAction.id
|
|
524
|
+
: undefined,
|
|
525
|
+
);
|
|
618
526
|
|
|
619
527
|
if (actionFormMode === "edit") {
|
|
620
528
|
setAllActions((prev) =>
|
|
621
|
-
prev.map((a) => (a.id === savedAction.id ? savedAction : a))
|
|
622
|
-
)
|
|
623
|
-
toast.success("Cập nhật hành động thành công")
|
|
529
|
+
prev.map((a) => (a.id === savedAction.id ? savedAction : a)),
|
|
530
|
+
);
|
|
531
|
+
toast.success("Cập nhật hành động thành công");
|
|
624
532
|
} else {
|
|
625
|
-
setAllActions((prev) => [savedAction, ...prev])
|
|
626
|
-
toast.success("Tạo hành động thành công")
|
|
533
|
+
setAllActions((prev) => [savedAction, ...prev]);
|
|
534
|
+
toast.success("Tạo hành động thành công");
|
|
627
535
|
}
|
|
628
|
-
setActionFormMode("list")
|
|
536
|
+
setActionFormMode("list");
|
|
629
537
|
} catch (error: any) {
|
|
630
|
-
toast.error(errorMessage(error, "Có lỗi xảy ra"))
|
|
538
|
+
toast.error(errorMessage(error, "Có lỗi xảy ra"));
|
|
631
539
|
}
|
|
632
|
-
}
|
|
540
|
+
};
|
|
633
541
|
|
|
634
542
|
const handleDeleteAction = (id: string, code: string) => {
|
|
635
|
-
setItemToDelete({ id, name: code })
|
|
636
|
-
setConfirmType("action")
|
|
637
|
-
setConfirmOpen(true)
|
|
638
|
-
}
|
|
543
|
+
setItemToDelete({ id, name: code });
|
|
544
|
+
setConfirmType("action");
|
|
545
|
+
setConfirmOpen(true);
|
|
546
|
+
};
|
|
639
547
|
|
|
640
548
|
const handleDeleteResource = () => {
|
|
641
|
-
if (!editingResource) return
|
|
642
|
-
setItemToDelete({ id: editingResource.id, name: editingResource.code })
|
|
643
|
-
setConfirmType("resource")
|
|
644
|
-
setConfirmOpen(true)
|
|
645
|
-
}
|
|
549
|
+
if (!editingResource) return;
|
|
550
|
+
setItemToDelete({ id: editingResource.id, name: editingResource.code });
|
|
551
|
+
setConfirmType("resource");
|
|
552
|
+
setConfirmOpen(true);
|
|
553
|
+
};
|
|
646
554
|
|
|
647
555
|
// Xóa trực tiếp từ thẻ (không cần mở dialog sửa)
|
|
648
556
|
const requestDeleteResource = (resource: Resource) => {
|
|
649
|
-
setItemToDelete({ id: resource.id, name: resource.code })
|
|
650
|
-
setConfirmType("resource")
|
|
651
|
-
setConfirmOpen(true)
|
|
652
|
-
}
|
|
557
|
+
setItemToDelete({ id: resource.id, name: resource.code });
|
|
558
|
+
setConfirmType("resource");
|
|
559
|
+
setConfirmOpen(true);
|
|
560
|
+
};
|
|
653
561
|
|
|
654
562
|
const handleConfirmDelete = async () => {
|
|
655
|
-
if (!itemToDelete || !confirmType) return
|
|
656
|
-
setIsDeleting(true)
|
|
563
|
+
if (!itemToDelete || !confirmType) return;
|
|
564
|
+
setIsDeleting(true);
|
|
657
565
|
try {
|
|
658
566
|
if (confirmType === "action") {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
if (!res.ok) {
|
|
663
|
-
await throwFetchError(res, "Delete failed")
|
|
664
|
-
}
|
|
665
|
-
setAllActions((prev) => prev.filter((a) => a.id !== itemToDelete.id))
|
|
666
|
-
toast.success("Xóa hành động thành công")
|
|
567
|
+
await deleteActionById(itemToDelete.id);
|
|
568
|
+
setAllActions((prev) => prev.filter((a) => a.id !== itemToDelete.id));
|
|
569
|
+
toast.success("Xóa hành động thành công");
|
|
667
570
|
} else if (confirmType === "resource") {
|
|
668
571
|
const res = await fetch(`/api/resources?id=${itemToDelete.id}`, {
|
|
669
572
|
method: "DELETE",
|
|
670
|
-
})
|
|
573
|
+
});
|
|
671
574
|
if (!res.ok) {
|
|
672
|
-
await throwFetchError(res, "Delete failed")
|
|
575
|
+
await throwFetchError(res, "Delete failed");
|
|
673
576
|
}
|
|
674
|
-
setResources((prev) => prev.filter((r) => r.id !== itemToDelete.id))
|
|
675
|
-
toast.success("Xóa tài nguyên thành công")
|
|
676
|
-
setIsDialogOpen(false)
|
|
577
|
+
setResources((prev) => prev.filter((r) => r.id !== itemToDelete.id));
|
|
578
|
+
toast.success("Xóa tài nguyên thành công");
|
|
579
|
+
setIsDialogOpen(false);
|
|
677
580
|
}
|
|
678
|
-
setConfirmOpen(false)
|
|
679
|
-
setItemToDelete(null)
|
|
680
|
-
setConfirmType(null)
|
|
581
|
+
setConfirmOpen(false);
|
|
582
|
+
setItemToDelete(null);
|
|
583
|
+
setConfirmType(null);
|
|
681
584
|
} catch (error) {
|
|
682
|
-
toast.error(
|
|
683
|
-
|
|
684
|
-
|
|
585
|
+
toast.error(
|
|
586
|
+
errorMessage(
|
|
587
|
+
error,
|
|
588
|
+
`Không thể xóa ${
|
|
589
|
+
confirmType === "action" ? "hành động" : "tài nguyên"
|
|
590
|
+
}`,
|
|
591
|
+
),
|
|
592
|
+
);
|
|
685
593
|
} finally {
|
|
686
|
-
setIsDeleting(false)
|
|
594
|
+
setIsDeleting(false);
|
|
687
595
|
}
|
|
688
|
-
}
|
|
596
|
+
};
|
|
689
597
|
|
|
690
|
-
const isSearching = searchTerm.trim().length > 0
|
|
691
|
-
const hasNoResources = !loading && resources.length === 0
|
|
598
|
+
const isSearching = searchTerm.trim().length > 0;
|
|
599
|
+
const hasNoResources = !loading && resources.length === 0;
|
|
692
600
|
|
|
693
601
|
const totalGroups = new Set(
|
|
694
|
-
resources.map((r) => (r.group || "other").toLowerCase())
|
|
695
|
-
).size
|
|
602
|
+
resources.map((r) => (r.group || "other").toLowerCase()),
|
|
603
|
+
).size;
|
|
696
604
|
|
|
697
605
|
return (
|
|
698
606
|
<div className="flex flex-col">
|
|
699
607
|
{/* ===== Bar navy 1 dòng — số liệu + tiện ích + Thêm tài nguyên ===== */}
|
|
700
|
-
<
|
|
701
|
-
<
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
</span>
|
|
608
|
+
<RbacPageBar
|
|
609
|
+
icon={<Shield className="h-4 w-4 text-primary-foreground/80" />}
|
|
610
|
+
title="Quản lý tài nguyên"
|
|
611
|
+
subtitle={
|
|
612
|
+
<>
|
|
613
|
+
{resources.length} tài nguyên · {totalGroups} nhóm ·{" "}
|
|
614
|
+
{allActions.length} hành động
|
|
615
|
+
</>
|
|
616
|
+
}
|
|
617
|
+
>
|
|
711
618
|
<Button
|
|
712
619
|
variant="ghost"
|
|
713
620
|
size="icon"
|
|
@@ -753,7 +660,7 @@ export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {
|
|
|
753
660
|
</Button>
|
|
754
661
|
</>
|
|
755
662
|
)}
|
|
756
|
-
</
|
|
663
|
+
</RbacPageBar>
|
|
757
664
|
|
|
758
665
|
{readOnly && (
|
|
759
666
|
<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">
|
|
@@ -774,73 +681,17 @@ export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {
|
|
|
774
681
|
{/* ===== Khối trắng liền: toolbar sticky + nhóm + hàng tài nguyên ===== */}
|
|
775
682
|
<div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
|
|
776
683
|
{/* Toolbar sticky */}
|
|
777
|
-
<
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
type="button"
|
|
789
|
-
onClick={() => setSearchTerm("")}
|
|
790
|
-
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
791
|
-
title="Xóa tìm kiếm"
|
|
792
|
-
>
|
|
793
|
-
<X className="h-3.5 w-3.5" />
|
|
794
|
-
</button>
|
|
795
|
-
)}
|
|
796
|
-
</div>
|
|
797
|
-
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
|
|
798
|
-
{isSearching && !isEmpty ? (
|
|
799
|
-
<>
|
|
800
|
-
<span className="font-semibold tabular-nums text-foreground">
|
|
801
|
-
{filteredResourceTotal}
|
|
802
|
-
</span>{" "}
|
|
803
|
-
khớp
|
|
804
|
-
</>
|
|
805
|
-
) : (
|
|
806
|
-
<>
|
|
807
|
-
<span className="font-semibold tabular-nums text-foreground">
|
|
808
|
-
{resources.length}
|
|
809
|
-
</span>{" "}
|
|
810
|
-
tài nguyên
|
|
811
|
-
</>
|
|
812
|
-
)}
|
|
813
|
-
</p>
|
|
814
|
-
{/* Chuyển dạng hiển thị: dòng / thẻ */}
|
|
815
|
-
<div className="flex shrink-0 overflow-hidden rounded-md border border-border">
|
|
816
|
-
<button
|
|
817
|
-
type="button"
|
|
818
|
-
onClick={() => setView("list")}
|
|
819
|
-
title="Dạng dòng"
|
|
820
|
-
className={cn(
|
|
821
|
-
"px-2 py-1.5 transition-colors",
|
|
822
|
-
view === "list"
|
|
823
|
-
? "bg-muted text-foreground"
|
|
824
|
-
: "bg-card text-muted-foreground hover:text-foreground"
|
|
825
|
-
)}
|
|
826
|
-
>
|
|
827
|
-
<List className="h-3.5 w-3.5" />
|
|
828
|
-
</button>
|
|
829
|
-
<button
|
|
830
|
-
type="button"
|
|
831
|
-
onClick={() => setView("grid")}
|
|
832
|
-
title="Dạng thẻ"
|
|
833
|
-
className={cn(
|
|
834
|
-
"border-l border-border px-2 py-1.5 transition-colors",
|
|
835
|
-
view === "grid"
|
|
836
|
-
? "bg-muted text-foreground"
|
|
837
|
-
: "bg-card text-muted-foreground hover:text-foreground"
|
|
838
|
-
)}
|
|
839
|
-
>
|
|
840
|
-
<LayoutGrid className="h-3.5 w-3.5" />
|
|
841
|
-
</button>
|
|
842
|
-
</div>
|
|
843
|
-
</div>
|
|
684
|
+
<RbacStickyToolbar
|
|
685
|
+
searchTerm={searchTerm}
|
|
686
|
+
onSearchChange={setSearchTerm}
|
|
687
|
+
searchPlaceholder="Tìm tài nguyên hoặc hành động…"
|
|
688
|
+
searchMaxWidthClass="sm:max-w-80"
|
|
689
|
+
count={resources.length}
|
|
690
|
+
countLabel="tài nguyên"
|
|
691
|
+
matchedCount={isEmpty ? undefined : filteredResourceTotal}
|
|
692
|
+
view={view}
|
|
693
|
+
onViewChange={setView}
|
|
694
|
+
/>
|
|
844
695
|
|
|
845
696
|
{/* --- Body --- */}
|
|
846
697
|
{loading && !isRefreshing ? (
|
|
@@ -857,8 +708,8 @@ export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {
|
|
|
857
708
|
/>
|
|
858
709
|
) : (
|
|
859
710
|
sections.map((section) => {
|
|
860
|
-
const collapsed = collapsedGroups.has(section.group)
|
|
861
|
-
const GroupIcon = section.meta.icon
|
|
711
|
+
const collapsed = collapsedGroups.has(section.group);
|
|
712
|
+
const GroupIcon = section.meta.icon;
|
|
862
713
|
return (
|
|
863
714
|
<section key={section.group}>
|
|
864
715
|
{/* Dòng tiêu đề nhóm — mảnh, trong cùng khối */}
|
|
@@ -875,7 +726,7 @@ export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {
|
|
|
875
726
|
<span
|
|
876
727
|
className={cn(
|
|
877
728
|
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
|
|
878
|
-
section.meta.iconCls
|
|
729
|
+
section.meta.iconCls,
|
|
879
730
|
)}
|
|
880
731
|
>
|
|
881
732
|
<GroupIcon className="h-3.5 w-3.5" />
|
|
@@ -928,7 +779,7 @@ export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {
|
|
|
928
779
|
</div>
|
|
929
780
|
))}
|
|
930
781
|
</section>
|
|
931
|
-
)
|
|
782
|
+
);
|
|
932
783
|
})
|
|
933
784
|
)}
|
|
934
785
|
</div>
|
|
@@ -1195,7 +1046,7 @@ export function ResourceListPage({ readOnly = false }: ResourceListPageProps = {
|
|
|
1195
1046
|
loading={isDeleting}
|
|
1196
1047
|
/>
|
|
1197
1048
|
</div>
|
|
1198
|
-
)
|
|
1049
|
+
);
|
|
1199
1050
|
}
|
|
1200
1051
|
|
|
1201
1052
|
// --- Editor quyền của 1 tài nguyên (dùng chung cho dòng bung + thẻ bung):
|
|
@@ -1207,17 +1058,17 @@ function ResourceActionsEditor({
|
|
|
1207
1058
|
onToggleAction,
|
|
1208
1059
|
onUpdateActions,
|
|
1209
1060
|
}: {
|
|
1210
|
-
resource: Resource
|
|
1211
|
-
allActions: Action[]
|
|
1212
|
-
categorized: { basic: Action[]; functional: Action[] }
|
|
1213
|
-
onToggleAction: (resource: Resource, actionCode: string) => void
|
|
1214
|
-
onUpdateActions: (resource: Resource, actions: string[]) => void
|
|
1061
|
+
resource: Resource;
|
|
1062
|
+
allActions: Action[];
|
|
1063
|
+
categorized: { basic: Action[]; functional: Action[] };
|
|
1064
|
+
onToggleAction: (resource: Resource, actionCode: string) => void;
|
|
1065
|
+
onUpdateActions: (resource: Resource, actions: string[]) => void;
|
|
1215
1066
|
}) {
|
|
1216
|
-
const granted = resource.actions || []
|
|
1217
|
-
const grantedSet = new Set(granted)
|
|
1067
|
+
const granted = resource.actions || [];
|
|
1068
|
+
const grantedSet = new Set(granted);
|
|
1218
1069
|
|
|
1219
1070
|
const renderItem = (action: Action) => {
|
|
1220
|
-
const on = grantedSet.has(action.code)
|
|
1071
|
+
const on = grantedSet.has(action.code);
|
|
1221
1072
|
return (
|
|
1222
1073
|
<button
|
|
1223
1074
|
key={action.code}
|
|
@@ -1230,7 +1081,7 @@ function ResourceActionsEditor({
|
|
|
1230
1081
|
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border",
|
|
1231
1082
|
on
|
|
1232
1083
|
? ACTION_TONES[getActionTone(action.code)]
|
|
1233
|
-
: "border-border bg-transparent text-transparent"
|
|
1084
|
+
: "border-border bg-transparent text-transparent",
|
|
1234
1085
|
)}
|
|
1235
1086
|
>
|
|
1236
1087
|
<Check className="h-3 w-3" />
|
|
@@ -1239,7 +1090,7 @@ function ResourceActionsEditor({
|
|
|
1239
1090
|
<span
|
|
1240
1091
|
className={cn(
|
|
1241
1092
|
"block text-xs leading-tight",
|
|
1242
|
-
on ? "font-medium text-foreground" : "text-muted-foreground"
|
|
1093
|
+
on ? "font-medium text-foreground" : "text-muted-foreground",
|
|
1243
1094
|
)}
|
|
1244
1095
|
>
|
|
1245
1096
|
{action.name}
|
|
@@ -1251,13 +1102,13 @@ function ResourceActionsEditor({
|
|
|
1251
1102
|
)}
|
|
1252
1103
|
</span>
|
|
1253
1104
|
</button>
|
|
1254
|
-
)
|
|
1255
|
-
}
|
|
1105
|
+
);
|
|
1106
|
+
};
|
|
1256
1107
|
|
|
1257
1108
|
const renderCategory = (label: string, list: Action[]) => {
|
|
1258
|
-
if (list.length === 0) return null
|
|
1259
|
-
const grantedList = list.filter((a) => grantedSet.has(a.code))
|
|
1260
|
-
const ungrantedList = list.filter((a) => !grantedSet.has(a.code))
|
|
1109
|
+
if (list.length === 0) return null;
|
|
1110
|
+
const grantedList = list.filter((a) => grantedSet.has(a.code));
|
|
1111
|
+
const ungrantedList = list.filter((a) => !grantedSet.has(a.code));
|
|
1261
1112
|
return (
|
|
1262
1113
|
<div key={label}>
|
|
1263
1114
|
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-foreground/70">
|
|
@@ -1286,8 +1137,8 @@ function ResourceActionsEditor({
|
|
|
1286
1137
|
)}
|
|
1287
1138
|
</div>
|
|
1288
1139
|
</div>
|
|
1289
|
-
)
|
|
1290
|
-
}
|
|
1140
|
+
);
|
|
1141
|
+
};
|
|
1291
1142
|
|
|
1292
1143
|
return (
|
|
1293
1144
|
<div>
|
|
@@ -1305,7 +1156,7 @@ function ResourceActionsEditor({
|
|
|
1305
1156
|
onClick={() =>
|
|
1306
1157
|
onUpdateActions(
|
|
1307
1158
|
resource,
|
|
1308
|
-
allActions.map((a) => a.code)
|
|
1159
|
+
allActions.map((a) => a.code),
|
|
1309
1160
|
)
|
|
1310
1161
|
}
|
|
1311
1162
|
disabled={granted.length === allActions.length}
|
|
@@ -1335,11 +1186,11 @@ function ResourceActionsEditor({
|
|
|
1335
1186
|
</div>
|
|
1336
1187
|
)}
|
|
1337
1188
|
</div>
|
|
1338
|
-
)
|
|
1189
|
+
);
|
|
1339
1190
|
}
|
|
1340
1191
|
|
|
1341
1192
|
// --- Resource row (chế độ dòng): tóm tắt quyền; bấm để bung editor ---
|
|
1342
|
-
const MAX_CHIPS = 8
|
|
1193
|
+
const MAX_CHIPS = 8;
|
|
1343
1194
|
|
|
1344
1195
|
function ResourceRow({
|
|
1345
1196
|
resource,
|
|
@@ -1352,19 +1203,19 @@ function ResourceRow({
|
|
|
1352
1203
|
onUpdateActions,
|
|
1353
1204
|
onEditResource,
|
|
1354
1205
|
}: {
|
|
1355
|
-
resource: Resource
|
|
1356
|
-
actionMap: Map<string, Action
|
|
1357
|
-
allActions: Action[]
|
|
1358
|
-
categorized: { basic: Action[]; functional: Action[] }
|
|
1359
|
-
expanded: boolean
|
|
1360
|
-
onToggleExpand: () => void
|
|
1361
|
-
onToggleAction: (resource: Resource, actionCode: string) => void
|
|
1362
|
-
onUpdateActions: (resource: Resource, actions: string[]) => void
|
|
1363
|
-
onEditResource: (resource: Resource) => void
|
|
1206
|
+
resource: Resource;
|
|
1207
|
+
actionMap: Map<string, Action>;
|
|
1208
|
+
allActions: Action[];
|
|
1209
|
+
categorized: { basic: Action[]; functional: Action[] };
|
|
1210
|
+
expanded: boolean;
|
|
1211
|
+
onToggleExpand: () => void;
|
|
1212
|
+
onToggleAction: (resource: Resource, actionCode: string) => void;
|
|
1213
|
+
onUpdateActions: (resource: Resource, actions: string[]) => void;
|
|
1214
|
+
onEditResource: (resource: Resource) => void;
|
|
1364
1215
|
}) {
|
|
1365
|
-
const granted = resource.actions || []
|
|
1366
|
-
const shown = granted.slice(0, MAX_CHIPS)
|
|
1367
|
-
const overflow = granted.length - shown.length
|
|
1216
|
+
const granted = resource.actions || [];
|
|
1217
|
+
const shown = granted.slice(0, MAX_CHIPS);
|
|
1218
|
+
const overflow = granted.length - shown.length;
|
|
1368
1219
|
|
|
1369
1220
|
return (
|
|
1370
1221
|
<li>
|
|
@@ -1375,8 +1226,8 @@ function ResourceRow({
|
|
|
1375
1226
|
onClick={onToggleExpand}
|
|
1376
1227
|
onKeyDown={(e) => {
|
|
1377
1228
|
if (e.key === "Enter" || e.key === " ") {
|
|
1378
|
-
e.preventDefault()
|
|
1379
|
-
onToggleExpand()
|
|
1229
|
+
e.preventDefault();
|
|
1230
|
+
onToggleExpand();
|
|
1380
1231
|
}
|
|
1381
1232
|
}}
|
|
1382
1233
|
className="flex cursor-pointer items-center gap-3 bg-card px-4 py-3 transition-colors hover:bg-muted/40"
|
|
@@ -1385,7 +1236,7 @@ function ResourceRow({
|
|
|
1385
1236
|
<div
|
|
1386
1237
|
className={cn(
|
|
1387
1238
|
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md",
|
|
1388
|
-
getGroupMeta(resource.group).iconCls
|
|
1239
|
+
getGroupMeta(resource.group).iconCls,
|
|
1389
1240
|
)}
|
|
1390
1241
|
>
|
|
1391
1242
|
<DynamicIcon
|
|
@@ -1420,7 +1271,7 @@ function ResourceRow({
|
|
|
1420
1271
|
key={code}
|
|
1421
1272
|
className={cn(
|
|
1422
1273
|
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px]",
|
|
1423
|
-
ACTION_TONES[getActionTone(code)]
|
|
1274
|
+
ACTION_TONES[getActionTone(code)],
|
|
1424
1275
|
)}
|
|
1425
1276
|
>
|
|
1426
1277
|
{actionMap.get(code)?.name || code}
|
|
@@ -1444,8 +1295,8 @@ function ResourceRow({
|
|
|
1444
1295
|
<button
|
|
1445
1296
|
type="button"
|
|
1446
1297
|
onClick={(e) => {
|
|
1447
|
-
e.stopPropagation()
|
|
1448
|
-
onEditResource(resource)
|
|
1298
|
+
e.stopPropagation();
|
|
1299
|
+
onEditResource(resource);
|
|
1449
1300
|
}}
|
|
1450
1301
|
className="shrink-0 rounded-md border border-border bg-card p-1.5 text-amber-500 shadow-sm transition-colors hover:border-amber-300 hover:bg-amber-50 hover:text-amber-600 dark:hover:border-amber-500/40 dark:hover:bg-amber-500/10"
|
|
1451
1302
|
title="Sửa thông tin tài nguyên"
|
|
@@ -1455,7 +1306,7 @@ function ResourceRow({
|
|
|
1455
1306
|
<ChevronRight
|
|
1456
1307
|
className={cn(
|
|
1457
1308
|
"h-4 w-4 shrink-0 text-muted-foreground/60 transition-transform",
|
|
1458
|
-
expanded && "rotate-90"
|
|
1309
|
+
expanded && "rotate-90",
|
|
1459
1310
|
)}
|
|
1460
1311
|
/>
|
|
1461
1312
|
</div>
|
|
@@ -1473,13 +1324,13 @@ function ResourceRow({
|
|
|
1473
1324
|
</div>
|
|
1474
1325
|
)}
|
|
1475
1326
|
</li>
|
|
1476
|
-
)
|
|
1327
|
+
);
|
|
1477
1328
|
}
|
|
1478
1329
|
|
|
1479
1330
|
// --- Thẻ tài nguyên (grid, MẶC ĐỊNH) — phong cách thẻ vai trò/hành động:
|
|
1480
1331
|
// icon nhóm màu + tên/mã + #stt, chip quyền preview, KHỐI SỐ LIỆU đóng khung,
|
|
1481
1332
|
// hàng nút Sửa/Xóa. Bấm thẻ bung editor quyền (thẻ giãn full hàng). ---
|
|
1482
|
-
const CARD_MAX_CHIPS = 6
|
|
1333
|
+
const CARD_MAX_CHIPS = 6;
|
|
1483
1334
|
|
|
1484
1335
|
function ResourceCard({
|
|
1485
1336
|
index,
|
|
@@ -1494,22 +1345,22 @@ function ResourceCard({
|
|
|
1494
1345
|
onEditResource,
|
|
1495
1346
|
onDeleteResource,
|
|
1496
1347
|
}: {
|
|
1497
|
-
index: number
|
|
1498
|
-
resource: Resource
|
|
1499
|
-
actionMap: Map<string, Action
|
|
1500
|
-
allActions: Action[]
|
|
1501
|
-
categorized: { basic: Action[]; functional: Action[] }
|
|
1502
|
-
expanded: boolean
|
|
1503
|
-
onToggleExpand: () => void
|
|
1504
|
-
onToggleAction: (resource: Resource, actionCode: string) => void
|
|
1505
|
-
onUpdateActions: (resource: Resource, actions: string[]) => void
|
|
1506
|
-
onEditResource: (resource: Resource) => void
|
|
1507
|
-
onDeleteResource: (resource: Resource) => void
|
|
1348
|
+
index: number;
|
|
1349
|
+
resource: Resource;
|
|
1350
|
+
actionMap: Map<string, Action>;
|
|
1351
|
+
allActions: Action[];
|
|
1352
|
+
categorized: { basic: Action[]; functional: Action[] };
|
|
1353
|
+
expanded: boolean;
|
|
1354
|
+
onToggleExpand: () => void;
|
|
1355
|
+
onToggleAction: (resource: Resource, actionCode: string) => void;
|
|
1356
|
+
onUpdateActions: (resource: Resource, actions: string[]) => void;
|
|
1357
|
+
onEditResource: (resource: Resource) => void;
|
|
1358
|
+
onDeleteResource: (resource: Resource) => void;
|
|
1508
1359
|
}) {
|
|
1509
|
-
const granted = resource.actions || []
|
|
1510
|
-
const shown = granted.slice(0, CARD_MAX_CHIPS)
|
|
1511
|
-
const overflow = granted.length - shown.length
|
|
1512
|
-
const ungrantedCount = Math.max(allActions.length - granted.length, 0)
|
|
1360
|
+
const granted = resource.actions || [];
|
|
1361
|
+
const shown = granted.slice(0, CARD_MAX_CHIPS);
|
|
1362
|
+
const overflow = granted.length - shown.length;
|
|
1363
|
+
const ungrantedCount = Math.max(allActions.length - granted.length, 0);
|
|
1513
1364
|
|
|
1514
1365
|
return (
|
|
1515
1366
|
<div
|
|
@@ -1519,13 +1370,13 @@ function ResourceCard({
|
|
|
1519
1370
|
onClick={onToggleExpand}
|
|
1520
1371
|
onKeyDown={(e) => {
|
|
1521
1372
|
if (e.key === "Enter" || e.key === " ") {
|
|
1522
|
-
e.preventDefault()
|
|
1523
|
-
onToggleExpand()
|
|
1373
|
+
e.preventDefault();
|
|
1374
|
+
onToggleExpand();
|
|
1524
1375
|
}
|
|
1525
1376
|
}}
|
|
1526
1377
|
className={cn(
|
|
1527
1378
|
"cursor-pointer overflow-hidden rounded-[12px] border border-border bg-card p-4 shadow-sm transition-all hover:border-primary/40 hover:shadow-md",
|
|
1528
|
-
expanded && "col-span-full border-primary/40"
|
|
1379
|
+
expanded && "col-span-full border-primary/40",
|
|
1529
1380
|
)}
|
|
1530
1381
|
>
|
|
1531
1382
|
{/* Header: icon nhóm màu + tên/mã | #stt */}
|
|
@@ -1534,7 +1385,7 @@ function ResourceCard({
|
|
|
1534
1385
|
<div
|
|
1535
1386
|
className={cn(
|
|
1536
1387
|
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg",
|
|
1537
|
-
getGroupMeta(resource.group).iconCls
|
|
1388
|
+
getGroupMeta(resource.group).iconCls,
|
|
1538
1389
|
)}
|
|
1539
1390
|
>
|
|
1540
1391
|
<DynamicIcon
|
|
@@ -1569,7 +1420,7 @@ function ResourceCard({
|
|
|
1569
1420
|
key={code}
|
|
1570
1421
|
className={cn(
|
|
1571
1422
|
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px]",
|
|
1572
|
-
ACTION_TONES[getActionTone(code)]
|
|
1423
|
+
ACTION_TONES[getActionTone(code)],
|
|
1573
1424
|
)}
|
|
1574
1425
|
>
|
|
1575
1426
|
{actionMap.get(code)?.name || code}
|
|
@@ -1611,8 +1462,8 @@ function ResourceCard({
|
|
|
1611
1462
|
size="sm"
|
|
1612
1463
|
className="h-8 flex-1 rounded-[8px] bg-card text-[12px] font-medium shadow-sm transition-colors hover:border-amber-300 hover:bg-amber-50 dark:hover:border-amber-500/40 dark:hover:bg-amber-500/10"
|
|
1613
1464
|
onClick={(e) => {
|
|
1614
|
-
e.stopPropagation()
|
|
1615
|
-
onEditResource(resource)
|
|
1465
|
+
e.stopPropagation();
|
|
1466
|
+
onEditResource(resource);
|
|
1616
1467
|
}}
|
|
1617
1468
|
>
|
|
1618
1469
|
<Pencil className="mr-1.5 h-3.5 w-3.5 text-amber-500" />
|
|
@@ -1623,8 +1474,8 @@ function ResourceCard({
|
|
|
1623
1474
|
size="sm"
|
|
1624
1475
|
className="h-8 flex-1 rounded-[8px] bg-card text-[12px] font-medium text-destructive shadow-sm transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
1625
1476
|
onClick={(e) => {
|
|
1626
|
-
e.stopPropagation()
|
|
1627
|
-
onDeleteResource(resource)
|
|
1477
|
+
e.stopPropagation();
|
|
1478
|
+
onDeleteResource(resource);
|
|
1628
1479
|
}}
|
|
1629
1480
|
>
|
|
1630
1481
|
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
|
@@ -1648,7 +1499,7 @@ function ResourceCard({
|
|
|
1648
1499
|
</div>
|
|
1649
1500
|
)}
|
|
1650
1501
|
</div>
|
|
1651
|
-
)
|
|
1502
|
+
);
|
|
1652
1503
|
}
|
|
1653
1504
|
|
|
1654
1505
|
// --- Empty state ---
|
|
@@ -1657,9 +1508,9 @@ function EmptyState({
|
|
|
1657
1508
|
onClear,
|
|
1658
1509
|
onCreate,
|
|
1659
1510
|
}: {
|
|
1660
|
-
searching: boolean
|
|
1661
|
-
onClear: () => void
|
|
1662
|
-
onCreate: () => void
|
|
1511
|
+
searching: boolean;
|
|
1512
|
+
onClear: () => void;
|
|
1513
|
+
onCreate: () => void;
|
|
1663
1514
|
}) {
|
|
1664
1515
|
return (
|
|
1665
1516
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
@@ -1689,5 +1540,5 @@ function EmptyState({
|
|
|
1689
1540
|
</Button>
|
|
1690
1541
|
)}
|
|
1691
1542
|
</div>
|
|
1692
|
-
)
|
|
1543
|
+
);
|
|
1693
1544
|
}
|