@goplusvn/core 0.1.69 → 0.1.71

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 (87) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/bin/goerp-init.mjs +15 -0
  3. package/package.json +1 -1
  4. package/src/auth/proxy-gate.ts +21 -2
  5. package/src/cron/__tests__/cron-schedule.test.ts +76 -0
  6. package/src/cron/cron-schedule.ts +128 -0
  7. package/src/cron/simple-cron-job.ts +83 -69
  8. package/src/crud/components/crud-table.tsx +13 -1
  9. package/src/crud/lib/translate-config.ts +16 -2
  10. package/src/guardrails/index.ts +1 -0
  11. package/src/guardrails/rules/auth.ts +54 -1
  12. package/src/guardrails/types.ts +17 -8
  13. package/src/print/print-styles.tsx +4 -1
  14. package/src/providers/brand-theme.ts +20 -0
  15. package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
  16. package/src/security/index.ts +2 -0
  17. package/src/security/pages/sessions-page.tsx +516 -0
  18. package/src/ui/auth/sign-in-form.tsx +64 -9
  19. package/src/ui/errors/error-view.tsx +164 -0
  20. package/src/ui/errors/index.ts +2 -0
  21. package/src/ui/errors/not-found-view.tsx +44 -0
  22. package/src/ui/index.tsx +1 -0
  23. package/src/ui/layout/logo.tsx +54 -19
  24. package/src/user/pages/users-client-page.tsx +12 -7
  25. package/templates/starter-app/.env.example +4 -0
  26. package/templates/starter-app/README.md +6 -2
  27. package/templates/starter-app/husky/pre-commit +10 -0
  28. package/templates/starter-app/package.json +27 -2
  29. package/templates/starter-app/prisma/schema/organization.prisma +20 -0
  30. package/templates/starter-app/prisma/schema/system.prisma +74 -0
  31. package/templates/starter-app/prisma/seed.ts +7 -5
  32. package/templates/starter-app/scripts/migration-new.mjs +89 -0
  33. package/templates/starter-app/src/__tests__/architecture.test.ts +8 -0
  34. package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
  35. package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +69 -0
  36. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +31 -0
  37. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +184 -0
  38. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +28 -0
  39. package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
  40. package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
  41. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +10 -0
  42. package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
  43. package/templates/starter-app/src/app/[lang]/(main)/security/sessions/page.tsx +24 -0
  44. package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
  45. package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +60 -0
  46. package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +39 -0
  47. package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +149 -0
  48. package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +68 -0
  49. package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
  50. package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
  51. package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
  52. package/templates/starter-app/src/app/api/branches/route.ts +26 -0
  53. package/templates/starter-app/src/app/api/departments/route.ts +26 -0
  54. package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
  55. package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +52 -0
  56. package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
  57. package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
  58. package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
  59. package/templates/starter-app/src/app/api/system-categories/route.ts +154 -0
  60. package/templates/starter-app/src/app/api/system-category-groups/route.ts +131 -0
  61. package/templates/starter-app/src/app/api/users/[id]/route.ts +222 -0
  62. package/templates/starter-app/src/app/api/users/route.ts +139 -0
  63. package/templates/starter-app/src/app/global-error.tsx +135 -0
  64. package/templates/starter-app/src/app/icon.svg +6 -0
  65. package/templates/starter-app/src/app/manifest.ts +26 -0
  66. package/templates/starter-app/src/configs/entities/index.ts +5 -1
  67. package/templates/starter-app/src/configs/entities/job-titles.config.ts +119 -0
  68. package/templates/starter-app/src/configs/entities/system-alerts.config.ts +91 -0
  69. package/templates/starter-app/src/configs/entities/users.config.ts +67 -0
  70. package/templates/starter-app/src/configs/permissions/index.ts +37 -0
  71. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
  72. package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
  73. package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
  74. package/templates/starter-app/src/configs/tenant.ts +2 -1
  75. package/templates/starter-app/src/data/dictionary.ts +76 -4
  76. package/templates/starter-app/src/data/navigations.ts +49 -0
  77. package/templates/starter-app/src/instrumentation.ts +5 -16
  78. package/templates/starter-app/src/lib/action-guard.ts +26 -0
  79. package/templates/starter-app/src/lib/better-auth.ts +8 -0
  80. package/templates/starter-app/src/lib/crud/index.ts +2 -0
  81. package/templates/starter-app/src/lib/prisma.ts +3 -2
  82. package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
  83. package/templates/starter-app/src/providers/theme-provider.tsx +27 -13
  84. package/templates/starter-app/src/proxy.ts +8 -3
  85. package/templates/starter-app/src/server/services/company-service.ts +47 -0
  86. package/templates/starter-app/src/server/services/user-service.ts +104 -0
  87. package/templates/starter-app/tsconfig.json +2 -0
@@ -0,0 +1,164 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import Link from "next/link"
5
+ import {
6
+ Button,
7
+ Card,
8
+ CardContent,
9
+ CardHeader,
10
+ CardTitle,
11
+ } from "../index"
12
+ import { AlertTriangle, Check, Copy, Home, RefreshCw } from "lucide-react"
13
+
14
+
15
+ interface ErrorViewProps {
16
+ error: Error & { digest?: string }
17
+ /** `reset` của error boundary — dựng lại đúng segment bị lỗi, không tải lại trang. */
18
+ reset?: () => void
19
+ title?: string
20
+ /** Đoạn giải thích cho người dùng cuối. Mặc định là câu trấn an + hướng xử lý. */
21
+ description?: string
22
+ /** Nút "Về trang chủ" — tắt ở boundary đã nằm sẵn trong vỏ app (có sidebar). */
23
+ showHome?: boolean
24
+ /** Bọc giữa màn hình (dùng cho boundary thay CẢ vỏ app). */
25
+ fullScreen?: boolean
26
+ }
27
+
28
+ /**
29
+ * Màn hình lỗi dùng chung cho mọi `error.tsx`. Có ba việc mà một trang lỗi phải
30
+ * làm, và trước đây app làm thiếu:
31
+ *
32
+ * 1. **Nói mã lỗi cho người dùng đọc được.** `error.digest` là thứ DUY NHẤT nối
33
+ * được màn hình của khách với dòng log trong `error_logs` — không hiện ra thì
34
+ * báo lỗi cho quản trị viên chỉ còn "trang bị lỗi".
35
+ * 2. **Cho lối thoát.** `reset()` dựng lại segment; nếu vẫn hỏng thì còn "Tải
36
+ * lại trang" và "Về trang chủ" — chứ không phải ngõ cụt như trang trắng.
37
+ * 3. **Ghi lại lỗi CHỈ Ở PHÍA CLIENT.** Lỗi ném từ server đã có `digest` và đã
38
+ * được `onRequestError` (instrumentation.ts) ghi kèm stack thật; ở client
39
+ * `error.message` bị Next thay bằng câu chung chung nên ghi thêm chỉ đẻ rác.
40
+ * Vì vậy chỉ POST khi KHÔNG có digest — đó mới là lỗi runtime trình duyệt,
41
+ * khoảng trống mà server không thấy.
42
+ */
43
+ export function ErrorView({
44
+ error,
45
+ reset,
46
+ title = "Đã xảy ra lỗi",
47
+ description = "Hệ thống gặp sự cố khi hiển thị nội dung này. Đây là lỗi kỹ thuật tạm thời — dữ liệu và quyền truy cập của bạn không thay đổi. Vui lòng thử lại; nếu vẫn lỗi, gửi mã lỗi bên dưới cho quản trị viên.",
48
+ showHome = true,
49
+ fullScreen = false,
50
+ }: ErrorViewProps) {
51
+ const [copied, setCopied] = useState(false)
52
+
53
+ useEffect(() => {
54
+ console.error("[ErrorView] Trang gặp lỗi", error, { digest: error.digest })
55
+
56
+ if (error.digest) return
57
+ // Fire-and-forget: ghi log KHÔNG bao giờ được làm hỏng thêm trang lỗi.
58
+ void fetch("/api/error-logs", {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify({
62
+ message: error.message || "Lỗi client không rõ nguyên nhân",
63
+ detail: error.stack,
64
+ context: "ErrorView",
65
+ url: window.location.href,
66
+ severity: "error",
67
+ }),
68
+ }).catch(() => {})
69
+ }, [error])
70
+
71
+ const handleCopy = async () => {
72
+ try {
73
+ await navigator.clipboard.writeText(error.digest ?? error.message)
74
+ setCopied(true)
75
+ setTimeout(() => setCopied(false), 2000)
76
+ } catch {
77
+ // Trình duyệt chặn clipboard (http, quyền) — người dùng vẫn đọc/chép tay được.
78
+ }
79
+ }
80
+
81
+ const card = (
82
+ <Card className="w-full max-w-lg">
83
+ <CardHeader>
84
+ <CardTitle className="flex items-center gap-2 text-destructive">
85
+ <AlertTriangle className="h-5 w-5 shrink-0" />
86
+ {title}
87
+ </CardTitle>
88
+ </CardHeader>
89
+ <CardContent className="space-y-4">
90
+ <p className="text-sm text-muted-foreground">{description}</p>
91
+
92
+ {error.digest ? (
93
+ <div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
94
+ <span className="text-xs text-muted-foreground">Mã lỗi</span>
95
+ <code className="flex-1 truncate font-mono text-xs text-foreground">
96
+ {error.digest}
97
+ </code>
98
+ <Button
99
+ variant="ghost"
100
+ size="icon"
101
+ className="h-7 w-7 shrink-0"
102
+ onClick={handleCopy}
103
+ title="Sao chép mã lỗi"
104
+ >
105
+ {copied ? (
106
+ <Check className="h-3.5 w-3.5 text-emerald-600" />
107
+ ) : (
108
+ <Copy className="h-3.5 w-3.5" />
109
+ )}
110
+ </Button>
111
+ </div>
112
+ ) : null}
113
+
114
+ {/* Chi tiết kỹ thuật chỉ mở ở máy dev. Trên production Next đã thay
115
+ `message` bằng câu chung chung nên hiện ra cũng không giúp gì, mà
116
+ còn dễ lộ đường dẫn nội bộ. */}
117
+ {process.env.NODE_ENV === "development" && error.stack ? (
118
+ <details className="rounded-md border border-border bg-muted/40 p-3">
119
+ <summary className="cursor-pointer text-xs font-medium text-muted-foreground">
120
+ Chi tiết kỹ thuật (chỉ hiện khi chạy dev)
121
+ </summary>
122
+ <pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-all text-[11px] leading-relaxed text-muted-foreground">
123
+ {error.stack}
124
+ </pre>
125
+ </details>
126
+ ) : null}
127
+
128
+ <div className="flex flex-wrap gap-2">
129
+ {reset ? (
130
+ <Button onClick={reset}>
131
+ <RefreshCw className="mr-2 h-4 w-4" />
132
+ Thử lại
133
+ </Button>
134
+ ) : null}
135
+ <Button variant="outline" onClick={() => window.location.reload()}>
136
+ Tải lại trang
137
+ </Button>
138
+ {showHome ? (
139
+ <Button variant="ghost" asChild>
140
+ <Link href="/">
141
+ <Home className="mr-2 h-4 w-4" />
142
+ Về trang chủ
143
+ </Link>
144
+ </Button>
145
+ ) : null}
146
+ </div>
147
+ </CardContent>
148
+ </Card>
149
+ )
150
+
151
+ if (!fullScreen) {
152
+ return (
153
+ <div className="flex min-h-0 flex-1 items-center justify-center p-4">
154
+ {card}
155
+ </div>
156
+ )
157
+ }
158
+
159
+ return (
160
+ <div className="flex min-h-screen items-center justify-center bg-background p-4">
161
+ {card}
162
+ </div>
163
+ )
164
+ }
@@ -0,0 +1,2 @@
1
+ export { NotFoundView } from "./not-found-view";
2
+ export { ErrorView } from "./error-view";
@@ -0,0 +1,44 @@
1
+ "use client"
2
+ import Link from "next/link"
3
+ import { Button } from "../index"
4
+ import { FileQuestion } from "lucide-react"
5
+
6
+ import { cn } from "../../utils"
7
+
8
+ /**
9
+ * Màn "không tìm thấy" cho `not-found.tsx`. Tách khỏi `NotFoundPage` của core vì
10
+ * bản core là tiếng Anh, `min-h-screen` (tràn khi nằm trong vỏ app có sidebar)
11
+ * và phụ thuộc một file SVG minh hoạ.
12
+ *
13
+ * Đây là màn cho `notFound()` gọi TỪ TRONG trang — chứng từ bị xoá, id sai, hết
14
+ * quyền xem. Khác với URL gõ sai: cái đó do `[lang]/[...not-found]/page.tsx` lo.
15
+ */
16
+ export function NotFoundView({
17
+ title = "Không tìm thấy nội dung",
18
+ description = "Bản ghi bạn mở không còn tồn tại, đã bị xoá, hoặc đường dẫn không đúng.",
19
+ homeHref = "/",
20
+ fullScreen = false,
21
+ }: {
22
+ title?: string
23
+ description?: string
24
+ homeHref?: string
25
+ fullScreen?: boolean
26
+ }) {
27
+ return (
28
+ <div
29
+ className={cn(
30
+ "flex flex-col items-center justify-center gap-4 p-8 text-center",
31
+ fullScreen ? "min-h-screen bg-background" : "min-h-0 flex-1"
32
+ )}
33
+ >
34
+ <FileQuestion className="h-12 w-12 text-muted-foreground" />
35
+ <div className="space-y-1">
36
+ <h1 className="text-xl font-bold">{title}</h1>
37
+ <p className="max-w-md text-sm text-muted-foreground">{description}</p>
38
+ </div>
39
+ <Button asChild>
40
+ <Link href={homeHref}>Về trang chủ</Link>
41
+ </Button>
42
+ </div>
43
+ )
44
+ }
package/src/ui/index.tsx CHANGED
@@ -10,6 +10,7 @@ export * from "./primitives/client";
10
10
  export * from "./forms";
11
11
  export * from "./data-display";
12
12
  export * from "./feedback";
13
+ export * from "./errors";
13
14
  export * from "./filters";
14
15
  export * from "./layout";
15
16
  export * from "./auth";
@@ -7,10 +7,37 @@ import { cn } from "../../utils";
7
7
 
8
8
  // Defaults used when neither props nor a TenantProvider supply branding — keeps
9
9
  // the component working out of the box (and backward compatible).
10
- const DEFAULT_LOGO_SRC = "/images/logos/goeat_logo.png";
11
10
  const DEFAULT_COMPANY = "GoERP";
12
11
  const DEFAULT_TAGLINE = "ERP System";
13
12
 
13
+ /**
14
+ * KHÔNG có logo cấu hình → monogram chữ cái đầu, KHÔNG trỏ vào một file magic
15
+ * trong public/. Bản cũ default "/images/logos/goeat_logo.png" (di sản goeat)
16
+ * làm mọi app mới init 400 ảnh trên từng trang vì template không ship file đó.
17
+ */
18
+ function Monogram({
19
+ name,
20
+ size,
21
+ className,
22
+ }: {
23
+ name: string;
24
+ size: number;
25
+ className?: string;
26
+ }) {
27
+ return (
28
+ <span
29
+ aria-hidden
30
+ className={cn(
31
+ "flex shrink-0 items-center justify-center rounded-md bg-primary font-bold text-primary-foreground",
32
+ className,
33
+ )}
34
+ style={{ width: size, height: size, fontSize: Math.round(size * 0.5) }}
35
+ >
36
+ {(name.trim()[0] ?? "G").toUpperCase()}
37
+ </span>
38
+ );
39
+ }
40
+
14
41
  interface LogoProps {
15
42
  className?: string;
16
43
  size?: "sm" | "md" | "lg" | "xl" | "2xl";
@@ -40,21 +67,25 @@ export function Logo({
40
67
  tagline,
41
68
  }: LogoProps) {
42
69
  const branding = useOptionalTenant()?.branding;
43
- const src = logoSrc ?? branding?.logo ?? DEFAULT_LOGO_SRC;
70
+ const src = logoSrc ?? branding?.logo;
44
71
  const name = companyName ?? branding?.companyName ?? DEFAULT_COMPANY;
45
72
  const sub = tagline ?? branding?.tagline ?? DEFAULT_TAGLINE;
46
73
  const dimensions = sizeMap[size];
47
74
 
48
75
  return (
49
76
  <div className={cn("flex items-center gap-2", className)}>
50
- <Image
51
- src={src}
52
- alt={`${name} Logo`}
53
- width={dimensions.width}
54
- height={dimensions.height}
55
- className="object-contain"
56
- priority
57
- />
77
+ {src ? (
78
+ <Image
79
+ src={src}
80
+ alt={`${name} Logo`}
81
+ width={dimensions.width}
82
+ height={dimensions.height}
83
+ className="object-contain"
84
+ priority
85
+ />
86
+ ) : (
87
+ <Monogram name={name} size={dimensions.height} />
88
+ )}
58
89
  {showText && !collapsed && (
59
90
  <div className="flex flex-col leading-none">
60
91
  <span className="text-base font-bold text-foreground">{name}</span>
@@ -77,19 +108,23 @@ export function LogoCompact({
77
108
  companyName?: string;
78
109
  }) {
79
110
  const branding = useOptionalTenant()?.branding;
80
- const src = logoSrc ?? branding?.logo ?? DEFAULT_LOGO_SRC;
111
+ const src = logoSrc ?? branding?.logo;
81
112
  const name = companyName ?? branding?.companyName ?? DEFAULT_COMPANY;
82
113
 
83
114
  return (
84
115
  <div className={cn("flex items-center justify-center", className)}>
85
- <Image
86
- src={src}
87
- alt={name}
88
- width={32}
89
- height={32}
90
- className="object-contain"
91
- priority
92
- />
116
+ {src ? (
117
+ <Image
118
+ src={src}
119
+ alt={name}
120
+ width={32}
121
+ height={32}
122
+ className="object-contain"
123
+ priority
124
+ />
125
+ ) : (
126
+ <Monogram name={name} size={32} />
127
+ )}
93
128
  </div>
94
129
  );
95
130
  }
@@ -212,7 +212,7 @@ export function UsersClientPage({
212
212
  };
213
213
 
214
214
  return (
215
- <div className="flex flex-col gap-4 relative">
215
+ <div className="flex h-full flex-col gap-4 relative">
216
216
  <UserStats stats={stats} />
217
217
 
218
218
  <UserToolbar
@@ -227,7 +227,10 @@ export function UsersClientPage({
227
227
  permissions={permissions}
228
228
  />
229
229
 
230
- <div className="min-h-[400px] flex flex-col">
230
+ {/* flex-1 + min-h-0: khu nội dung chiếm hết chiều cao còn lại (cùng
231
+ chuỗi flex với crud-page) để thanh phân trang nằm SÁT ĐÁY, không
232
+ lửng giữa trang khi ít dữ liệu. */}
233
+ <div className="flex flex-1 min-h-0 flex-col">
231
234
  {viewMode === "table" ? (
232
235
  <UsersTable
233
236
  data={pageData}
@@ -240,11 +243,13 @@ export function UsersClientPage({
240
243
  />
241
244
  ) : (
242
245
  <>
243
- <UsersCardView
244
- data={pageData}
245
- onSelect={handleSelectUser}
246
- onViewPermissions={menuTree ? setPermUser : undefined}
247
- />
246
+ <div className="flex-1 min-h-0 overflow-auto">
247
+ <UsersCardView
248
+ data={pageData}
249
+ onSelect={handleSelectUser}
250
+ onViewPermissions={menuTree ? setPermUser : undefined}
251
+ />
252
+ </div>
248
253
  {/* Cùng thanh phân trang với chế độ bảng (bảng tự vẽ trong khung
249
254
  DataTable) — card view vẽ dưới lưới, giống trang khách hàng. */}
250
255
  {total > 0 && (
@@ -23,3 +23,7 @@ BETTER_AUTH_URL="http://localhost:3010"
23
23
  # clone; bỏ đi khi đã seed tài khoản thật. Đặt trên production cũng vô hiệu —
24
24
  # cả app lẫn core đều chặn cứng khi NODE_ENV=production.
25
25
  # BYPASS_AUTH=1
26
+
27
+ # `pnpm migration:new` tự tạo/xoá DB shadow cạnh DB chính. Tài khoản DB không
28
+ # có quyền CREATE DATABASE thì tạo sẵn một DB trống rồi trỏ vào đây.
29
+ # SHADOW_DATABASE_URL="postgresql://user:password@localhost:5432/starter_app_shadow"
@@ -96,10 +96,13 @@ Xong. Không viết route, không viết engine.
96
96
  ## Lấy thêm tính năng từ core
97
97
 
98
98
  ```bash
99
- pnpm goerp-features list # các feature core có sẵn
100
- pnpm goerp-features add <name> # chép schema + migration vào app
99
+ pnpm goerp-features status # feature nào đã materialize, feature nào chưa
100
+ pnpm goerp-features sync # chép schema fragment + migration của MỌI feature vào app
101
+ pnpm goerp-features sync --only <name> # chỉ một feature
101
102
  ```
102
103
 
104
+ Sau `sync`: `pnpm prisma:deploy && pnpm prisma:generate`.
105
+
103
106
  Feature đã cắm sẵn trong template: `audit-logs`, `background-tasks`,
104
107
  `error-logs`, `notifications`, `system-jobs`.
105
108
 
@@ -114,6 +117,7 @@ Feature đã cắm sẵn trong template: `audit-logs`, `background-tasks`,
114
117
  | `pnpm test` | Ratchet kiến trúc. Chạy trước mỗi lần commit. |
115
118
  | `pnpm rbac-sync` | Đồng bộ Permission Registry → DB (idempotent, chạy lại vô hại). |
116
119
  | `pnpm prisma:deploy` | Áp migration đang chờ. **Cách duy nhất** đụng vào DB có dữ liệu thật. |
120
+ | `pnpm migration:new <ten>` | Sinh migration mới từ diff schema (shadow DB tự tạo/xoá — Prisma 7 đã bỏ `--shadow-database-url`). |
117
121
  | `pnpm seed` | Vai trò + tài khoản admin + dữ liệu mẫu. Chạy TRƯỚC `rbac-sync`. |
118
122
 
119
123
  ---
@@ -0,0 +1,10 @@
1
+ echo "🔍 Type check..."
2
+ pnpm exec tsc --noEmit
3
+
4
+ # Ratchet kiến trúc (~1s). Bài học vinhhoa: guardrail chỉ chạy khi ai đó nhớ gõ
5
+ # `pnpm test` thì âm thầm đỏ nhiều tuần — móc vào pre-commit để không ai quên.
6
+ echo "🧱 Architecture guardrails..."
7
+ pnpm exec vitest run src/__tests__/architecture.test.ts
8
+
9
+ echo "💅 Lint staged..."
10
+ pnpm exec lint-staged
@@ -17,20 +17,26 @@
17
17
  "rbac-sync": "tsx scripts/rbac-sync.ts",
18
18
  "goerp-features": "goerp-features",
19
19
  "lint": "eslint .",
20
- "guardrails": "goerp-guardrails"
20
+ "guardrails": "goerp-guardrails",
21
+ "migration:new": "node scripts/migration-new.mjs",
22
+ "prepare": "husky || true"
21
23
  },
22
24
  "dependencies": {
23
- "@goerp/core": "npm:@goplusvn/core@^0.1.59",
25
+ "@goerp/core": "npm:@goplusvn/core@^0.1.71",
26
+ "@hookform/resolvers": "3.9.0",
24
27
  "@prisma/adapter-pg": "^7.0.0",
25
28
  "@prisma/client": "^7.0.0",
26
29
  "bcryptjs": "^3.0.3",
27
30
  "better-auth": "1.6.23",
31
+ "date-fns": "^4.1.0",
32
+ "date-fns-tz": "^3.2.0",
28
33
  "lucide-react": "0.446.0",
29
34
  "next": "16.0.10",
30
35
  "next-themes": "^0.2.1",
31
36
  "pg": "^8.16.3",
32
37
  "react": "19.2.0",
33
38
  "react-dom": "19.2.0",
39
+ "react-hook-form": "^7.54.2",
34
40
  "server-only": "^0.0.1",
35
41
  "sonner": "2.0.2",
36
42
  "swr": "^2.3.6",
@@ -59,7 +65,9 @@
59
65
  "eslint-plugin-prettier": "5.2.3",
60
66
  "eslint-plugin-react-hooks": "^7.0.1",
61
67
  "globals": "16.5.0",
68
+ "husky": "^9.1.7",
62
69
  "jsdom": "^27.4.0",
70
+ "lint-staged": "^17.3.0",
63
71
  "prettier": "3.5.3",
64
72
  "prettier-plugin-tailwindcss": "0.6.11",
65
73
  "prisma": "^7.0.0",
@@ -68,5 +76,22 @@
68
76
  "tw-animate-css": "1.2.5",
69
77
  "typescript": "5.8.3",
70
78
  "vitest": "^4.0.16"
79
+ },
80
+ "pnpm": {
81
+ "onlyBuiltDependencies": [
82
+ "@prisma/engines",
83
+ "esbuild",
84
+ "prisma",
85
+ "sharp"
86
+ ]
87
+ },
88
+ "lint-staged": {
89
+ "*.{ts,tsx}": [
90
+ "eslint --fix",
91
+ "prettier --write"
92
+ ],
93
+ "*.{json,md,css}": [
94
+ "prettier --write"
95
+ ]
71
96
  }
72
97
  }
@@ -29,3 +29,23 @@ model UserBranch {
29
29
  @@index([userId])
30
30
  @@map("user_branches")
31
31
  }
32
+
33
+ // Chức danh trong tổ chức — entity CRUD generic (/crud/job-titles). Chép từ
34
+ // app chuẩn, BỎ relation profiles (template không có model Profile).
35
+ model JobTitle {
36
+ id String @id @map("id")
37
+ code String @unique @map("code")
38
+ name String @map("name")
39
+ description String? @map("description")
40
+ level Int @default(0) @map("level")
41
+ status String @default("active") @map("status")
42
+ createdAt DateTime @default(now()) @map("created_at")
43
+ updatedAt DateTime @updatedAt @map("updated_at")
44
+ createdBy String? @map("created_by")
45
+ updatedBy String? @map("updated_by")
46
+
47
+ @@index([code])
48
+ @@index([status])
49
+ @@index([level])
50
+ @@map("job_titles")
51
+ }
@@ -20,3 +20,77 @@ model SystemConfig {
20
20
 
21
21
  @@map("system_configs")
22
22
  }
23
+
24
+ // Danh mục hệ thống: cặp bảng nhóm → mục, phục vụ trang /system-categories
25
+ // (SystemCategoryPage của @goerp/core). Định nghĩa chép nguyên từ app chuẩn —
26
+ // GIỮ tên bảng/cột để dữ liệu port qua lại được.
27
+ model SystemCategory {
28
+ id String @id @map("id")
29
+ code String @unique @map("code")
30
+ name String @map("name")
31
+ groupCode String @map("group_code")
32
+ description String @map("description")
33
+ status String @default("active") @map("status")
34
+ order Int? @map("order")
35
+ createdAt DateTime @default(now()) @map("created_at")
36
+ createdBy String @map("created_by")
37
+ updatedAt DateTime @map("updated_at")
38
+ updatedBy String @map("updated_by")
39
+
40
+ @@map("system_categories")
41
+ }
42
+
43
+ model SystemCategoryGroup {
44
+ id String @id @map("id")
45
+ code String @unique @map("code")
46
+ name String @map("name")
47
+ description String @map("description")
48
+ status String @default("active") @map("status")
49
+ createdAt DateTime @default(now()) @map("created_at")
50
+ createdBy String @map("created_by")
51
+ updatedAt DateTime @map("updated_at")
52
+ updatedBy String @map("updated_by")
53
+
54
+ @@map("system_category_groups")
55
+ }
56
+
57
+ // Cảnh báo/thông báo hệ thống — entity CRUD generic (/crud/system-alerts).
58
+ // App chuẩn KHÔNG có model riêng (map tạm vào system_logs); template khai
59
+ // model thật khớp các field của system-alerts.config.ts.
60
+ model SystemAlert {
61
+ id String @id @default(cuid()) @map("id")
62
+ title String @map("title")
63
+ message String @map("message")
64
+ type String @default("info") @map("type")
65
+ status String @default("pending") @map("status")
66
+ createdAt DateTime @default(now()) @map("created_at")
67
+ createdBy String? @map("created_by")
68
+ updatedAt DateTime @updatedAt @map("updated_at")
69
+ updatedBy String? @map("updated_by")
70
+
71
+ @@index([status])
72
+ @@index([type])
73
+ @@map("system_alerts")
74
+ }
75
+
76
+ /// Hồ sơ công ty gốc — MỘT dòng duy nhất, dùng cho biểu mẫu/hoá đơn/branding.
77
+ /// (Đơn giản hoá từ vinhhoa: không quan hệ branches — Branch của template
78
+ /// độc lập; app đa-công-ty tự thêm quan hệ sau.)
79
+ model Company {
80
+ id String @id @default(cuid()) @map("id")
81
+ name String @map("name")
82
+ taxCode String @unique @map("tax_code")
83
+ address String @map("address")
84
+ phone String @map("phone")
85
+ email String @map("email")
86
+ logoUrl String? @map("logo_url")
87
+ systemName String? @map("system_name")
88
+ status String @default("active") @map("status")
89
+ createdAt DateTime @default(now()) @map("created_at")
90
+ createdBy String @default("system") @map("created_by")
91
+ updatedAt DateTime @updatedAt @map("updated_at")
92
+ updatedBy String @default("system") @map("updated_by")
93
+
94
+ @@index([status])
95
+ @@map("companies")
96
+ }
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * Seed dữ liệu khởi tạo — chạy được nhiều lần (toàn upsert).
3
3
  *
4
- * pnpm prisma:deploy && pnpm rbac-sync && pnpm seed
4
+ * pnpm prisma:deploy && pnpm seed && pnpm rbac-sync
5
5
  *
6
- * Phân công ràng: `rbac-sync` sở hữu resource/action/grant mặc định (đọc từ
6
+ * THỨ TỰ `seed rbac-sync` BẮT BUỘC: rbac-sync chỉ cấp grant mặc định cho
7
+ * resource nó vừa TẠO MỚI, và grant cần VAI TRÒ (do seed tạo) tồn tại sẵn.
8
+ * Chạy rbac-sync trước seed thì grant bị bỏ qua VĨNH VIỄN trong im lặng —
9
+ * lối thoát duy nhất là `pnpm rbac-sync --force-grants`.
10
+ *
11
+ * Phân công: `rbac-sync` sở hữu resource/action/grant mặc định (đọc từ
7
12
  * Permission Registry); file này chỉ tạo những thứ registry không biết —
8
13
  * VAI TRÒ, tài khoản quản trị, chi nhánh, cấu hình và vài dòng dữ liệu mẫu.
9
- * Vì vậy hãy chạy rbac-sync TRƯỚC seed ở lần đầu... nhưng grant mặc định cần
10
- * vai trò tồn tại sẵn, nên seed cũng tạo vai trò và bạn chạy rbac-sync lần nữa
11
- * sau seed. Thứ tự an toàn nhất: seed → rbac-sync.
12
14
  */
13
15
 
14
16
  import { PrismaPg } from "@prisma/adapter-pg"