@goplusvn/core 0.1.67 → 0.1.70
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 +94 -1
- 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/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/print/print-styles.tsx +4 -1
- package/src/types/index.ts +1 -1
- 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
|
@@ -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
|
+
}
|