@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
@@ -1,8 +1,80 @@
1
1
  import type { DictionaryType } from "@goerp/core/hooks"
2
2
 
3
3
  /**
4
- * i18n dictionary for the shell. The sidebar falls back to the raw nav title
5
- * when a key is missing, so an empty object works to start. Grow it into
6
- * `{ navigation: { ... }, label: { ... } }` as you localize.
4
+ * i18n dictionary của shell + CRUD engine.
5
+ *
6
+ * Block `crud` BẮT BUỘC có: engine CRUD của core tra nhãn qua các key
7
+ * `crud.common.*` / `crud.messages.*` — thiếu thì UI hiện nguyên raw key
8
+ * (vd chip trạng thái hiện "crud.common.options.active" thay vì "Hoạt động").
9
+ * Sidebar thì fall back về title trong navigations nên `navigation` để trống
10
+ * được; localize dần bằng cách thêm `{ navigation: {...}, label: {...} }`.
7
11
  */
8
- export const dictionary: DictionaryType = {}
12
+ export const dictionary: DictionaryType = {
13
+ crud: {
14
+ common: {
15
+ create: "Tạo",
16
+ edit: "Sửa",
17
+ delete: "Xóa",
18
+ save: "Lưu",
19
+ saving: "Đang lưu...",
20
+ cancel: "Hủy",
21
+ submit: "Gửi",
22
+ update: "Cập nhật",
23
+ search: "Tìm kiếm...",
24
+ filter: "Lọc",
25
+ filters: "Bộ lọc",
26
+ export: "Xuất",
27
+ import: "Nhập",
28
+ reset: "Đặt lại",
29
+ clear: "Xóa",
30
+ clearAll: "Xóa tất cả",
31
+ actions: "Hành động",
32
+ noData: "Không có dữ liệu",
33
+ loading: "Đang tải...",
34
+ success: "Thành công",
35
+ error: "Lỗi",
36
+ confirm: "Xác nhận",
37
+ close: "Đóng",
38
+ detail: "Chi tiết",
39
+ selectAll: "Chọn tất cả",
40
+ selected: "đã chọn",
41
+ item: "mục",
42
+ items: "mục",
43
+ add: "Thêm",
44
+ creating: "Đang tạo...",
45
+ updating: "Đang cập nhật...",
46
+ deleting: "Đang xóa...",
47
+ savedSuccessfully: "Đã lưu thành công!",
48
+ createdSuccessfully: "Đã tạo thành công!",
49
+ updatedSuccessfully: "Đã cập nhật thành công!",
50
+ deleteSelected: "Xóa đã chọn",
51
+ addNew: "Thêm mới",
52
+ confirmDeleteTitle: "Xóa {{entity}}?",
53
+ confirmBulkDeleteTitle: "Xóa {{count}} {{entities}}?",
54
+ confirmDeleteDescription:
55
+ 'Bạn có chắc chắn muốn xóa "{{name}}"? Hành động này không thể hoàn tác.',
56
+ confirmBulkDeleteDescription:
57
+ "Bạn có chắc chắn muốn xóa {{count}} {{entities}}? Hành động này không thể hoàn tác.",
58
+ cannotBeUndone: "Hành động này không thể hoàn tác.",
59
+ options: {
60
+ active: "Hoạt động",
61
+ inactive: "Tạm ngưng",
62
+ },
63
+ },
64
+ messages: {
65
+ created: "{{entity}} đã được tạo thành công",
66
+ updated: "{{entity}} đã được cập nhật thành công",
67
+ deleted: "{{entity}} đã được xóa thành công",
68
+ bulkDeleted: "{{count}} {{entities}} đã được xóa thành công",
69
+ deleteConfirm: "Bạn có chắc chắn muốn xóa {{entity}} này không?",
70
+ bulkDeleteConfirm: "Bạn có chắc chắn muốn xóa {{count}} {{entities}} không?",
71
+ deleteFailed: "Không thể xóa {{entity}}",
72
+ saveFailed: "Không thể lưu {{entity}}",
73
+ loadFailed: "Không thể tải dữ liệu. Vui lòng thử lại.",
74
+ exportSuccess: 'File "{{filename}}" đã được tải xuống thành công',
75
+ exportFailed: "Xuất dữ liệu thất bại. Vui lòng thử lại.",
76
+ importSuccess: "{{count}} {{entities}} đã được nhập thành công",
77
+ importFailed: "Nhập dữ liệu thất bại. Vui lòng thử lại.",
78
+ },
79
+ },
80
+ }
@@ -25,19 +25,68 @@ export const navigations: NavigationType[] = [
25
25
  href: "/crud/departments",
26
26
  resource: "department",
27
27
  },
28
+ {
29
+ title: "Chức danh",
30
+ iconName: "Briefcase",
31
+ href: "/crud/job-titles",
32
+ resource: "job-title",
33
+ },
28
34
  ],
29
35
  },
30
36
  {
31
37
  title: "Hệ thống",
32
38
  iconName: "ShieldCheck",
33
39
  items: [
40
+ { title: "Người dùng", iconName: "Users", href: "/users", resource: "user" },
41
+ {
42
+ title: "Phiên đăng nhập",
43
+ iconName: "KeyRound",
44
+ href: "/security/sessions",
45
+ resource: "user",
46
+ },
34
47
  { title: "Vai trò", iconName: "ShieldCheck", href: "/roles", resource: "role" },
48
+ {
49
+ title: "Tài nguyên",
50
+ iconName: "Boxes",
51
+ href: "/resources",
52
+ resource: "resource",
53
+ },
54
+ {
55
+ title: "Hành động",
56
+ iconName: "MousePointerClick",
57
+ href: "/actions",
58
+ resource: "action",
59
+ },
60
+ {
61
+ title: "Hồ sơ công ty",
62
+ iconName: "Building",
63
+ href: "/admin/system/company",
64
+ resource: "system-setting",
65
+ },
35
66
  {
36
67
  title: "Cấu hình",
37
68
  iconName: "Settings2",
38
69
  href: "/admin/system/settings",
39
70
  resource: "system-setting",
40
71
  },
72
+ {
73
+ title: "Danh mục hệ thống",
74
+ iconName: "FolderTree",
75
+ href: "/system-categories",
76
+ resource: "system-category",
77
+ },
78
+ {
79
+ title: "Cảnh báo hệ thống",
80
+ iconName: "Bell",
81
+ href: "/crud/system-alerts",
82
+ resource: "system-alert",
83
+ },
84
+ {
85
+ title: "Bộ nhớ đệm",
86
+ iconName: "Database",
87
+ href: "/admin/system/cache",
88
+ resource: "system-cache",
89
+ },
41
90
  {
42
91
  title: "Nhật ký hoạt động",
43
92
  iconName: "Scroll",
@@ -42,23 +42,12 @@ export async function register() {
42
42
  }
43
43
 
44
44
  /**
45
- * BẪY lịch chạy: bộ hẹn giờ của core quy cron về `setInterval` TỪ LÚC BOOT —
46
- * "0 1 * * *" thành "cứ 24 giờ kể từ khi khởi động", không phải "1 giờ sáng".
47
- * Container deploy vài lần một ngày thì job hằng ngày gần như không bao giờ
48
- * chạy đúng giờ mong muốn.
45
+ * Từ core 0.1.71, biểu thức cron 5 trường chạy ĐÚNG GIỜ ĐỊA PHƯƠNG của tiến
46
+ * trình ("0 1 * * *" = 1 giờ sáng thật Dockerfile đã set TZ=Asia/Ho_Chi_Minh)
47
+ * nên job hằng ngày cứ khai lịch thẳng, KHÔNG cần mẹo gác giờ nữa.
49
48
  *
50
- * Cách chữa: hẹn MỖI GIỜ (`0 * * * *`) rồi tự gác bằng hàm này. Mỗi giờ đồng
51
- * hồ chỉ đúng một tick nên không chạy trùng.
52
- *
53
- * cronManager.addJob({
54
- * name: "daily-report",
55
- * cronTime: "0 * * * *",
56
- * start: true,
57
- * onTick: async () => {
58
- * if (!isLocalHour(1)) return
59
- * await sendDailyReport()
60
- * },
61
- * })
49
+ * Hàm dưới chỉ còn cho ca đặc thù: job muốn gác theo MỘT múi giờ KHÁC múi giờ
50
+ * tiến trình (vd server chạy UTC nhưng nghiệp vụ theo giờ VN).
62
51
  */
63
52
  export function isLocalHour(hour: number, timeZone = "Asia/Ho_Chi_Minh"): boolean {
64
53
  return (
@@ -0,0 +1,26 @@
1
+ import { checkPermission } from "@goerp/core/auth"
2
+
3
+ import type { Session } from "@/types/session"
4
+
5
+ import { getSession } from "@/lib/auth"
6
+
7
+ /**
8
+ * Cổng cho SERVER ACTION — tương đương apiHandler({resource, action}) của tầng
9
+ * API. Action là endpoint POST thật sự (mọi client đã đăng nhập gọi được) nên
10
+ * PHẢI gác quyền; guardrail `auth/server-action-bare-session` cấm gọi
11
+ * getSession() trần trong file "use server" để ép đi qua đây.
12
+ *
13
+ * const session = await requirePermission("system-setting", "update")
14
+ */
15
+ export async function requirePermission(
16
+ resource: string,
17
+ action = "view",
18
+ ): Promise<Session> {
19
+ const session = await getSession()
20
+ if (!session?.user) throw new Error("Unauthorized")
21
+ const s = session as Parameters<typeof checkPermission>[0]
22
+ if (!checkPermission(s, resource, action)) {
23
+ throw new Error(`Forbidden: ${resource}:${action}`)
24
+ }
25
+ return session as Session
26
+ }
@@ -6,6 +6,7 @@ import { prismaAdapter } from "better-auth/adapters/prisma"
6
6
  import { APIError } from "better-auth/api"
7
7
  import { customSession } from "better-auth/plugins"
8
8
 
9
+ import { tenant } from "@/configs/tenant"
9
10
  import { db } from "@/lib/prisma"
10
11
  import { loadUserAccess } from "@/lib/rbac/access"
11
12
 
@@ -28,6 +29,13 @@ function buildAuth() {
28
29
  secret: process.env.BETTER_AUTH_SECRET,
29
30
  database: prismaAdapter(db, { provider: "postgresql" }),
30
31
 
32
+ // Cookie mang tên riêng của app (`<tenant.id>.session_token`): cookie theo
33
+ // HOST không phân biệt port, nên dev nhiều app GoERP trên cùng localhost mà
34
+ // dùng tên mặc định `better-auth.*` là app này mở khoá proxy app kia.
35
+ // proxy.ts phải truyền CÙNG prefix vào getSessionCookie — đổi một nơi thì
36
+ // đổi cả hai.
37
+ advanced: { cookiePrefix: tenant.id },
38
+
31
39
  rateLimit: {
32
40
  enabled: true,
33
41
  window: 60,
@@ -5,6 +5,8 @@ import { db } from "@/lib/prisma"
5
5
  // Engine CRUD generic ở CORE — app chỉ tiêm prisma + map entity(số nhiều)→model Prisma.
6
6
  const MODEL_MAP: Record<string, string> = {
7
7
  departments: "department",
8
+ "job-titles": "jobTitle",
9
+ "system-alerts": "systemAlert",
8
10
  }
9
11
 
10
12
  export const crudService = createServerCrudService({
@@ -14,8 +14,9 @@ import { configureSettingsService } from "@goerp/core/system/services/settings-s
14
14
  * 3. bọc branch-guard (lưới an toàn phạm vi chi nhánh cho thao tác ĐỌC);
15
15
  * 4. gọi configureSettingsService(db) để core đọc SystemConfig thật.
16
16
  *
17
- * Các seam còn lại (tasks / notifications / cron) cần import nặng hơn nên nằm ở
18
- * src/server/bootstrap.ts, chạy từ instrumentation.
17
+ * Các seam còn lại cấu hình chỗ chúng sống: tasks src/server/tasks/,
18
+ * notifications → src/server/services/notification-service.ts, cron
19
+ * src/lib/cron/db-cron-manager.ts (khởi động từ src/instrumentation.ts).
19
20
  */
20
21
  const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
21
22
 
@@ -1,31 +1,26 @@
1
1
  "use client"
2
2
 
3
3
  import { useEffect } from "react"
4
+ import { useIsDarkMode } from "@goerp/core/hooks"
4
5
 
5
6
  import type { ReactNode } from "react"
6
7
 
8
+ const MODES = ["light", "dark"]
9
+
7
10
  /**
8
- * Sáng/tối theo tuỳ chọn hệ điều hành. Cố ý viết bằng matchMedia thay vì kéo
9
- * thêm thư viện: starter càng ít phụ thuộc càng dễ nâng cấp. Muốn người dùng
10
- * tự chọn chế độ thì thay bằng next-themes bỏ file này.
11
+ * Áp chế độ Sáng/Tối/Hệ thống người dùng chọn trong Customizer (bánh răng).
12
+ * `useIsDarkMode()` của core đã resolve settings.mode (lưu cookie) + theo dõi
13
+ * prefers-color-scheme khi chọn "Hệ thống" đây chỉ việc gắn class lên <html>.
11
14
  */
12
- const MODES = ["light", "dark"] as const
13
-
14
15
  export function ModeProvider({ children }: { children: ReactNode }) {
15
- useEffect(() => {
16
- const query = window.matchMedia("(prefers-color-scheme: dark)")
16
+ const isDarkMode = useIsDarkMode()
17
+ const mode = isDarkMode ? "dark" : "light"
17
18
 
18
- const apply = (isDark: boolean) => {
19
- const root = document.documentElement
20
- root.classList.remove(...MODES)
21
- root.classList.add(isDark ? "dark" : "light")
22
- }
23
-
24
- apply(query.matches)
25
- const onChange = (e: MediaQueryListEvent) => apply(e.matches)
26
- query.addEventListener("change", onChange)
27
- return () => query.removeEventListener("change", onChange)
28
- }, [])
19
+ useEffect(() => {
20
+ const root = document.documentElement
21
+ root.classList.remove(...MODES)
22
+ root.classList.add(mode)
23
+ }, [mode])
29
24
 
30
25
  return <>{children}</>
31
26
  }
@@ -1,21 +1,35 @@
1
- 'use client'
1
+ "use client"
2
2
 
3
- import { useEffect } from 'react'
3
+ import { useEffect } from "react"
4
+ import { useSettings } from "@goerp/core/hooks"
5
+ import {
6
+ applyBrandThemeTokens,
7
+ THEME_PRESETS,
8
+ } from "@goerp/core/providers/brand-theme"
4
9
 
5
- import type { ReactNode } from 'react'
6
-
7
- const DEFAULT_THEME = 'blue'
8
- const DEFAULT_RADIUS = 0.5
10
+ import type { ReactNode } from "react"
9
11
 
12
+ /**
13
+ * Áp màu thương hiệu người dùng chọn trong Customizer vào CSS token
14
+ * (--primary, --ring, --sidebar-background…). Bảng màu calibrate sẵn nằm ở
15
+ * core (`THEME_PRESETS`); app muốn nắn tay từng hue thì thay bằng bảng riêng
16
+ * (xem vinhhoa src/providers/theme-provider.tsx).
17
+ *
18
+ * Radius ("Bo góc") + density ("Mật độ") do SettingsProvider của core tự áp.
19
+ */
10
20
  export function ThemeProvider({ children }: { children: ReactNode }) {
21
+ const { settings } = useSettings()
22
+
11
23
  useEffect(() => {
12
- const bodyElement = document.body
13
- Array.from(bodyElement.classList)
14
- .filter((c) => c.startsWith('theme-') || c.startsWith('radius-'))
15
- .forEach((c) => bodyElement.classList.remove(c))
16
- bodyElement.classList.add(`theme-${DEFAULT_THEME}`)
17
- bodyElement.classList.add(`radius-${DEFAULT_RADIUS}`)
18
- }, [])
24
+ const theme = THEME_PRESETS[settings.theme] ?? THEME_PRESETS.blue
25
+ const isDark =
26
+ settings.mode === "dark" ||
27
+ (settings.mode === "system" &&
28
+ typeof window !== "undefined" &&
29
+ !!window.matchMedia?.("(prefers-color-scheme: dark)").matches)
30
+
31
+ applyBrandThemeTokens(document.documentElement, theme, isDark)
32
+ }, [settings.theme, settings.mode])
19
33
 
20
34
  return <>{children}</>
21
35
  }
@@ -2,6 +2,7 @@ import { createAuthProxy } from "@goerp/core/auth/proxy-gate"
2
2
  import { getSessionCookie } from "better-auth/cookies"
3
3
 
4
4
  import { i18n } from "@/configs/i18n"
5
+ import { tenant } from "@/configs/tenant"
5
6
 
6
7
  /**
7
8
  * Cổng xác thực DEFAULT-DENY, dùng thẳng proxy-gate của core.
@@ -19,14 +20,18 @@ const BYPASS =
19
20
  (process.env.BYPASS_AUTH === "true" || process.env.BYPASS_AUTH === "1")
20
21
 
21
22
  export const proxy = createAuthProxy({
22
- // Thêm prefix vào đây khi có endpoint tự xác thực (webhook, token khách…).
23
- publicApiPrefixes: ["/api/better-auth", "/api/public"],
23
+ // Thêm prefix vào đây khi có endpoint tự xác thực webhook, token khách,
24
+ // và cả đường NGOÀI /api (máy móc không có cookie kiểu /pub, /iclock của
25
+ // máy chấm công). Mỗi entry phải ghi rõ nó tự bảo vệ bằng gì; guardrail
26
+ // `auth/route-outside-api-declared` sẽ bắt route ngoài /api chưa khai ở đây.
27
+ publicPrefixes: ["/api/better-auth", "/api/public"],
24
28
  publicPages: [...i18n.locales.map((l) => `/${l}/sign-in`), "/sign-in"],
25
29
  signInPath: `/${i18n.defaultLocale}/sign-in`,
26
30
  homePath: `/${i18n.defaultLocale}`,
31
+ // cookiePrefix PHẢI khớp advanced.cookiePrefix trong src/lib/better-auth.ts.
27
32
  getToken: BYPASS
28
33
  ? async () => ({ dev: true })
29
- : async (req) => getSessionCookie(req),
34
+ : async (req) => getSessionCookie(req, { cookiePrefix: tenant.id }),
30
35
  })
31
36
 
32
37
  export default proxy
@@ -0,0 +1,47 @@
1
+ import { cache } from "react"
2
+
3
+ import { tenant } from "@/configs/tenant"
4
+ import { db } from "@/lib/prisma"
5
+
6
+ /**
7
+ * Công ty "hệ thống" = dòng ĐẦU TIÊN của bảng companies. Chưa có dòng nào thì
8
+ * trả mặc định từ tenant config để biểu mẫu không trống — lưu lần đầu sẽ tạo.
9
+ */
10
+ export const getSystemCompany = cache(async function getSystemCompany() {
11
+ const company = await db.company.findFirst({ orderBy: { createdAt: "asc" } })
12
+ if (company) return company
13
+ return {
14
+ id: null as string | null,
15
+ name: tenant.branding?.companyName ?? tenant.name,
16
+ taxCode: "",
17
+ address: "",
18
+ phone: "",
19
+ email: "",
20
+ logoUrl: null as string | null,
21
+ systemName: tenant.name,
22
+ }
23
+ })
24
+
25
+ export interface CompanyProfileInput {
26
+ name: string
27
+ taxCode: string
28
+ address: string
29
+ phone: string
30
+ email: string
31
+ logoUrl?: string | null
32
+ systemName?: string | null
33
+ }
34
+
35
+ export async function updateSystemCompany(
36
+ data: CompanyProfileInput,
37
+ updatedBy: string,
38
+ ) {
39
+ const existing = await db.company.findFirst({ orderBy: { createdAt: "asc" } })
40
+ if (existing) {
41
+ return db.company.update({
42
+ where: { id: existing.id },
43
+ data: { ...data, updatedBy },
44
+ })
45
+ }
46
+ return db.company.create({ data: { ...data, createdBy: updatedBy, updatedBy } })
47
+ }
@@ -0,0 +1,104 @@
1
+ import { db } from "@/lib/prisma"
2
+
3
+ /**
4
+ * Danh sách người dùng cho trang /users và GET /api/users.
5
+ *
6
+ * CỐ Ý không dùng `getUsersData` của @goerp/core/user: bản đó select các cột
7
+ * vinhhoa-specific (`userType`, quan hệ `profiles`/`supplier`) không tồn tại
8
+ * trong schema template — Prisma sẽ ném PrismaClientValidationError ngay
9
+ * request đầu tiên. Query ở đây chỉ đụng User/UserRole/UserBranch của template
10
+ * và trả đúng shape mà UsersTable/UsersCardView/UnifiedProfileDialog của core
11
+ * đọc: `status` ("active"/"inactive"), `roleNames`/`roleCodes`,
12
+ * `branchIds`/`branchNames`, `defaultBranchId`.
13
+ * (`getUserStats`/`getActiveRoles` của core thì an toàn — chỗ đếm theo
14
+ * `userType` đã có `.catch(() => 0)`.)
15
+ */
16
+ export interface UsersListParams {
17
+ search?: string
18
+ /** Lọc theo mã vai trò (Role.code). */
19
+ roleCode?: string
20
+ /** "active" | "inactive" — map sang cột isActive. */
21
+ status?: string
22
+ }
23
+
24
+ import type { Prisma } from "@prisma/client"
25
+
26
+ type Where = Prisma.UserWhereInput
27
+
28
+ export async function getUsersList(params: UsersListParams = {}) {
29
+ const { search, roleCode, status } = params
30
+
31
+ const where: Where = {}
32
+ if (search) {
33
+ where.OR = [
34
+ { name: { contains: search, mode: "insensitive" } },
35
+ { email: { contains: search, mode: "insensitive" } },
36
+ ]
37
+ }
38
+ if (status === "active" || status === "inactive") {
39
+ where.isActive = status === "active"
40
+ }
41
+ if (roleCode) {
42
+ where.userRoles = { some: { roleCode } }
43
+ }
44
+
45
+ // Trả TOÀN BỘ danh sách đã lọc: UsersClientPage của core tự phân trang phía
46
+ // client theo ?page&pageSize (danh sách người dùng nội bộ đủ nhỏ).
47
+ const [users, total] = await Promise.all([
48
+ db.user.findMany({
49
+ where,
50
+ orderBy: { createdAt: "desc" },
51
+ select: {
52
+ id: true,
53
+ name: true,
54
+ email: true,
55
+ image: true,
56
+ isActive: true,
57
+ lastLoginAt: true,
58
+ createdAt: true,
59
+ updatedAt: true,
60
+ userRoles: {
61
+ select: { role: { select: { code: true, name: true } } },
62
+ },
63
+ userBranches: {
64
+ select: {
65
+ isDefault: true,
66
+ branch: { select: { id: true, name: true } },
67
+ },
68
+ orderBy: [{ isDefault: "desc" }, { createdAt: "asc" }],
69
+ },
70
+ },
71
+ }),
72
+ db.user.count({ where }),
73
+ ])
74
+
75
+ const data = users.map((user) => {
76
+ const branches = user.userBranches
77
+ .filter((ub) => ub.branch)
78
+ .map((ub) => ({
79
+ id: ub.branch.id,
80
+ name: ub.branch.name,
81
+ isDefault: ub.isDefault,
82
+ }))
83
+ return {
84
+ id: user.id,
85
+ name: user.name,
86
+ email: user.email,
87
+ image: user.image,
88
+ isActive: user.isActive,
89
+ status: user.isActive ? "active" : "inactive",
90
+ lastLoginAt: user.lastLoginAt,
91
+ createdAt: user.createdAt,
92
+ updatedAt: user.updatedAt,
93
+ roleNames: user.userRoles.map((ur) => ur.role.name),
94
+ roleCodes: user.userRoles.map((ur) => ur.role.code),
95
+ branches,
96
+ branchIds: branches.map((b) => b.id),
97
+ branchNames: branches.map((b) => b.name),
98
+ defaultBranchId:
99
+ branches.find((b) => b.isDefault)?.id ?? branches[0]?.id ?? null,
100
+ }
101
+ })
102
+
103
+ return { data, total }
104
+ }
@@ -27,9 +27,11 @@
27
27
  "./src/*"
28
28
  ],
29
29
  "@goerp/core": [
30
+ "../../src",
30
31
  "./node_modules/@goerp/core/src"
31
32
  ],
32
33
  "@goerp/core/*": [
34
+ "../../src/*",
33
35
  "./node_modules/@goerp/core/src/*"
34
36
  ]
35
37
  }