@goplusvn/core 0.1.70 → 0.1.72

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 (175) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/bin/goerp-init.mjs +15 -0
  3. package/package.json +2 -1
  4. package/src/auth/index.ts +5 -1
  5. package/src/auth/proxy-gate.ts +21 -2
  6. package/src/configs/entities/departments.config.ts +1 -0
  7. package/src/configs/entities/material-categories.config.ts +1 -0
  8. package/src/cron/__tests__/cron-schedule.test.ts +76 -0
  9. package/src/cron/cron-schedule.ts +128 -0
  10. package/src/cron/simple-cron-job.ts +83 -69
  11. package/src/crud/components/crud-export-button.tsx +3 -2
  12. package/src/crud/components/crud-page.tsx +56 -25
  13. package/src/crud/components/crud-row-actions.tsx +16 -4
  14. package/src/crud/components/crud-table.tsx +13 -1
  15. package/src/crud/crud-route-handlers.test.ts +352 -0
  16. package/src/crud/crud-route-handlers.ts +260 -13
  17. package/src/crud/index.ts +3 -0
  18. package/src/crud/lib/coerce.test.ts +118 -0
  19. package/src/crud/lib/coerce.ts +95 -0
  20. package/src/crud/lib/crud-utils.ts +4 -0
  21. package/src/crud/lib/entity-endpoints.test.ts +41 -0
  22. package/src/crud/lib/entity-endpoints.ts +23 -0
  23. package/src/crud/lib/errors.ts +14 -0
  24. package/src/crud/lib/import-request.ts +99 -0
  25. package/src/crud/lib/mutation-builder.test.ts +114 -0
  26. package/src/crud/lib/mutation-builder.ts +27 -34
  27. package/src/crud/lib/permissions.test.ts +57 -0
  28. package/src/crud/lib/permissions.ts +25 -13
  29. package/src/crud/lib/query-builder.ts +13 -6
  30. package/src/crud/lib/translate-config.ts +16 -2
  31. package/src/crud/pages/entity-crud-page.tsx +11 -8
  32. package/src/crud/server-service.test.ts +112 -0
  33. package/src/crud/server-service.ts +52 -27
  34. package/src/crud/server.ts +11 -1
  35. package/src/guardrails/index.ts +1 -0
  36. package/src/guardrails/rules/auth.ts +54 -1
  37. package/src/guardrails/rules/design.ts +3 -2
  38. package/src/guardrails/types.ts +17 -8
  39. package/src/providers/brand-theme.ts +20 -0
  40. package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
  41. package/src/security/index.ts +2 -0
  42. package/src/security/pages/sessions-page.tsx +516 -0
  43. package/src/types/index.ts +75 -10
  44. package/src/ui/auth/sign-in-form.tsx +64 -9
  45. package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
  46. package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
  47. package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
  48. package/src/ui/data-display/data-table/data-table.tsx +68 -23
  49. package/src/ui/data-display/data-table-pagination.tsx +25 -8
  50. package/src/ui/data-display/index.tsx +1 -0
  51. package/src/ui/data-display/use-client-pagination.ts +48 -0
  52. package/src/ui/errors/error-view.tsx +164 -0
  53. package/src/ui/errors/index.ts +2 -0
  54. package/src/ui/errors/not-found-view.tsx +44 -0
  55. package/src/ui/index.tsx +1 -0
  56. package/src/ui/layout/logo.tsx +54 -19
  57. package/src/user/pages/users-client-page.tsx +12 -7
  58. package/templates/starter-app/.env.example +4 -0
  59. package/templates/starter-app/AGENTS.md +1 -1
  60. package/templates/starter-app/README.md +6 -2
  61. package/templates/starter-app/husky/pre-commit +10 -0
  62. package/templates/starter-app/package.json +27 -2
  63. package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
  64. package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
  65. package/templates/starter-app/prisma/schema/organization.prisma +20 -0
  66. package/templates/starter-app/prisma/schema/system.prisma +74 -0
  67. package/templates/starter-app/prisma/seed.ts +7 -5
  68. package/templates/starter-app/scripts/migration-new.mjs +89 -0
  69. package/templates/starter-app/scripts/rbac-sync.ts +49 -21
  70. package/templates/starter-app/src/__tests__/architecture.test.ts +11 -2
  71. package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
  72. package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +68 -0
  73. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +33 -0
  74. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +195 -0
  75. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +29 -0
  76. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
  77. package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
  78. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
  79. package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
  80. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +13 -3
  81. package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
  82. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
  83. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
  84. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
  85. package/templates/starter-app/src/app/[lang]/(main)/security/sessions/page.tsx +24 -0
  86. package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
  87. package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +62 -0
  88. package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +41 -0
  89. package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +157 -0
  90. package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +69 -0
  91. package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
  92. package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
  93. package/templates/starter-app/src/app/[lang]/layout.tsx +2 -2
  94. package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
  95. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
  96. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
  97. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
  98. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
  99. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
  100. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
  101. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
  102. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
  103. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
  104. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
  105. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
  106. package/templates/starter-app/src/app/api/branches/route.ts +26 -0
  107. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
  108. package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
  109. package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
  110. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
  111. package/templates/starter-app/src/app/api/departments/route.ts +26 -0
  112. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
  113. package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
  114. package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
  115. package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
  116. package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
  117. package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
  118. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
  119. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
  120. package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
  121. package/templates/starter-app/src/app/api/roles/route.ts +2 -1
  122. package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +52 -0
  123. package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
  124. package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
  125. package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
  126. package/templates/starter-app/src/app/api/system-categories/route.ts +179 -0
  127. package/templates/starter-app/src/app/api/system-category-groups/route.ts +149 -0
  128. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
  129. package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
  130. package/templates/starter-app/src/app/api/upload/route.ts +1 -2
  131. package/templates/starter-app/src/app/api/users/[id]/route.ts +230 -0
  132. package/templates/starter-app/src/app/api/users/route.ts +141 -0
  133. package/templates/starter-app/src/app/global-error.tsx +135 -0
  134. package/templates/starter-app/src/app/globals.css +0 -1
  135. package/templates/starter-app/src/app/icon.svg +6 -0
  136. package/templates/starter-app/src/app/manifest.ts +26 -0
  137. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
  138. package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
  139. package/templates/starter-app/src/configs/entities/index.ts +5 -1
  140. package/templates/starter-app/src/configs/entities/job-titles.config.ts +121 -0
  141. package/templates/starter-app/src/configs/entities/system-alerts.config.ts +93 -0
  142. package/templates/starter-app/src/configs/entities/users.config.ts +80 -0
  143. package/templates/starter-app/src/configs/i18n.ts +6 -6
  144. package/templates/starter-app/src/configs/permissions/index.ts +37 -0
  145. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
  146. package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
  147. package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
  148. package/templates/starter-app/src/configs/tenant.ts +2 -1
  149. package/templates/starter-app/src/data/dictionary.ts +77 -4
  150. package/templates/starter-app/src/data/navigations.ts +60 -1
  151. package/templates/starter-app/src/instrumentation.ts +5 -16
  152. package/templates/starter-app/src/lib/action-guard.ts +25 -0
  153. package/templates/starter-app/src/lib/api-handler.ts +27 -19
  154. package/templates/starter-app/src/lib/auth.ts +3 -1
  155. package/templates/starter-app/src/lib/better-auth.ts +8 -0
  156. package/templates/starter-app/src/lib/branch-scope.ts +1 -1
  157. package/templates/starter-app/src/lib/crud/index.ts +5 -10
  158. package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
  159. package/templates/starter-app/src/lib/logger.ts +11 -4
  160. package/templates/starter-app/src/lib/page-guard.ts +2 -3
  161. package/templates/starter-app/src/lib/prisma.ts +16 -7
  162. package/templates/starter-app/src/lib/rbac/access.ts +2 -2
  163. package/templates/starter-app/src/lib/storage.ts +3 -1
  164. package/templates/starter-app/src/providers/index.tsx +2 -1
  165. package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
  166. package/templates/starter-app/src/providers/theme-provider.tsx +27 -12
  167. package/templates/starter-app/src/proxy.ts +8 -3
  168. package/templates/starter-app/src/server/services/company-service.ts +49 -0
  169. package/templates/starter-app/src/server/services/notification-service.ts +6 -6
  170. package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
  171. package/templates/starter-app/src/server/services/user-service.ts +104 -0
  172. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
  173. package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
  174. package/templates/starter-app/tsconfig.json +10 -0
  175. package/templates/starter-app/vitest.config.ts +17 -1
@@ -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"
@@ -58,7 +58,7 @@ ESLint, nên khi thấy id thì tra thẳng được:
58
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
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
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` |
61
+ | `design/*` | không `tailwind.config.ts`; token lấy từ core; không `flex {bp}:hidden`; bảng dùng `DataTable` từ `@goerp/core/ui` |
62
62
  | `safety/*` | không `FOR UPDATE` thô; không gọi mạng trong `$transaction`; `findMany` trong page phải có `take` |
63
63
  | `debt/*` | trần `any` theo thư mục — chỉ được hạ, và **không được dư** |
64
64
 
@@ -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
  }
@@ -0,0 +1,87 @@
1
+ -- CreateTable
2
+ CREATE TABLE "job_titles" (
3
+ "id" TEXT NOT NULL,
4
+ "code" TEXT NOT NULL,
5
+ "name" TEXT NOT NULL,
6
+ "description" TEXT,
7
+ "level" INTEGER NOT NULL DEFAULT 0,
8
+ "status" TEXT NOT NULL DEFAULT 'active',
9
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10
+ "updated_at" TIMESTAMP(3) NOT NULL,
11
+ "created_by" TEXT,
12
+ "updated_by" TEXT,
13
+
14
+ CONSTRAINT "job_titles_pkey" PRIMARY KEY ("id")
15
+ );
16
+
17
+ -- CreateTable
18
+ CREATE TABLE "system_categories" (
19
+ "id" TEXT NOT NULL,
20
+ "code" TEXT NOT NULL,
21
+ "name" TEXT NOT NULL,
22
+ "group_code" TEXT NOT NULL,
23
+ "description" TEXT NOT NULL,
24
+ "status" TEXT NOT NULL DEFAULT 'active',
25
+ "order" INTEGER,
26
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
27
+ "created_by" TEXT NOT NULL,
28
+ "updated_at" TIMESTAMP(3) NOT NULL,
29
+ "updated_by" TEXT NOT NULL,
30
+
31
+ CONSTRAINT "system_categories_pkey" PRIMARY KEY ("id")
32
+ );
33
+
34
+ -- CreateTable
35
+ CREATE TABLE "system_category_groups" (
36
+ "id" TEXT NOT NULL,
37
+ "code" TEXT NOT NULL,
38
+ "name" TEXT NOT NULL,
39
+ "description" TEXT NOT NULL,
40
+ "status" TEXT NOT NULL DEFAULT 'active',
41
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
42
+ "created_by" TEXT NOT NULL,
43
+ "updated_at" TIMESTAMP(3) NOT NULL,
44
+ "updated_by" TEXT NOT NULL,
45
+
46
+ CONSTRAINT "system_category_groups_pkey" PRIMARY KEY ("id")
47
+ );
48
+
49
+ -- CreateTable
50
+ CREATE TABLE "system_alerts" (
51
+ "id" TEXT NOT NULL,
52
+ "title" TEXT NOT NULL,
53
+ "message" TEXT NOT NULL,
54
+ "type" TEXT NOT NULL DEFAULT 'info',
55
+ "status" TEXT NOT NULL DEFAULT 'pending',
56
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
57
+ "created_by" TEXT,
58
+ "updated_at" TIMESTAMP(3) NOT NULL,
59
+ "updated_by" TEXT,
60
+
61
+ CONSTRAINT "system_alerts_pkey" PRIMARY KEY ("id")
62
+ );
63
+
64
+ -- CreateIndex
65
+ CREATE UNIQUE INDEX "job_titles_code_key" ON "job_titles"("code");
66
+
67
+ -- CreateIndex
68
+ CREATE INDEX "job_titles_code_idx" ON "job_titles"("code");
69
+
70
+ -- CreateIndex
71
+ CREATE INDEX "job_titles_status_idx" ON "job_titles"("status");
72
+
73
+ -- CreateIndex
74
+ CREATE INDEX "job_titles_level_idx" ON "job_titles"("level");
75
+
76
+ -- CreateIndex
77
+ CREATE UNIQUE INDEX "system_categories_code_key" ON "system_categories"("code");
78
+
79
+ -- CreateIndex
80
+ CREATE UNIQUE INDEX "system_category_groups_code_key" ON "system_category_groups"("code");
81
+
82
+ -- CreateIndex
83
+ CREATE INDEX "system_alerts_status_idx" ON "system_alerts"("status");
84
+
85
+ -- CreateIndex
86
+ CREATE INDEX "system_alerts_type_idx" ON "system_alerts"("type");
87
+
@@ -0,0 +1,25 @@
1
+ -- CreateTable
2
+ CREATE TABLE "companies" (
3
+ "id" TEXT NOT NULL,
4
+ "name" TEXT NOT NULL,
5
+ "tax_code" TEXT NOT NULL,
6
+ "address" TEXT NOT NULL,
7
+ "phone" TEXT NOT NULL,
8
+ "email" TEXT NOT NULL,
9
+ "logo_url" TEXT,
10
+ "system_name" TEXT,
11
+ "status" TEXT NOT NULL DEFAULT 'active',
12
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
13
+ "created_by" TEXT NOT NULL DEFAULT 'system',
14
+ "updated_at" TIMESTAMP(3) NOT NULL,
15
+ "updated_by" TEXT NOT NULL DEFAULT 'system',
16
+
17
+ CONSTRAINT "companies_pkey" PRIMARY KEY ("id")
18
+ );
19
+
20
+ -- CreateIndex
21
+ CREATE UNIQUE INDEX "companies_tax_code_key" ON "companies"("tax_code");
22
+
23
+ -- CreateIndex
24
+ CREATE INDEX "companies_status_idx" ON "companies"("status");
25
+
@@ -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
+ }