@goplusvn/core 0.1.66 → 0.1.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +116 -0
- package/bin/goerp-guardrails.mjs +45 -0
- package/eslint/index.mjs +120 -0
- package/package.json +10 -3
- package/scripts/doctor.ts +99 -0
- package/src/auth/__tests__/permissions.test.ts +162 -0
- package/src/auth/index.ts +113 -34
- package/src/guardrails/__tests__/guardrails.test.ts +430 -0
- package/src/guardrails/index.ts +57 -0
- package/src/guardrails/preset.ts +75 -0
- package/src/guardrails/primitives.ts +307 -0
- package/src/guardrails/rules/auth.ts +178 -0
- package/src/guardrails/rules/debt.ts +71 -0
- package/src/guardrails/rules/design.ts +95 -0
- package/src/guardrails/rules/layering.ts +160 -0
- package/src/guardrails/rules/one-door.ts +115 -0
- package/src/guardrails/rules/rbac.ts +282 -0
- package/src/guardrails/rules/safety.ts +86 -0
- package/src/guardrails/rules/structure.ts +136 -0
- package/src/guardrails/run.ts +130 -0
- package/src/guardrails/scanner.ts +144 -0
- package/src/guardrails/types.ts +181 -0
- package/src/rbac/index.ts +6 -9
- package/src/types/index.ts +17 -0
- package/src/ui/data-display/shallow-pagination.tsx +189 -0
- package/src/ui/index.tsx +1 -0
- package/src/user/components/index.ts +1 -0
- package/src/user/components/user-toolbar.tsx +8 -2
- package/src/user/components/user-visuals.tsx +84 -0
- package/src/user/components/users-card-view.tsx +1 -26
- package/src/user/components/users-table.tsx +215 -0
- package/src/user/pages/users-client-page.tsx +84 -259
- package/templates/starter-app/AGENTS.md +39 -3
- package/templates/starter-app/eslint.config.mjs +85 -0
- package/templates/starter-app/package.json +19 -2
- package/templates/starter-app/prettier.config.mjs +54 -0
- package/templates/starter-app/src/__tests__/architecture.test.ts +35 -143
package/src/rbac/index.ts
CHANGED
|
@@ -106,22 +106,19 @@ export { getFromCache, setToCache, pendingQueries };
|
|
|
106
106
|
// Permissions
|
|
107
107
|
// ============================================================================
|
|
108
108
|
|
|
109
|
-
interface ExtendedUser {
|
|
110
|
-
id: string;
|
|
111
|
-
name?: string | null;
|
|
112
|
-
email?: string | null;
|
|
113
|
-
image?: string | null;
|
|
114
|
-
roles?: string[];
|
|
115
|
-
permissions?: Permission[];
|
|
116
|
-
}
|
|
117
|
-
|
|
118
109
|
// Permission checks now live in a single hardened source: @goerp/core/auth
|
|
119
110
|
// (bypass is dev-only; the admin role is honored BEFORE the empty-permissions
|
|
120
111
|
// guard). Re-exported here so existing @goerp/core/rbac consumers are unchanged
|
|
121
112
|
// and can never diverge from auth again.
|
|
113
|
+
//
|
|
114
|
+
// (Ở đây từng có thêm một `interface ExtendedUser` thứ ba, không ai dùng — bản
|
|
115
|
+
// thật nằm ở ../auth.)
|
|
122
116
|
export {
|
|
123
117
|
getUserPermissions,
|
|
124
118
|
getCrudPermissionsFromSession,
|
|
119
|
+
getAccessibleResources,
|
|
120
|
+
toPermissionMap,
|
|
121
|
+
fromPermissionMap,
|
|
125
122
|
checkPermission,
|
|
126
123
|
hasPermission,
|
|
127
124
|
requirePermission,
|
package/src/types/index.ts
CHANGED
|
@@ -164,6 +164,23 @@ export interface Permission {
|
|
|
164
164
|
|
|
165
165
|
export type PermissionType = Permission;
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Bản GỌN của `Permission[]` để nhét vào session: resource → các action.
|
|
169
|
+
*
|
|
170
|
+
* Vì sao cần: một user nhiều vai trò có thể có hàng nghìn cặp
|
|
171
|
+
* resource/action, mà `Permission[]` lặp lại tên resource ở MỌI cặp. Đo trên
|
|
172
|
+
* ERP thật (2.428 quyền / 73 resource): `Permission[]` là 139,7 KB JSON, bản
|
|
173
|
+
* map là 35,5 KB — mà payload đó đi kèm mọi lượt đọc session, kể cả về client.
|
|
174
|
+
*
|
|
175
|
+
* Tra cứu cũng đổi chất: `Permission[]` bắt quét tuyến tính cả mảng cho MỖI
|
|
176
|
+
* lần `checkPermission`, map thì tra thẳng theo resource rồi chỉ quét danh
|
|
177
|
+
* sách action của riêng nó (vài chục phần tử).
|
|
178
|
+
*
|
|
179
|
+
* `ExtendedUser` nhận CẢ HAI: app chưa chuyển vẫn gán `permissions`, mọi hàm
|
|
180
|
+
* kiểm quyền của core đọc `permissionMap` trước rồi mới lùi về `permissions`.
|
|
181
|
+
*/
|
|
182
|
+
export type PermissionMap = Record<string, string[]>;
|
|
183
|
+
|
|
167
184
|
export type PermissionMatrix = Record<string, Record<string, boolean>>;
|
|
168
185
|
|
|
169
186
|
// ============================================================================
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
import type { KeyboardEvent } from "react";
|
|
5
|
+
import { usePathname, useSearchParams } from "next/navigation";
|
|
6
|
+
import {
|
|
7
|
+
ChevronLeft,
|
|
8
|
+
ChevronRight,
|
|
9
|
+
ChevronsLeft,
|
|
10
|
+
ChevronsRight,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
|
|
13
|
+
import { Button } from "../primitives";
|
|
14
|
+
import {
|
|
15
|
+
Select,
|
|
16
|
+
SelectContent,
|
|
17
|
+
SelectItem,
|
|
18
|
+
SelectTrigger,
|
|
19
|
+
SelectValue,
|
|
20
|
+
} from "../primitives/client";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Biến thể NÔNG của `UrlPagination` cho trang mà CLIENT làm chủ dữ liệu (SWR
|
|
24
|
+
* hoặc danh sách đã nạp đủ về client).
|
|
25
|
+
*
|
|
26
|
+
* `UrlPagination` dùng `router.push`, nên đổi trang là một lượt điều hướng RSC:
|
|
27
|
+
* chạy lại page.tsx, `loading.tsx` nháy skeleton TOÀN TRANG, rồi trả về đúng
|
|
28
|
+
* một trang danh sách mà client đã tự cắt/fetch được. Bản này ghi URL bằng
|
|
29
|
+
* `history.replaceState` (Next 14.1+ vẫn đồng bộ `useSearchParams`) nên chỉ
|
|
30
|
+
* client re-render — và đổi trang không tạo mục lịch sử mới, giống đổi bộ lọc.
|
|
31
|
+
*
|
|
32
|
+
* CHỈ dùng cho trang có client đọc `page`/`pageSize` từ `useSearchParams`.
|
|
33
|
+
* Trang SSR thuần vẫn phải dùng `UrlPagination` vì chúng cần lượt RSC mới có
|
|
34
|
+
* dữ liệu. Giao diện giữ giống `DataTablePagination` 1:1 để hai loại trang
|
|
35
|
+
* nhìn như nhau.
|
|
36
|
+
*/
|
|
37
|
+
export function ShallowPagination({
|
|
38
|
+
totalItems,
|
|
39
|
+
currentPage,
|
|
40
|
+
pageSize,
|
|
41
|
+
disabled,
|
|
42
|
+
}: {
|
|
43
|
+
totalItems: number;
|
|
44
|
+
currentPage: number;
|
|
45
|
+
pageSize: number;
|
|
46
|
+
disabled?: boolean;
|
|
47
|
+
}) {
|
|
48
|
+
const pathname = usePathname();
|
|
49
|
+
const searchParams = useSearchParams();
|
|
50
|
+
|
|
51
|
+
const pageCount = Math.ceil(totalItems / pageSize);
|
|
52
|
+
const startItem = (currentPage - 1) * pageSize + 1;
|
|
53
|
+
const endItem = Math.min(currentPage * pageSize, totalItems);
|
|
54
|
+
|
|
55
|
+
const applyParams = useCallback(
|
|
56
|
+
(params: Record<string, string | number | null>) => {
|
|
57
|
+
const next = new URLSearchParams(searchParams?.toString());
|
|
58
|
+
for (const [key, value] of Object.entries(params)) {
|
|
59
|
+
if (value === null) next.delete(key);
|
|
60
|
+
else next.set(key, String(value));
|
|
61
|
+
}
|
|
62
|
+
const qs = next.toString();
|
|
63
|
+
window.history.replaceState(
|
|
64
|
+
null,
|
|
65
|
+
"",
|
|
66
|
+
qs ? `${pathname}?${qs}` : pathname,
|
|
67
|
+
);
|
|
68
|
+
},
|
|
69
|
+
[pathname, searchParams],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const handlePageChange = (page: number) => {
|
|
73
|
+
if (page < 1 || page > Math.max(1, pageCount)) return;
|
|
74
|
+
applyParams({ page });
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const handlePageSizeChange = (value: string) => {
|
|
78
|
+
// Đổi số dòng thì về trang 1, tránh rơi ra ngoài phạm vi
|
|
79
|
+
applyParams({ pageSize: Number(value), page: 1 });
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const [inputPage, setInputPage] = useState(String(currentPage));
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
setInputPage(String(currentPage));
|
|
86
|
+
}, [currentPage]);
|
|
87
|
+
|
|
88
|
+
const handlePageInputBlur = () => {
|
|
89
|
+
const pageNum = Number(inputPage);
|
|
90
|
+
if (isNaN(pageNum) || pageNum < 1 || pageNum > pageCount) {
|
|
91
|
+
setInputPage(String(currentPage));
|
|
92
|
+
} else if (pageNum !== currentPage) {
|
|
93
|
+
handlePageChange(pageNum);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const handlePageInputKeyDown = (e: KeyboardEvent) => {
|
|
98
|
+
if (e.key === "Enter") handlePageInputBlur();
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div className="flex flex-col sm:flex-row items-center justify-between gap-1 sm:gap-2 px-1 sm:px-2">
|
|
103
|
+
<div className="hidden sm:block flex-1 text-[11px] text-left text-muted-foreground w-full">
|
|
104
|
+
Hiển thị {totalItems > 0 ? startItem : 0} -{" "}
|
|
105
|
+
{totalItems > 0 ? endItem : 0} trong tổng số {totalItems} mục
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div className="flex items-center justify-center gap-2 w-full sm:w-auto overflow-x-auto">
|
|
109
|
+
<div className="flex items-center gap-1 px-1 border-r pr-1 sm:pr-1.5">
|
|
110
|
+
<p className="hidden sm:block text-[11px] font-medium whitespace-nowrap">
|
|
111
|
+
Số dòng
|
|
112
|
+
</p>
|
|
113
|
+
<Select
|
|
114
|
+
value={`${pageSize}`}
|
|
115
|
+
onValueChange={handlePageSizeChange}
|
|
116
|
+
disabled={disabled}
|
|
117
|
+
>
|
|
118
|
+
<SelectTrigger className="!h-6 !min-h-0 py-0 w-[60px] sm:w-[65px] text-[11px] px-1.5 focus:ring-inset focus:ring-offset-0">
|
|
119
|
+
<SelectValue placeholder={pageSize} />
|
|
120
|
+
</SelectTrigger>
|
|
121
|
+
<SelectContent side="top">
|
|
122
|
+
{[10, 20, 50, 100, 500, 1000].map((size) => (
|
|
123
|
+
<SelectItem key={size} value={`${size}`}>
|
|
124
|
+
{size}
|
|
125
|
+
</SelectItem>
|
|
126
|
+
))}
|
|
127
|
+
</SelectContent>
|
|
128
|
+
</Select>
|
|
129
|
+
</div>
|
|
130
|
+
<div className="flex items-center gap-1 pl-1">
|
|
131
|
+
<Button
|
|
132
|
+
variant="outline"
|
|
133
|
+
className="hidden h-6 w-6 p-0 lg:flex"
|
|
134
|
+
onClick={() => handlePageChange(1)}
|
|
135
|
+
disabled={currentPage <= 1 || disabled || totalItems === 0}
|
|
136
|
+
>
|
|
137
|
+
<span className="sr-only">Trang đầu</span>
|
|
138
|
+
<ChevronsLeft className="h-3 w-3" />
|
|
139
|
+
</Button>
|
|
140
|
+
<Button
|
|
141
|
+
variant="outline"
|
|
142
|
+
className="h-6 w-6 p-0"
|
|
143
|
+
onClick={() => handlePageChange(currentPage - 1)}
|
|
144
|
+
disabled={currentPage <= 1 || disabled || totalItems === 0}
|
|
145
|
+
>
|
|
146
|
+
<span className="sr-only">Trang trước</span>
|
|
147
|
+
<ChevronLeft className="h-3 w-3" />
|
|
148
|
+
</Button>
|
|
149
|
+
|
|
150
|
+
<div className="flex items-center gap-1 sm:gap-2 px-1">
|
|
151
|
+
<span className="hidden sm:inline text-[11px] font-medium whitespace-nowrap">
|
|
152
|
+
Trang
|
|
153
|
+
</span>
|
|
154
|
+
<input
|
|
155
|
+
className="h-6 w-10 sm:w-12 rounded-md border border-input bg-background px-1 text-[11px] text-center focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
156
|
+
value={inputPage}
|
|
157
|
+
onChange={(e) => setInputPage(e.target.value)}
|
|
158
|
+
onBlur={handlePageInputBlur}
|
|
159
|
+
onKeyDown={handlePageInputKeyDown}
|
|
160
|
+
disabled={disabled || totalItems === 0}
|
|
161
|
+
/>
|
|
162
|
+
<span className="text-[11px] font-medium text-muted-foreground whitespace-nowrap">
|
|
163
|
+
/ {Math.max(1, pageCount)}
|
|
164
|
+
</span>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<Button
|
|
168
|
+
variant="outline"
|
|
169
|
+
className="h-6 w-6 p-0"
|
|
170
|
+
onClick={() => handlePageChange(currentPage + 1)}
|
|
171
|
+
disabled={currentPage >= pageCount || disabled || totalItems === 0}
|
|
172
|
+
>
|
|
173
|
+
<span className="sr-only">Trang sau</span>
|
|
174
|
+
<ChevronRight className="h-3 w-3" />
|
|
175
|
+
</Button>
|
|
176
|
+
<Button
|
|
177
|
+
variant="outline"
|
|
178
|
+
className="hidden h-6 w-6 p-0 lg:flex"
|
|
179
|
+
onClick={() => handlePageChange(pageCount)}
|
|
180
|
+
disabled={currentPage >= pageCount || disabled || totalItems === 0}
|
|
181
|
+
>
|
|
182
|
+
<span className="sr-only">Trang cuối</span>
|
|
183
|
+
<ChevronsRight className="h-3 w-3" />
|
|
184
|
+
</Button>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
package/src/ui/index.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect } from "react";
|
|
3
|
+
import { useState, useEffect, useRef } from "react";
|
|
4
4
|
import { Plus, Search, LayoutGrid, List, X, Filter } from "lucide-react";
|
|
5
5
|
import {
|
|
6
6
|
Button,
|
|
@@ -45,9 +45,15 @@ export function UserToolbar({
|
|
|
45
45
|
}: UserToolbarProps) {
|
|
46
46
|
const [localSearch, setLocalSearch] = useState("");
|
|
47
47
|
|
|
48
|
-
// Debounce search
|
|
48
|
+
// Debounce search. CHỈ phát khi giá trị thật sự đổi so với lần phát trước:
|
|
49
|
+
// effect này còn chạy lại khi identity `onSearchChange` đổi (client page tạo
|
|
50
|
+
// lại callback lúc URL đổi), mà phát lại "" lúc đó là một cú router.push xoá
|
|
51
|
+
// sạch query — từng nuốt mất ?page khi bấm phân trang.
|
|
52
|
+
const lastEmittedSearch = useRef(localSearch);
|
|
49
53
|
useEffect(() => {
|
|
54
|
+
if (localSearch === lastEmittedSearch.current) return;
|
|
50
55
|
const timer = setTimeout(() => {
|
|
56
|
+
lastEmittedSearch.current = localSearch;
|
|
51
57
|
onSearchChange(localSearch);
|
|
52
58
|
}, 300);
|
|
53
59
|
return () => clearTimeout(timer);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Badge } from "../../ui";
|
|
4
|
+
|
|
5
|
+
// Palette avatar dùng chung cho card view + table view — đổi một chỗ đổi cả hai.
|
|
6
|
+
export const AVATAR_PALETTE = [
|
|
7
|
+
{ bg: "bg-blue-600", text: "text-white" },
|
|
8
|
+
{ bg: "bg-emerald-600", text: "text-white" },
|
|
9
|
+
{ bg: "bg-orange-600", text: "text-white" },
|
|
10
|
+
{ bg: "bg-violet-600", text: "text-white" },
|
|
11
|
+
{ bg: "bg-cyan-600", text: "text-white" },
|
|
12
|
+
{ bg: "bg-rose-600", text: "text-white" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export const getAvatarColor = (name: string | null) => {
|
|
16
|
+
if (!name) return AVATAR_PALETTE[0];
|
|
17
|
+
const charCode = name.charCodeAt(0) + (name.charCodeAt(name.length - 1) || 0);
|
|
18
|
+
return AVATAR_PALETTE[charCode % AVATAR_PALETTE.length];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const getInitials = (name: string | null) => {
|
|
22
|
+
if (!name) return "U";
|
|
23
|
+
return name
|
|
24
|
+
.split(" ")
|
|
25
|
+
.map((n) => n[0])
|
|
26
|
+
.join("")
|
|
27
|
+
.toUpperCase()
|
|
28
|
+
.slice(0, 2);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const BADGE_BASE =
|
|
32
|
+
"text-[9px] h-4 px-1.5 font-bold border-transparent uppercase tracking-widest shrink-0 rounded-full";
|
|
33
|
+
|
|
34
|
+
export function UserStatusBadge({ active }: { active: boolean }) {
|
|
35
|
+
return active ? (
|
|
36
|
+
<Badge
|
|
37
|
+
className={`${BADGE_BASE} bg-emerald-100 text-emerald-700 hover:bg-emerald-200`}
|
|
38
|
+
>
|
|
39
|
+
<div className="w-1.5 h-1.5 rounded-full bg-emerald-600 mr-1.5" />
|
|
40
|
+
Hoạt động
|
|
41
|
+
</Badge>
|
|
42
|
+
) : (
|
|
43
|
+
<Badge
|
|
44
|
+
className={`${BADGE_BASE} bg-slate-100 text-slate-600 hover:bg-slate-200`}
|
|
45
|
+
>
|
|
46
|
+
<div className="w-1.5 h-1.5 rounded-full bg-slate-400 mr-1.5" />
|
|
47
|
+
Đã khóa
|
|
48
|
+
</Badge>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function UserTypeBadge({
|
|
53
|
+
userType,
|
|
54
|
+
short,
|
|
55
|
+
}: {
|
|
56
|
+
userType?: string | null;
|
|
57
|
+
short?: boolean;
|
|
58
|
+
}) {
|
|
59
|
+
if (userType === "customer") {
|
|
60
|
+
return (
|
|
61
|
+
<Badge
|
|
62
|
+
className={`${BADGE_BASE} bg-emerald-100 text-emerald-700 hover:bg-emerald-200`}
|
|
63
|
+
>
|
|
64
|
+
{short ? "Khách" : "Khách hàng"}
|
|
65
|
+
</Badge>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (userType === "supplier") {
|
|
69
|
+
return (
|
|
70
|
+
<Badge
|
|
71
|
+
className={`${BADGE_BASE} bg-orange-100 text-orange-700 hover:bg-orange-200`}
|
|
72
|
+
>
|
|
73
|
+
{short ? "NCC" : "Nhà cung cấp"}
|
|
74
|
+
</Badge>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return (
|
|
78
|
+
<Badge
|
|
79
|
+
className={`${BADGE_BASE} bg-indigo-100 text-indigo-700 hover:bg-indigo-200`}
|
|
80
|
+
>
|
|
81
|
+
{short ? "NV" : "Nhân viên"}
|
|
82
|
+
</Badge>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { cn } from "../../utils";
|
|
17
17
|
import { Phone, MoreHorizontal, Eye, Edit, User, Briefcase, Building2, KeyRound, Mail, ShieldCheck, Copy } from "lucide-react";
|
|
18
18
|
import { toast } from "sonner";
|
|
19
|
+
import { getAvatarColor, getInitials } from "./user-visuals";
|
|
19
20
|
|
|
20
21
|
interface UsersCardViewProps {
|
|
21
22
|
data: any[];
|
|
@@ -24,22 +25,6 @@ interface UsersCardViewProps {
|
|
|
24
25
|
onViewPermissions?: (user: any) => void;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
// Professional Corporate Palette for Avatars
|
|
28
|
-
const AVATAR_PALETTE = [
|
|
29
|
-
{ bg: "bg-blue-600", text: "text-white" },
|
|
30
|
-
{ bg: "bg-emerald-600", text: "text-white" },
|
|
31
|
-
{ bg: "bg-orange-600", text: "text-white" },
|
|
32
|
-
{ bg: "bg-violet-600", text: "text-white" },
|
|
33
|
-
{ bg: "bg-cyan-600", text: "text-white" },
|
|
34
|
-
{ bg: "bg-rose-600", text: "text-white" },
|
|
35
|
-
];
|
|
36
|
-
|
|
37
|
-
const getAvatarColor = (name: string | null) => {
|
|
38
|
-
if (!name) return AVATAR_PALETTE[0];
|
|
39
|
-
const charCode = name.charCodeAt(0) + (name.charCodeAt(name.length - 1) || 0);
|
|
40
|
-
return AVATAR_PALETTE[charCode % AVATAR_PALETTE.length];
|
|
41
|
-
};
|
|
42
|
-
|
|
43
28
|
export function UsersCardView({
|
|
44
29
|
data,
|
|
45
30
|
onSelect,
|
|
@@ -61,16 +46,6 @@ export function UsersCardView({
|
|
|
61
46
|
);
|
|
62
47
|
}
|
|
63
48
|
|
|
64
|
-
const getInitials = (name: string | null) => {
|
|
65
|
-
if (!name) return "U";
|
|
66
|
-
return name
|
|
67
|
-
.split(" ")
|
|
68
|
-
.map((n) => n[0])
|
|
69
|
-
.join("")
|
|
70
|
-
.toUpperCase()
|
|
71
|
-
.slice(0, 2);
|
|
72
|
-
};
|
|
73
|
-
|
|
74
49
|
return (
|
|
75
50
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5 pb-4 mt-2">
|
|
76
51
|
{data.map((user) => {
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { Briefcase, Building2, Edit2, Phone } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
import type { ColumnDef } from "@tanstack/react-table";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
Avatar,
|
|
10
|
+
AvatarFallback,
|
|
11
|
+
AvatarImage,
|
|
12
|
+
Badge,
|
|
13
|
+
Button,
|
|
14
|
+
DataTable,
|
|
15
|
+
} from "../../ui";
|
|
16
|
+
import { cn } from "../../utils";
|
|
17
|
+
import {
|
|
18
|
+
UserStatusBadge,
|
|
19
|
+
UserTypeBadge,
|
|
20
|
+
getAvatarColor,
|
|
21
|
+
getInitials,
|
|
22
|
+
} from "./user-visuals";
|
|
23
|
+
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
+
type UserRow = Record<string, any>;
|
|
26
|
+
|
|
27
|
+
interface UsersTableProps {
|
|
28
|
+
data: UserRow[];
|
|
29
|
+
/** Phân trang server-style qua URL — client page cắt trang và cầm URL. */
|
|
30
|
+
page: number;
|
|
31
|
+
pageSize: number;
|
|
32
|
+
total: number;
|
|
33
|
+
onPageChange: (page: number, pageSize: number) => void;
|
|
34
|
+
onRowClick?: (user: UserRow) => void;
|
|
35
|
+
onEdit?: (user: UserRow) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Bảng danh sách người dùng theo chuẩn chung (DataTable tanstack + thanh phân
|
|
40
|
+
* trang "Hiển thị x - y trong tổng số n mục"). Thay cho BasicUserTable cũ vốn
|
|
41
|
+
* tự chế phân trang bằng state cục bộ — số trang không vào URL, không đồng bộ
|
|
42
|
+
* với card view.
|
|
43
|
+
*/
|
|
44
|
+
export function UsersTable({
|
|
45
|
+
data,
|
|
46
|
+
page,
|
|
47
|
+
pageSize,
|
|
48
|
+
total,
|
|
49
|
+
onPageChange,
|
|
50
|
+
onRowClick,
|
|
51
|
+
onEdit,
|
|
52
|
+
}: UsersTableProps) {
|
|
53
|
+
const columns = useMemo<ColumnDef<UserRow>[]>(
|
|
54
|
+
() => [
|
|
55
|
+
{
|
|
56
|
+
id: "user",
|
|
57
|
+
header: "Người dùng",
|
|
58
|
+
size: 300,
|
|
59
|
+
minSize: 260,
|
|
60
|
+
cell: ({ row }) => {
|
|
61
|
+
const user = row.original;
|
|
62
|
+
const isActive = user.isActive || user.status === "active";
|
|
63
|
+
const avatarColors = getAvatarColor(user.name);
|
|
64
|
+
return (
|
|
65
|
+
<div className="flex items-start gap-3 py-1">
|
|
66
|
+
<Avatar className="h-9 w-9 shrink-0 rounded-full border shadow-sm">
|
|
67
|
+
<AvatarImage
|
|
68
|
+
src={user.image || user.avatar || ""}
|
|
69
|
+
alt={user.name || ""}
|
|
70
|
+
className="object-cover rounded-full"
|
|
71
|
+
/>
|
|
72
|
+
<AvatarFallback
|
|
73
|
+
className={cn(
|
|
74
|
+
"text-xs font-bold rounded-full",
|
|
75
|
+
avatarColors.bg,
|
|
76
|
+
avatarColors.text,
|
|
77
|
+
)}
|
|
78
|
+
>
|
|
79
|
+
{getInitials(user.name || user.email)}
|
|
80
|
+
</AvatarFallback>
|
|
81
|
+
</Avatar>
|
|
82
|
+
<div className="flex flex-col min-w-0">
|
|
83
|
+
<div className="flex items-center gap-1.5 mb-0.5 flex-wrap">
|
|
84
|
+
<span className="font-semibold text-sm text-foreground truncate mr-1">
|
|
85
|
+
{user.name || "Chưa đặt tên"}
|
|
86
|
+
</span>
|
|
87
|
+
<UserStatusBadge active={isActive} />
|
|
88
|
+
<UserTypeBadge userType={user.userType} />
|
|
89
|
+
</div>
|
|
90
|
+
<span className="text-xs text-muted-foreground truncate">
|
|
91
|
+
{user.email || "—"}
|
|
92
|
+
</span>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "contact",
|
|
100
|
+
header: "Liên hệ",
|
|
101
|
+
size: 150,
|
|
102
|
+
minSize: 130,
|
|
103
|
+
cell: ({ row }) => (
|
|
104
|
+
<div className="flex items-center gap-1.5">
|
|
105
|
+
<Phone className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
106
|
+
<span className="text-sm text-foreground/80">
|
|
107
|
+
{row.original.profiles?.phone || row.original.phone || "—"}
|
|
108
|
+
</span>
|
|
109
|
+
</div>
|
|
110
|
+
),
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "department",
|
|
114
|
+
header: "Phòng ban / Chi nhánh",
|
|
115
|
+
size: 200,
|
|
116
|
+
minSize: 170,
|
|
117
|
+
cell: ({ row }) => {
|
|
118
|
+
const user = row.original;
|
|
119
|
+
const branches =
|
|
120
|
+
user.branchNames && user.branchNames.length > 0
|
|
121
|
+
? user.branchNames.join(", ")
|
|
122
|
+
: user.branchName || user.branch?.name || "—";
|
|
123
|
+
return (
|
|
124
|
+
<div className="flex flex-col gap-1">
|
|
125
|
+
<div className="flex items-center gap-1.5">
|
|
126
|
+
<Briefcase className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
127
|
+
<span
|
|
128
|
+
className="text-sm font-medium text-foreground/80 truncate max-w-[200px]"
|
|
129
|
+
title={user.departmentName}
|
|
130
|
+
>
|
|
131
|
+
{user.departmentName || "—"}
|
|
132
|
+
</span>
|
|
133
|
+
</div>
|
|
134
|
+
<div className="flex items-center gap-1.5">
|
|
135
|
+
<Building2 className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
136
|
+
<span
|
|
137
|
+
className="text-xs text-muted-foreground truncate max-w-[200px]"
|
|
138
|
+
title={branches}
|
|
139
|
+
>
|
|
140
|
+
{branches}
|
|
141
|
+
</span>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: "roles",
|
|
149
|
+
header: "Vai trò",
|
|
150
|
+
size: 240,
|
|
151
|
+
minSize: 180,
|
|
152
|
+
cell: ({ row }) => {
|
|
153
|
+
const roleNames: string[] = row.original.roleNames || [];
|
|
154
|
+
return (
|
|
155
|
+
<div className="flex flex-wrap gap-1">
|
|
156
|
+
{roleNames.length > 0 ? (
|
|
157
|
+
roleNames.map((r, i) => (
|
|
158
|
+
<Badge
|
|
159
|
+
key={i}
|
|
160
|
+
variant="secondary"
|
|
161
|
+
className="text-[10px] px-2 py-0.5 font-medium truncate max-w-[140px]"
|
|
162
|
+
title={r}
|
|
163
|
+
>
|
|
164
|
+
{r}
|
|
165
|
+
</Badge>
|
|
166
|
+
))
|
|
167
|
+
) : (
|
|
168
|
+
<span className="text-xs text-muted-foreground italic">—</span>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
...(onEdit
|
|
175
|
+
? [
|
|
176
|
+
{
|
|
177
|
+
id: "actions",
|
|
178
|
+
header: "",
|
|
179
|
+
size: 60,
|
|
180
|
+
maxSize: 60,
|
|
181
|
+
cell: ({ row }: { row: { original: UserRow } }) => (
|
|
182
|
+
<div className="text-right">
|
|
183
|
+
<Button
|
|
184
|
+
variant="ghost"
|
|
185
|
+
size="icon"
|
|
186
|
+
className="h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
|
|
187
|
+
onClick={(e) => {
|
|
188
|
+
e.stopPropagation();
|
|
189
|
+
onEdit(row.original);
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
<Edit2 className="h-4 w-4" />
|
|
193
|
+
</Button>
|
|
194
|
+
</div>
|
|
195
|
+
),
|
|
196
|
+
} satisfies ColumnDef<UserRow>,
|
|
197
|
+
]
|
|
198
|
+
: []),
|
|
199
|
+
],
|
|
200
|
+
[onEdit],
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<DataTable<UserRow>
|
|
205
|
+
data={data}
|
|
206
|
+
columns={columns}
|
|
207
|
+
enableRowNumber
|
|
208
|
+
pagination={{ page, pageSize, total }}
|
|
209
|
+
onPaginationChange={(p) => onPageChange(p.page, p.pageSize)}
|
|
210
|
+
onRowClick={onRowClick}
|
|
211
|
+
tableClassName="text-sm"
|
|
212
|
+
cellClassName="py-2"
|
|
213
|
+
/>
|
|
214
|
+
);
|
|
215
|
+
}
|