@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.
- package/CHANGELOG.md +45 -0
- package/bin/goerp-init.mjs +15 -0
- package/package.json +2 -1
- package/src/auth/index.ts +5 -1
- package/src/auth/proxy-gate.ts +21 -2
- package/src/configs/entities/departments.config.ts +1 -0
- package/src/configs/entities/material-categories.config.ts +1 -0
- package/src/cron/__tests__/cron-schedule.test.ts +76 -0
- package/src/cron/cron-schedule.ts +128 -0
- package/src/cron/simple-cron-job.ts +83 -69
- package/src/crud/components/crud-export-button.tsx +3 -2
- package/src/crud/components/crud-page.tsx +56 -25
- package/src/crud/components/crud-row-actions.tsx +16 -4
- package/src/crud/components/crud-table.tsx +13 -1
- package/src/crud/crud-route-handlers.test.ts +352 -0
- package/src/crud/crud-route-handlers.ts +260 -13
- package/src/crud/index.ts +3 -0
- package/src/crud/lib/coerce.test.ts +118 -0
- package/src/crud/lib/coerce.ts +95 -0
- package/src/crud/lib/crud-utils.ts +4 -0
- package/src/crud/lib/entity-endpoints.test.ts +41 -0
- package/src/crud/lib/entity-endpoints.ts +23 -0
- package/src/crud/lib/errors.ts +14 -0
- package/src/crud/lib/import-request.ts +99 -0
- package/src/crud/lib/mutation-builder.test.ts +114 -0
- package/src/crud/lib/mutation-builder.ts +27 -34
- package/src/crud/lib/permissions.test.ts +57 -0
- package/src/crud/lib/permissions.ts +25 -13
- package/src/crud/lib/query-builder.ts +13 -6
- package/src/crud/lib/translate-config.ts +16 -2
- package/src/crud/pages/entity-crud-page.tsx +11 -8
- package/src/crud/server-service.test.ts +112 -0
- package/src/crud/server-service.ts +52 -27
- package/src/crud/server.ts +11 -1
- package/src/guardrails/index.ts +1 -0
- package/src/guardrails/rules/auth.ts +54 -1
- package/src/guardrails/rules/design.ts +3 -2
- package/src/guardrails/types.ts +17 -8
- package/src/providers/brand-theme.ts +20 -0
- package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
- package/src/security/index.ts +2 -0
- package/src/security/pages/sessions-page.tsx +516 -0
- package/src/types/index.ts +75 -10
- package/src/ui/auth/sign-in-form.tsx +64 -9
- package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
- package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
- package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
- package/src/ui/data-display/data-table/data-table.tsx +68 -23
- package/src/ui/data-display/data-table-pagination.tsx +25 -8
- package/src/ui/data-display/index.tsx +1 -0
- package/src/ui/data-display/use-client-pagination.ts +48 -0
- package/src/ui/errors/error-view.tsx +164 -0
- package/src/ui/errors/index.ts +2 -0
- package/src/ui/errors/not-found-view.tsx +44 -0
- package/src/ui/index.tsx +1 -0
- package/src/ui/layout/logo.tsx +54 -19
- package/src/user/pages/users-client-page.tsx +12 -7
- package/templates/starter-app/.env.example +4 -0
- package/templates/starter-app/AGENTS.md +1 -1
- package/templates/starter-app/README.md +6 -2
- package/templates/starter-app/husky/pre-commit +10 -0
- package/templates/starter-app/package.json +27 -2
- package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
- package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
- package/templates/starter-app/prisma/schema/organization.prisma +20 -0
- package/templates/starter-app/prisma/schema/system.prisma +74 -0
- package/templates/starter-app/prisma/seed.ts +7 -5
- package/templates/starter-app/scripts/migration-new.mjs +89 -0
- package/templates/starter-app/scripts/rbac-sync.ts +49 -21
- package/templates/starter-app/src/__tests__/architecture.test.ts +11 -2
- package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +68 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +33 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +195 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +29 -0
- package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +13 -3
- package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
- package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/security/sessions/page.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +62 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +41 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +157 -0
- package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +69 -0
- package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
- package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
- package/templates/starter-app/src/app/[lang]/layout.tsx +2 -2
- package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
- package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
- package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
- package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
- package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
- package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
- package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
- package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
- package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
- package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
- package/templates/starter-app/src/app/api/branches/route.ts +26 -0
- package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
- package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
- package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
- package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
- package/templates/starter-app/src/app/api/departments/route.ts +26 -0
- package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
- package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
- package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
- package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
- package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
- package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
- package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
- package/templates/starter-app/src/app/api/roles/route.ts +2 -1
- package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +52 -0
- package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
- package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
- package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
- package/templates/starter-app/src/app/api/system-categories/route.ts +179 -0
- package/templates/starter-app/src/app/api/system-category-groups/route.ts +149 -0
- package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
- package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
- package/templates/starter-app/src/app/api/upload/route.ts +1 -2
- package/templates/starter-app/src/app/api/users/[id]/route.ts +230 -0
- package/templates/starter-app/src/app/api/users/route.ts +141 -0
- package/templates/starter-app/src/app/global-error.tsx +135 -0
- package/templates/starter-app/src/app/globals.css +0 -1
- package/templates/starter-app/src/app/icon.svg +6 -0
- package/templates/starter-app/src/app/manifest.ts +26 -0
- package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
- package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
- package/templates/starter-app/src/configs/entities/index.ts +5 -1
- package/templates/starter-app/src/configs/entities/job-titles.config.ts +121 -0
- package/templates/starter-app/src/configs/entities/system-alerts.config.ts +93 -0
- package/templates/starter-app/src/configs/entities/users.config.ts +80 -0
- package/templates/starter-app/src/configs/i18n.ts +6 -6
- package/templates/starter-app/src/configs/permissions/index.ts +37 -0
- package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
- package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
- package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
- package/templates/starter-app/src/configs/tenant.ts +2 -1
- package/templates/starter-app/src/data/dictionary.ts +77 -4
- package/templates/starter-app/src/data/navigations.ts +60 -1
- package/templates/starter-app/src/instrumentation.ts +5 -16
- package/templates/starter-app/src/lib/action-guard.ts +25 -0
- package/templates/starter-app/src/lib/api-handler.ts +27 -19
- package/templates/starter-app/src/lib/auth.ts +3 -1
- package/templates/starter-app/src/lib/better-auth.ts +8 -0
- package/templates/starter-app/src/lib/branch-scope.ts +1 -1
- package/templates/starter-app/src/lib/crud/index.ts +5 -10
- package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
- package/templates/starter-app/src/lib/logger.ts +11 -4
- package/templates/starter-app/src/lib/page-guard.ts +2 -3
- package/templates/starter-app/src/lib/prisma.ts +16 -7
- package/templates/starter-app/src/lib/rbac/access.ts +2 -2
- package/templates/starter-app/src/lib/storage.ts +3 -1
- package/templates/starter-app/src/providers/index.tsx +2 -1
- package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
- package/templates/starter-app/src/providers/theme-provider.tsx +27 -12
- package/templates/starter-app/src/proxy.ts +8 -3
- package/templates/starter-app/src/server/services/company-service.ts +49 -0
- package/templates/starter-app/src/server/services/notification-service.ts +6 -6
- package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
- package/templates/starter-app/src/server/services/user-service.ts +104 -0
- package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
- package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
- package/templates/starter-app/tsconfig.json +10 -0
- 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,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
package/src/ui/layout/logo.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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 `
|
|
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
|
|
100
|
-
pnpm goerp-features
|
|
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.
|
|
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
|
+
}
|