@goplusvn/core 0.1.67 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +85 -1
  2. package/bin/goerp-guardrails.mjs +45 -0
  3. package/eslint/index.mjs +120 -0
  4. package/package.json +10 -3
  5. package/scripts/doctor.ts +99 -0
  6. package/src/guardrails/__tests__/guardrails.test.ts +430 -0
  7. package/src/guardrails/index.ts +57 -0
  8. package/src/guardrails/preset.ts +75 -0
  9. package/src/guardrails/primitives.ts +307 -0
  10. package/src/guardrails/rules/auth.ts +178 -0
  11. package/src/guardrails/rules/debt.ts +71 -0
  12. package/src/guardrails/rules/design.ts +95 -0
  13. package/src/guardrails/rules/layering.ts +160 -0
  14. package/src/guardrails/rules/one-door.ts +115 -0
  15. package/src/guardrails/rules/rbac.ts +282 -0
  16. package/src/guardrails/rules/safety.ts +86 -0
  17. package/src/guardrails/rules/structure.ts +136 -0
  18. package/src/guardrails/run.ts +130 -0
  19. package/src/guardrails/scanner.ts +144 -0
  20. package/src/guardrails/types.ts +181 -0
  21. package/src/types/index.ts +1 -1
  22. package/src/ui/data-display/shallow-pagination.tsx +189 -0
  23. package/src/ui/index.tsx +1 -0
  24. package/src/user/components/index.ts +1 -0
  25. package/src/user/components/user-toolbar.tsx +8 -2
  26. package/src/user/components/user-visuals.tsx +84 -0
  27. package/src/user/components/users-card-view.tsx +1 -26
  28. package/src/user/components/users-table.tsx +215 -0
  29. package/src/user/pages/users-client-page.tsx +84 -259
  30. package/templates/starter-app/AGENTS.md +39 -3
  31. package/templates/starter-app/eslint.config.mjs +85 -0
  32. package/templates/starter-app/package.json +19 -2
  33. package/templates/starter-app/prettier.config.mjs +54 -0
  34. 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
- const handleSearch = useCallback(
95
- (value: string) => {
96
- const params = new URLSearchParams(searchParams.toString());
97
- if (value) params.set("search", value);
98
- else params.delete("search");
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, searchParams],
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
- const params = new URLSearchParams(searchParams.toString());
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
- const params = new URLSearchParams(searchParams.toString());
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
- <div className="border rounded-md">
218
- <BasicUserTable
219
- data={initialData}
220
- onEdit={(u) => handleEditUser(u)}
221
- />
222
- </div>
223
- ) : (
224
- <UsersCardView
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 kiểm tra về
34
- cuối. Ratchet trong `src/__tests__/architecture.test.ts` chỉ được siết chặt thêm,
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