@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
|
@@ -1,29 +1,16 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, useCallback } from "react";
|
|
3
|
+
import { useState, useCallback, useMemo } from "react";
|
|
4
4
|
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
|
5
5
|
import { useViewMode } from "../../hooks";
|
|
6
6
|
import { toast } from "sonner";
|
|
7
|
-
import {
|
|
8
|
-
Table,
|
|
9
|
-
TableBody,
|
|
10
|
-
TableCell,
|
|
11
|
-
TableHead,
|
|
12
|
-
TableHeader,
|
|
13
|
-
TableRow,
|
|
14
|
-
Badge,
|
|
15
|
-
Button,
|
|
16
|
-
Avatar,
|
|
17
|
-
AvatarFallback,
|
|
18
|
-
AvatarImage,
|
|
19
|
-
} from "../../ui";
|
|
20
|
-
import { Edit2, Phone, Briefcase, Building2 } from "lucide-react";
|
|
7
|
+
import { ShallowPagination } from "../../ui";
|
|
21
8
|
import type { EntityConfig, CrudPermissions } from "../../types";
|
|
22
|
-
import { cn } from "../../utils";
|
|
23
9
|
|
|
24
10
|
import { UserToolbar } from "../components/user-toolbar";
|
|
25
11
|
import { UserStats } from "../components/user-stats";
|
|
26
12
|
import { UsersCardView } from "../components/users-card-view";
|
|
13
|
+
import { UsersTable } from "../components/users-table";
|
|
27
14
|
import { UnifiedProfileDialog } from "../components/unified-profile-dialog";
|
|
28
15
|
import {
|
|
29
16
|
EffectivePermissionsDialog,
|
|
@@ -52,6 +39,8 @@ interface UsersClientPageProps {
|
|
|
52
39
|
actionMeta?: Record<string, { label?: string; flow?: string; description?: string }>;
|
|
53
40
|
}
|
|
54
41
|
|
|
42
|
+
const DEFAULT_PAGE_SIZE = 20;
|
|
43
|
+
|
|
55
44
|
export function UsersClientPage({
|
|
56
45
|
initialData,
|
|
57
46
|
config,
|
|
@@ -69,6 +58,36 @@ export function UsersClientPage({
|
|
|
69
58
|
const pathname = usePathname();
|
|
70
59
|
|
|
71
60
|
const searchParams = useSearchParams();
|
|
61
|
+
|
|
62
|
+
// ── Phân trang chung cho CẢ card view lẫn table view ──
|
|
63
|
+
// Server trả toàn bộ danh sách đã lọc (initialData); trang/kích thước trang
|
|
64
|
+
// sống trong URL (?page&pageSize) như trang khách hàng, và client cắt trang
|
|
65
|
+
// MỘT chỗ ở đây — hai chế độ xem không còn mỗi bên một kiểu (card không phân
|
|
66
|
+
// trang, table tự chế state cục bộ) như trước.
|
|
67
|
+
const total = initialData.length;
|
|
68
|
+
const pageSize = Number(searchParams.get("pageSize")) || DEFAULT_PAGE_SIZE;
|
|
69
|
+
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
|
70
|
+
// Kẹp vào phạm vi thật: đổi bộ lọc làm danh sách ngắn lại thì trang đang
|
|
71
|
+
// đứng có thể vượt quá trang cuối.
|
|
72
|
+
const page = Math.min(Number(searchParams.get("page")) || 1, pageCount);
|
|
73
|
+
const pageData = useMemo(
|
|
74
|
+
() => initialData.slice((page - 1) * pageSize, page * pageSize),
|
|
75
|
+
[initialData, page, pageSize],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Dữ liệu đã nằm sẵn ở client nên đổi trang chỉ cần ghi URL nông
|
|
79
|
+
// (history.replaceState — Next vẫn đồng bộ useSearchParams), không kéo theo
|
|
80
|
+
// một lượt RSC chỉ để trả về đúng danh sách client đang cầm.
|
|
81
|
+
const handlePageChange = useCallback(
|
|
82
|
+
(nextPage: number, nextPageSize: number) => {
|
|
83
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
84
|
+
params.set("page", String(nextPageSize !== pageSize ? 1 : nextPage));
|
|
85
|
+
params.set("pageSize", String(nextPageSize));
|
|
86
|
+
window.history.replaceState(null, "", `${pathname}?${params.toString()}`);
|
|
87
|
+
},
|
|
88
|
+
[pathname, searchParams, pageSize],
|
|
89
|
+
);
|
|
90
|
+
|
|
72
91
|
// View Mode
|
|
73
92
|
const [viewModeRaw, handleViewModeChangeRaw] = useViewMode(
|
|
74
93
|
"users-view-mode",
|
|
@@ -87,38 +106,36 @@ export function UsersClientPage({
|
|
|
87
106
|
|
|
88
107
|
// Dialogs State
|
|
89
108
|
const [userFormOpen, setUserFormOpen] = useState(false);
|
|
90
|
-
const [userFormMode, setUserFormMode] = useState<"create" | "edit">("create");
|
|
91
|
-
const [userFormData, setUserFormData] = useState<any>(null);
|
|
92
109
|
|
|
93
|
-
// Handlers
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
// Handlers — bộ lọc đổi thì về trang 1, kẻo đứng ở trang ngoài phạm vi mới.
|
|
111
|
+
// Đọc query tại THỜI ĐIỂM GỌI (window.location) thay vì phụ thuộc
|
|
112
|
+
// `searchParams`: phụ thuộc vào đó làm callback đổi identity mỗi lần URL đổi,
|
|
113
|
+
// kéo effect debounce của ô tìm kiếm trong toolbar chạy lại và phát "" đè
|
|
114
|
+
// mất ?page vừa bấm.
|
|
115
|
+
const applyFilter = useCallback(
|
|
116
|
+
(key: string, value: string) => {
|
|
117
|
+
const params = new URLSearchParams(window.location.search);
|
|
118
|
+
if (value && value !== "all") params.set(key, value);
|
|
119
|
+
else params.delete(key);
|
|
120
|
+
params.delete("page");
|
|
99
121
|
router.push(`${pathname}?${params.toString()}`);
|
|
100
122
|
},
|
|
101
|
-
[pathname, router
|
|
123
|
+
[pathname, router],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const handleSearch = useCallback(
|
|
127
|
+
(value: string) => applyFilter("search", value),
|
|
128
|
+
[applyFilter],
|
|
102
129
|
);
|
|
103
130
|
|
|
104
131
|
const handleRoleFilter = useCallback(
|
|
105
|
-
(value: string) =>
|
|
106
|
-
|
|
107
|
-
if (value && value !== "all") params.set("role", value);
|
|
108
|
-
else params.delete("role");
|
|
109
|
-
router.push(`${pathname}?${params.toString()}`);
|
|
110
|
-
},
|
|
111
|
-
[pathname, router, searchParams],
|
|
132
|
+
(value: string) => applyFilter("role", value),
|
|
133
|
+
[applyFilter],
|
|
112
134
|
);
|
|
113
135
|
|
|
114
136
|
const handleUserTypeFilter = useCallback(
|
|
115
|
-
(value: string) =>
|
|
116
|
-
|
|
117
|
-
if (value && value !== "all") params.set("type", value);
|
|
118
|
-
else params.delete("type");
|
|
119
|
-
router.push(`${pathname}?${params.toString()}`);
|
|
120
|
-
},
|
|
121
|
-
[pathname, router, searchParams],
|
|
137
|
+
(value: string) => applyFilter("type", value),
|
|
138
|
+
[applyFilter],
|
|
122
139
|
);
|
|
123
140
|
|
|
124
141
|
const handleSelectUser = (user: any) => {
|
|
@@ -127,8 +144,6 @@ export function UsersClientPage({
|
|
|
127
144
|
};
|
|
128
145
|
|
|
129
146
|
const handleCreateNew = () => {
|
|
130
|
-
setUserFormMode("create");
|
|
131
|
-
setUserFormData(null);
|
|
132
147
|
setUserFormOpen(true);
|
|
133
148
|
};
|
|
134
149
|
|
|
@@ -212,20 +227,36 @@ export function UsersClientPage({
|
|
|
212
227
|
permissions={permissions}
|
|
213
228
|
/>
|
|
214
229
|
|
|
215
|
-
<div className="min-h-[400px]">
|
|
230
|
+
<div className="min-h-[400px] flex flex-col">
|
|
216
231
|
{viewMode === "table" ? (
|
|
217
|
-
<
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
data={initialData}
|
|
226
|
-
onSelect={handleSelectUser}
|
|
227
|
-
onViewPermissions={menuTree ? setPermUser : undefined}
|
|
232
|
+
<UsersTable
|
|
233
|
+
data={pageData}
|
|
234
|
+
page={page}
|
|
235
|
+
pageSize={pageSize}
|
|
236
|
+
total={total}
|
|
237
|
+
onPageChange={handlePageChange}
|
|
238
|
+
onRowClick={handleSelectUser}
|
|
239
|
+
onEdit={(u) => handleEditUser(u)}
|
|
228
240
|
/>
|
|
241
|
+
) : (
|
|
242
|
+
<>
|
|
243
|
+
<UsersCardView
|
|
244
|
+
data={pageData}
|
|
245
|
+
onSelect={handleSelectUser}
|
|
246
|
+
onViewPermissions={menuTree ? setPermUser : undefined}
|
|
247
|
+
/>
|
|
248
|
+
{/* Cùng thanh phân trang với chế độ bảng (bảng tự vẽ trong khung
|
|
249
|
+
DataTable) — card view vẽ dưới lưới, giống trang khách hàng. */}
|
|
250
|
+
{total > 0 && (
|
|
251
|
+
<div className="border-t bg-background px-2 sm:px-6 py-1 sm:py-2 shrink-0">
|
|
252
|
+
<ShallowPagination
|
|
253
|
+
totalItems={total}
|
|
254
|
+
currentPage={page}
|
|
255
|
+
pageSize={pageSize}
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
</>
|
|
229
260
|
)}
|
|
230
261
|
</div>
|
|
231
262
|
|
|
@@ -266,209 +297,3 @@ export function UsersClientPage({
|
|
|
266
297
|
</div>
|
|
267
298
|
);
|
|
268
299
|
}
|
|
269
|
-
|
|
270
|
-
// Simple Basic Table Component
|
|
271
|
-
function BasicUserTable({
|
|
272
|
-
data,
|
|
273
|
-
onEdit,
|
|
274
|
-
}: {
|
|
275
|
-
data: any[];
|
|
276
|
-
onEdit: (u: any) => void;
|
|
277
|
-
}) {
|
|
278
|
-
const [currentPage, setCurrentPage] = useState(1);
|
|
279
|
-
const pageSize = 10;
|
|
280
|
-
|
|
281
|
-
if (!data.length) return (
|
|
282
|
-
<div className="p-8 text-center bg-card flex flex-col items-center justify-center border-t border-border">
|
|
283
|
-
<p className="text-sm font-medium text-muted-foreground">Không có dữ liệu</p>
|
|
284
|
-
</div>
|
|
285
|
-
);
|
|
286
|
-
|
|
287
|
-
const totalPages = Math.ceil(data.length / pageSize);
|
|
288
|
-
const paginatedData = data.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
|
289
|
-
|
|
290
|
-
const getInitials = (name: string | null) => {
|
|
291
|
-
if (!name) return "U";
|
|
292
|
-
return name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2);
|
|
293
|
-
};
|
|
294
|
-
|
|
295
|
-
// Corporate Palette for Avatars
|
|
296
|
-
const AVATAR_PALETTE = [
|
|
297
|
-
{ bg: "bg-blue-600", text: "text-white" },
|
|
298
|
-
{ bg: "bg-emerald-600", text: "text-white" },
|
|
299
|
-
{ bg: "bg-orange-600", text: "text-white" },
|
|
300
|
-
{ bg: "bg-violet-600", text: "text-white" },
|
|
301
|
-
{ bg: "bg-cyan-600", text: "text-white" },
|
|
302
|
-
{ bg: "bg-rose-600", text: "text-white" },
|
|
303
|
-
];
|
|
304
|
-
|
|
305
|
-
const getAvatarColor = (name: string | null) => {
|
|
306
|
-
if (!name) return AVATAR_PALETTE[0];
|
|
307
|
-
const charCode = name.charCodeAt(0) + (name.charCodeAt(name.length - 1) || 0);
|
|
308
|
-
return AVATAR_PALETTE[charCode % AVATAR_PALETTE.length];
|
|
309
|
-
};
|
|
310
|
-
|
|
311
|
-
return (
|
|
312
|
-
<div className="flex flex-col border border-border rounded-lg overflow-hidden shadow-sm mt-2">
|
|
313
|
-
<div className="bg-card w-full overflow-x-auto">
|
|
314
|
-
<Table className="w-full">
|
|
315
|
-
<TableHeader>
|
|
316
|
-
<TableRow className="border-b border-border bg-muted/50 hover:bg-muted/50">
|
|
317
|
-
<TableHead className="w-[50px] font-semibold text-muted-foreground text-xs uppercase tracking-wider text-center h-11">STT</TableHead>
|
|
318
|
-
<TableHead className="w-[30%] min-w-[250px] font-semibold text-muted-foreground text-xs uppercase tracking-wider h-11">Người dùng</TableHead>
|
|
319
|
-
<TableHead className="w-[15%] min-w-[150px] font-semibold text-muted-foreground text-xs uppercase tracking-wider h-11">Liên hệ</TableHead>
|
|
320
|
-
<TableHead className="w-[20%] min-w-[180px] font-semibold text-muted-foreground text-xs uppercase tracking-wider h-11">Phòng ban / Chi nhánh</TableHead>
|
|
321
|
-
<TableHead className="w-[25%] min-w-[200px] font-semibold text-muted-foreground text-xs uppercase tracking-wider h-11">Vai trò</TableHead>
|
|
322
|
-
<TableHead className="w-[60px] text-right h-11"></TableHead>
|
|
323
|
-
</TableRow>
|
|
324
|
-
</TableHeader>
|
|
325
|
-
<TableBody>
|
|
326
|
-
{paginatedData.map((user, index) => {
|
|
327
|
-
const isActive = user.isActive || user.status === "active";
|
|
328
|
-
const avatarColors = getAvatarColor(user.name);
|
|
329
|
-
|
|
330
|
-
return (
|
|
331
|
-
<TableRow key={user.id} className="border-b border-border/40 hover:bg-accent/10 transition-colors group">
|
|
332
|
-
{/* STT */}
|
|
333
|
-
<TableCell className="py-3 text-center text-muted-foreground text-xs font-medium align-middle">
|
|
334
|
-
{(currentPage - 1) * pageSize + index + 1}
|
|
335
|
-
</TableCell>
|
|
336
|
-
|
|
337
|
-
{/* User Info (Avatar + Name + Type + Status) */}
|
|
338
|
-
<TableCell className="py-3 align-top">
|
|
339
|
-
<div className="flex items-start gap-3">
|
|
340
|
-
<div className="relative shrink-0 mt-0.5">
|
|
341
|
-
<Avatar className="h-10 w-10 rounded-full border shadow-sm">
|
|
342
|
-
<AvatarImage src={user.image || user.avatar || ""} alt={user.name || ""} className="object-cover rounded-full" />
|
|
343
|
-
<AvatarFallback className={cn("text-xs font-bold rounded-full", avatarColors.bg, avatarColors.text)}>
|
|
344
|
-
{getInitials(user.name || user.email)}
|
|
345
|
-
</AvatarFallback>
|
|
346
|
-
</Avatar>
|
|
347
|
-
{/* Note: Status indicator is now a badge next to the name */}
|
|
348
|
-
</div>
|
|
349
|
-
|
|
350
|
-
<div className="flex flex-col min-w-0">
|
|
351
|
-
<div className="flex items-center gap-1.5 mb-1 flex-wrap">
|
|
352
|
-
<span className="font-bold text-sm text-foreground truncate mr-1">{user.name || "Chưa đặt tên"}</span>
|
|
353
|
-
|
|
354
|
-
{/* Status Badge */}
|
|
355
|
-
{isActive ? (
|
|
356
|
-
<Badge className="text-[9px] h-4 px-1.5 font-bold bg-emerald-100 text-emerald-700 hover:bg-emerald-200 border-transparent uppercase tracking-widest shrink-0 rounded-full">
|
|
357
|
-
<div className="w-1.5 h-1.5 rounded-full bg-emerald-600 mr-1.5" />
|
|
358
|
-
Hoạt động
|
|
359
|
-
</Badge>
|
|
360
|
-
) : (
|
|
361
|
-
<Badge className="text-[9px] h-4 px-1.5 font-bold bg-slate-100 text-slate-600 hover:bg-slate-200 border-transparent uppercase tracking-widest shrink-0 rounded-full">
|
|
362
|
-
<div className="w-1.5 h-1.5 rounded-full bg-slate-400 mr-1.5" />
|
|
363
|
-
Đã khóa
|
|
364
|
-
</Badge>
|
|
365
|
-
)}
|
|
366
|
-
|
|
367
|
-
{/* Type Badge */}
|
|
368
|
-
{user.userType === "customer" && <Badge className="text-[9px] h-4 px-1.5 font-bold bg-emerald-100 text-emerald-700 hover:bg-emerald-200 border-transparent uppercase tracking-widest shrink-0 rounded-full">Khách hàng</Badge>}
|
|
369
|
-
{user.userType === "supplier" && <Badge className="text-[9px] h-4 px-1.5 font-bold bg-orange-100 text-orange-700 hover:bg-orange-200 border-transparent uppercase tracking-widest shrink-0 rounded-full">Nhà cung cấp</Badge>}
|
|
370
|
-
{(!user.userType || user.userType === "employee") && <Badge className="text-[9px] h-4 px-1.5 font-bold bg-indigo-100 text-indigo-700 hover:bg-indigo-200 border-transparent uppercase tracking-widest shrink-0 rounded-full">Nhân viên</Badge>}
|
|
371
|
-
</div>
|
|
372
|
-
<span className="text-xs text-muted-foreground truncate font-medium">{user.email || "—"}</span>
|
|
373
|
-
</div>
|
|
374
|
-
</div>
|
|
375
|
-
</TableCell>
|
|
376
|
-
|
|
377
|
-
{/* Contact */}
|
|
378
|
-
<TableCell className="py-3 align-top">
|
|
379
|
-
<div className="flex items-center gap-1.5 mt-1">
|
|
380
|
-
<Phone className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
381
|
-
<span className="text-sm font-medium text-foreground/80">{user.profiles?.phone || user.phone || "—"}</span>
|
|
382
|
-
</div>
|
|
383
|
-
</TableCell>
|
|
384
|
-
|
|
385
|
-
{/* Department & Branch */}
|
|
386
|
-
<TableCell className="py-3 align-top">
|
|
387
|
-
<div className="flex flex-col gap-1.5 mt-1">
|
|
388
|
-
<div className="flex items-center gap-1.5">
|
|
389
|
-
<Briefcase className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
390
|
-
<span className="text-sm font-semibold text-foreground/80 truncate max-w-[200px]" title={user.departmentName}>{user.departmentName || "—"}</span>
|
|
391
|
-
</div>
|
|
392
|
-
<div className="flex items-center gap-1.5">
|
|
393
|
-
<Building2 className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
394
|
-
<span className="text-xs text-muted-foreground truncate max-w-[200px]" title={(user.branchNames && user.branchNames.length > 0) ? user.branchNames.join(", ") : (user.branchName || user.branch?.name || "")}>
|
|
395
|
-
{(user.branchNames && user.branchNames.length > 0) ? user.branchNames.join(", ") : (user.branchName || user.branch?.name || "—")}
|
|
396
|
-
</span>
|
|
397
|
-
</div>
|
|
398
|
-
</div>
|
|
399
|
-
</TableCell>
|
|
400
|
-
|
|
401
|
-
{/* Roles */}
|
|
402
|
-
<TableCell className="py-3 align-top">
|
|
403
|
-
<div className="flex flex-wrap gap-1 mt-0.5">
|
|
404
|
-
{user.roleNames && user.roleNames.length > 0 ? (
|
|
405
|
-
user.roleNames.map((r: string, i: number) => (
|
|
406
|
-
<Badge
|
|
407
|
-
key={i}
|
|
408
|
-
className="text-[10px] px-2 py-0.5 font-medium bg-slate-100 text-slate-700 border-transparent hover:bg-slate-200 truncate max-w-[140px]"
|
|
409
|
-
title={r}
|
|
410
|
-
>
|
|
411
|
-
{r}
|
|
412
|
-
</Badge>
|
|
413
|
-
))
|
|
414
|
-
) : (
|
|
415
|
-
<span className="text-xs text-muted-foreground italic">—</span>
|
|
416
|
-
)}
|
|
417
|
-
</div>
|
|
418
|
-
</TableCell>
|
|
419
|
-
|
|
420
|
-
{/* Actions */}
|
|
421
|
-
<TableCell className="py-3 text-right align-middle">
|
|
422
|
-
<Button
|
|
423
|
-
variant="ghost"
|
|
424
|
-
size="icon"
|
|
425
|
-
className="h-8 w-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-background shadow-sm opacity-0 group-hover:opacity-100 transition-all focus:opacity-100"
|
|
426
|
-
onClick={(e: React.MouseEvent) => {
|
|
427
|
-
e.stopPropagation();
|
|
428
|
-
onEdit(user);
|
|
429
|
-
}}
|
|
430
|
-
>
|
|
431
|
-
<Edit2 className="h-4 w-4" />
|
|
432
|
-
</Button>
|
|
433
|
-
</TableCell>
|
|
434
|
-
</TableRow>
|
|
435
|
-
);
|
|
436
|
-
})}
|
|
437
|
-
</TableBody>
|
|
438
|
-
</Table>
|
|
439
|
-
</div>
|
|
440
|
-
|
|
441
|
-
{/* Pagination */}
|
|
442
|
-
{totalPages > 1 && (
|
|
443
|
-
<div className="flex items-center justify-between px-4 py-3 bg-muted/20 border-t border-border">
|
|
444
|
-
<p className="text-xs text-muted-foreground font-medium">
|
|
445
|
-
Hiển thị từ <span className="font-bold text-foreground">{(currentPage - 1) * pageSize + 1}</span> đến <span className="font-bold text-foreground">{Math.min(currentPage * pageSize, data.length)}</span> / <span className="font-bold text-foreground">{data.length}</span>
|
|
446
|
-
</p>
|
|
447
|
-
<div className="flex items-center gap-1.5">
|
|
448
|
-
<Button
|
|
449
|
-
variant="outline"
|
|
450
|
-
size="sm"
|
|
451
|
-
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
452
|
-
disabled={currentPage === 1}
|
|
453
|
-
className="h-7 text-xs px-2.5 rounded-full bg-card"
|
|
454
|
-
>
|
|
455
|
-
Trang trước
|
|
456
|
-
</Button>
|
|
457
|
-
<div className="text-xs font-semibold text-foreground px-2">
|
|
458
|
-
{currentPage} / {totalPages}
|
|
459
|
-
</div>
|
|
460
|
-
<Button
|
|
461
|
-
variant="outline"
|
|
462
|
-
size="sm"
|
|
463
|
-
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
464
|
-
disabled={currentPage === totalPages}
|
|
465
|
-
className="h-7 text-xs px-2.5 rounded-full bg-card"
|
|
466
|
-
>
|
|
467
|
-
Trang sau
|
|
468
|
-
</Button>
|
|
469
|
-
</div>
|
|
470
|
-
</div>
|
|
471
|
-
)}
|
|
472
|
-
</div>
|
|
473
|
-
);
|
|
474
|
-
}
|
|
@@ -30,13 +30,49 @@ ký tự = nút biến mất vĩnh viễn, không có thông báo. `pnpm test` b
|
|
|
30
30
|
khởi tạo. Với DB đang chạy: viết SQL tay vào `prisma/migrations/<ts>_<tên>/` rồi
|
|
31
31
|
`pnpm prisma:deploy`.
|
|
32
32
|
|
|
33
|
-
**5. Mỗi lần sửa xong: `pnpm type-check` + `pnpm test`.** Không dồn
|
|
34
|
-
cuối. Ratchet trong `src/__tests__/architecture.test.ts` chỉ được
|
|
35
|
-
không được nới ra.
|
|
33
|
+
**5. Mỗi lần sửa xong: `pnpm type-check` + `pnpm lint` + `pnpm test`.** Không dồn
|
|
34
|
+
kiểm tra về cuối. Ratchet trong `src/__tests__/architecture.test.ts` chỉ được
|
|
35
|
+
siết chặt thêm, không được nới ra.
|
|
36
36
|
|
|
37
37
|
**6. Lỗi phải đi vào `error_logs`.** Route dùng `serverError(error, req)` trong
|
|
38
38
|
catch; đừng nuốt lỗi bằng `console.log` rồi trả 200.
|
|
39
39
|
|
|
40
|
+
## Luật nào đang được MÁY chặn
|
|
41
|
+
|
|
42
|
+
Đừng đoán — hỏi thẳng bộ thước:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pnpm guardrails # bảng trạng thái từng thước + chỗ chưa đạt
|
|
46
|
+
pnpm guardrails --verbose # in đủ, không cắt bớt
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Luật sống ở `@goerp/core/guardrails` (dùng chung mọi app), chạy trong
|
|
50
|
+
`pnpm test`. `src/__tests__/architecture.test.ts` chỉ khai phần riêng của app.
|
|
51
|
+
Mỗi thước có một **id** — id đó xuất hiện trong thông báo lỗi của cả test lẫn
|
|
52
|
+
ESLint, nên khi thấy id thì tra thẳng được:
|
|
53
|
+
|
|
54
|
+
| Nhóm | Chặn điều gì |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| `auth/*` | proxy mặc-định-chặn; route API phải gác hoặc **được khai công khai** trong `proxy.ts`; server action không `getSession` trần |
|
|
57
|
+
| `rbac/*` | resource/action dùng ở route + menu phải có trong registry; file quyền là dữ liệu thuần |
|
|
58
|
+
| `layering/*` | ngoài module chỉ import qua public API; `lib` không gọi ngược lên `modules`; domain không dính `next/*` |
|
|
59
|
+
| `one-door/*` | một Prisma client, một cửa kho tập tin / phạm vi chi nhánh; export qua `useExport`; API 500 phải ghi `error_logs` |
|
|
60
|
+
| `structure/*` | đặt tên & vị trí file trong `app/`; `.dockerignore` không nuốt mã nguồn |
|
|
61
|
+
| `design/*` | không `tailwind.config.ts`; token lấy từ core; không `flex {bp}:hidden`; bảng dùng `DataTableWrapper` |
|
|
62
|
+
| `safety/*` | không `FOR UPDATE` thô; không gọi mạng trong `$transaction`; `findMany` trong page phải có `take` |
|
|
63
|
+
| `debt/*` | trần `any` theo thư mục — chỉ được hạ, và **không được dư** |
|
|
64
|
+
|
|
65
|
+
Thước đỏ thì **sửa code**, đừng nới thước. Thật sự cần miễn trừ thì có hai
|
|
66
|
+
đường, cả hai đều để lại dấu vết nhìn thấy khi review:
|
|
67
|
+
|
|
68
|
+
- thêm đường dẫn vào `allowlists[<id>]` — nợ cũ, chỉ được rút bớt. Entry kết
|
|
69
|
+
thúc bằng `/` miễn trừ CẢ THƯ MỤC: dùng khi mọi file trong đó vi phạm vì đúng
|
|
70
|
+
một lý do và số file còn tăng (ví dụ `_handlers/` của dispatcher khai resource
|
|
71
|
+
bằng biến) — liệt kê từng file ở chỗ đó chỉ đẻ ra danh sách phải sửa cho xanh;
|
|
72
|
+
- khai `skip: { "<id>": "lý do" }` — tắt hẳn, phải ghi lý do.
|
|
73
|
+
|
|
74
|
+
Nợ trả xong mà quên gỡ entry thì chính thước sẽ báo entry đó đã thừa.
|
|
75
|
+
|
|
40
76
|
## Bẫy đã trả giá
|
|
41
77
|
|
|
42
78
|
- Đổi schema xong phải **restart `next dev`**. KHÔNG `rm -rf .next` khi dev chạy.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { dirname, resolve } from "node:path"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
|
|
5
|
+
import { includeIgnoreFile } from "@eslint/compat"
|
|
6
|
+
import js from "@eslint/js"
|
|
7
|
+
import nextPlugin from "@next/eslint-plugin-next"
|
|
8
|
+
import goerp from "@goerp/core/eslint"
|
|
9
|
+
import tseslint from "@typescript-eslint/eslint-plugin"
|
|
10
|
+
import tsParser from "@typescript-eslint/parser"
|
|
11
|
+
import importPlugin from "eslint-plugin-import"
|
|
12
|
+
import jsxA11yPlugin from "eslint-plugin-jsx-a11y"
|
|
13
|
+
import prettierPlugin from "eslint-plugin-prettier"
|
|
14
|
+
import reactHooksPlugin from "eslint-plugin-react-hooks"
|
|
15
|
+
import globals from "globals"
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
18
|
+
const gitignorePath = resolve(__dirname, ".gitignore")
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Cấu hình lint của app. Mốc cần giữ: **0 lỗi**.
|
|
22
|
+
*
|
|
23
|
+
* Không phải vì con số đẹp, mà vì một baseline có sẵn 400 lỗi thì cảnh báo thứ
|
|
24
|
+
* 401 — cái thật sự là bug — không ai nhìn thấy. Đã ở 0 rồi thì mọi lỗi mới đều
|
|
25
|
+
* là tín hiệu.
|
|
26
|
+
*
|
|
27
|
+
* `goerp()` là tầng luật kiến trúc dùng chung của @goerp/core (một cửa Prisma /
|
|
28
|
+
* kho tập tin, ranh giới module, useExport…). Nó soi được trong phạm vi một
|
|
29
|
+
* file, ngay lúc gõ; phần cần nhìn nhiều file thì do `pnpm test` (guardrails)
|
|
30
|
+
* và `pnpm guardrails` lo.
|
|
31
|
+
*/
|
|
32
|
+
const eslintConfig = [
|
|
33
|
+
...(existsSync(gitignorePath) ? [includeIgnoreFile(gitignorePath)] : []),
|
|
34
|
+
js.configs.recommended,
|
|
35
|
+
{
|
|
36
|
+
files: ["**/*.{js,jsx,ts,tsx,mjs,cjs}"],
|
|
37
|
+
languageOptions: {
|
|
38
|
+
parser: tsParser,
|
|
39
|
+
parserOptions: { ecmaVersion: "latest", sourceType: "module" },
|
|
40
|
+
globals: { ...globals.browser, ...globals.es2021, ...globals.node },
|
|
41
|
+
},
|
|
42
|
+
plugins: {
|
|
43
|
+
"@next/next": nextPlugin,
|
|
44
|
+
"@typescript-eslint": tseslint,
|
|
45
|
+
"jsx-a11y": jsxA11yPlugin,
|
|
46
|
+
import: importPlugin,
|
|
47
|
+
prettier: prettierPlugin,
|
|
48
|
+
"react-hooks": reactHooksPlugin,
|
|
49
|
+
},
|
|
50
|
+
rules: {
|
|
51
|
+
...(nextPlugin.configs?.recommended?.rules ?? {}),
|
|
52
|
+
...reactHooksPlugin.configs.recommended.rules,
|
|
53
|
+
"@next/next/no-html-link-for-pages": "error",
|
|
54
|
+
"@next/next/no-sync-scripts": "error",
|
|
55
|
+
"react-hooks/error-boundaries": "off",
|
|
56
|
+
"react-hooks/set-state-in-effect": "off",
|
|
57
|
+
"no-undef": "off",
|
|
58
|
+
"no-unused-vars": "off",
|
|
59
|
+
"no-extra-boolean-cast": "off",
|
|
60
|
+
// Nuốt lỗi có chủ đích (`catch {}`) là idiom hợp lệ — phần đáng bắt của
|
|
61
|
+
// no-empty là `if {}` / `for {}` bỏ quên. Chỗ nào nuốt thật thì viết
|
|
62
|
+
// `catch {}` kèm comment lý do, đừng để `catch (e) {}` treo biến.
|
|
63
|
+
"no-empty": ["error", { allowEmptyCatch: true }],
|
|
64
|
+
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
|
65
|
+
"@typescript-eslint/consistent-type-imports": "error",
|
|
66
|
+
"@typescript-eslint/no-empty-object-type": "off",
|
|
67
|
+
"@typescript-eslint/ban-ts-comment": "off",
|
|
68
|
+
"@typescript-eslint/no-unused-vars": [
|
|
69
|
+
"error",
|
|
70
|
+
{
|
|
71
|
+
argsIgnorePattern: "^_",
|
|
72
|
+
varsIgnorePattern: "^_",
|
|
73
|
+
caughtErrorsIgnorePattern: "^_",
|
|
74
|
+
// `const { details, ...rest } = item` là cách bóc bớt field chuẩn —
|
|
75
|
+
// `details` "không dùng" chính là mục đích.
|
|
76
|
+
ignoreRestSiblings: true,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
"prettier/prettier": "error",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
...goerp(),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
export default eslintConfig
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
"prisma:studio": "prisma studio",
|
|
16
16
|
"seed": "tsx prisma/seed.ts",
|
|
17
17
|
"rbac-sync": "tsx scripts/rbac-sync.ts",
|
|
18
|
-
"goerp-features": "goerp-features"
|
|
18
|
+
"goerp-features": "goerp-features",
|
|
19
|
+
"lint": "eslint .",
|
|
20
|
+
"guardrails": "goerp-guardrails"
|
|
19
21
|
},
|
|
20
22
|
"dependencies": {
|
|
21
23
|
"@goerp/core": "npm:@goplusvn/core@^0.1.59",
|
|
@@ -35,16 +37,31 @@
|
|
|
35
37
|
"zod": "3.23.8"
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|
|
40
|
+
"@eslint/compat": "1.2.7",
|
|
41
|
+
"@eslint/js": "9.18.0",
|
|
42
|
+
"@ianvs/prettier-plugin-sort-imports": "4.4.1",
|
|
43
|
+
"@next/eslint-plugin-next": "16.0.3",
|
|
38
44
|
"@tailwindcss/postcss": "4.0.17",
|
|
39
|
-
"@vitejs/plugin-react": "^5.0.4",
|
|
40
45
|
"@testing-library/react": "^16.3.0",
|
|
41
46
|
"@types/bcryptjs": "^2.4.6",
|
|
42
47
|
"@types/node": "22.9.0",
|
|
43
48
|
"@types/pg": "^8.16.0",
|
|
44
49
|
"@types/react": "19.0.12",
|
|
45
50
|
"@types/react-dom": "19.0.4",
|
|
51
|
+
"@typescript-eslint/eslint-plugin": "8.46.4",
|
|
52
|
+
"@typescript-eslint/parser": "8.46.4",
|
|
53
|
+
"@vitejs/plugin-react": "^5.0.4",
|
|
46
54
|
"dotenv": "^16.4.7",
|
|
55
|
+
"eslint": "9.18.0",
|
|
56
|
+
"eslint-config-prettier": "10.1.1",
|
|
57
|
+
"eslint-plugin-import": "2.32.0",
|
|
58
|
+
"eslint-plugin-jsx-a11y": "6.10.2",
|
|
59
|
+
"eslint-plugin-prettier": "5.2.3",
|
|
60
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
61
|
+
"globals": "16.5.0",
|
|
47
62
|
"jsdom": "^27.4.0",
|
|
63
|
+
"prettier": "3.5.3",
|
|
64
|
+
"prettier-plugin-tailwindcss": "0.6.11",
|
|
48
65
|
"prisma": "^7.0.0",
|
|
49
66
|
"tailwindcss": "4.1.3",
|
|
50
67
|
"tsx": "^4.7.1",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** @type {import('prettier').Config} */
|
|
2
|
+
const config = {
|
|
3
|
+
plugins: [
|
|
4
|
+
"prettier-plugin-tailwindcss",
|
|
5
|
+
"@ianvs/prettier-plugin-sort-imports",
|
|
6
|
+
],
|
|
7
|
+
semi: false,
|
|
8
|
+
singleQuote: false,
|
|
9
|
+
trailingComma: "es5",
|
|
10
|
+
printWidth: 80,
|
|
11
|
+
tabWidth: 2,
|
|
12
|
+
bracketSpacing: true,
|
|
13
|
+
arrowParens: "always",
|
|
14
|
+
endOfLine: "lf",
|
|
15
|
+
tailwindStylesheet: "./src/app/globals.css",
|
|
16
|
+
tailwindFunctions: ["cn", "clsx"],
|
|
17
|
+
importOrder: [
|
|
18
|
+
"<BUILTIN_MODULES>",
|
|
19
|
+
"",
|
|
20
|
+
"^(react/(.*)$)|^(react$)",
|
|
21
|
+
"^(react-dom/(.*)$)|^(react-dom$)",
|
|
22
|
+
"^(next/(.*)$)|^(next$)",
|
|
23
|
+
"<THIRD_PARTY_MODULES>",
|
|
24
|
+
"^(lucide-react/(.*)$)|^(lucide-react$)",
|
|
25
|
+
"^(react-icons/(.*)$)|^(react-icons$)",
|
|
26
|
+
"",
|
|
27
|
+
".css$",
|
|
28
|
+
"",
|
|
29
|
+
"<TYPES>^(node:)",
|
|
30
|
+
"<TYPES>",
|
|
31
|
+
"<TYPES>^[.]",
|
|
32
|
+
"/types(.*)$",
|
|
33
|
+
"",
|
|
34
|
+
"/(_data|data)/(.*)$",
|
|
35
|
+
"",
|
|
36
|
+
"/(_schemas|schemas)/(.*)$",
|
|
37
|
+
"",
|
|
38
|
+
"/constants/(.*)$",
|
|
39
|
+
"/configs/(.*)$",
|
|
40
|
+
"/lib/(.*)$",
|
|
41
|
+
"",
|
|
42
|
+
"/(_hooks|hooks)/(.*)$",
|
|
43
|
+
"/(_contexts|contexts)/(.*)$",
|
|
44
|
+
"/(_providers|providers)/(.*)$",
|
|
45
|
+
"^@/components/ui/(.*)$",
|
|
46
|
+
"/(_components|components)/(.*)$",
|
|
47
|
+
"[.]",
|
|
48
|
+
],
|
|
49
|
+
importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"],
|
|
50
|
+
importOrderTypeScriptVersion: "5.0.0",
|
|
51
|
+
importOrderCaseSensitive: true,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default config
|