@goplusvn/core 0.1.43 → 0.1.45

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 CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.44 — UnifiedProfileDialog: chi nhánh mặc định + ngày sinh DatePicker + fix Select loại người dùng
4
+
5
+ **Ngày sinh dùng `DatePicker` (thay `<input type="date">`):** ô ngày sinh cũ khó
6
+ nhập tay và không đồng bộ style; nay dùng `DatePicker` (gõ được `dd/MM/yyyy` +
7
+ lịch popover). Cầu nối 2 chiều `ymdToDate`/`dateToYmd` giữ nguyên định dạng lưu
8
+ `"yyyy-MM-dd"`, neo giờ 00:00 địa phương để tránh lệch ngày do timezone.
9
+
10
+ **Chi nhánh mặc định (`UserBranch.isDefault`):**
11
+
12
+ - Tab "Công việc" của dialog người dùng: mỗi chi nhánh đã chọn có nút "⭐ Đặt
13
+ mặc định"; chi nhánh mặc định hiện badge "Mặc định". Chọn chi nhánh đầu tiên
14
+ tự động thành mặc định; bỏ chọn chi nhánh đang là mặc định thì tự thăng chi
15
+ nhánh còn lại kế tiếp.
16
+ - Payload gửi thêm `defaultBranchId` (đã lọc để luôn nằm trong `branchIds`, fallback
17
+ `branchIds[0]`). App đọc field này thay cho hard-code `isDefault: index === 0`.
18
+ - Đọc lại `data.defaultBranchId` khi mở dialog ở chế độ sửa (fallback `branchIds[0]`).
19
+
20
+ **Fix Select "Loại người dùng" icon + chữ rớt 2 dòng:** base `SelectTrigger` của
21
+ core có `[&_span]:line-clamp-none` — selector này (`.trigger span`, độ ưu tiên
22
+ `0,1,1`) biên dịch kèm `display:block`, THẮNG class `inline-flex` (`0,1,0`) trên
23
+ span icon+chữ bên trong, nên (cộng với Preflight `svg{display:block}`) icon bị
24
+ đẩy xuống dòng riêng. Nới chiều ngang KHÔNG chữa vì đây là lỗi xếp dọc. Fix:
25
+ trigger dùng `[&_span]:!inline-flex [&_span]:!items-center [&_span]:!gap-2`
26
+ (`!important` thắng tuyệt đối `line-clamp-none` bất kể thứ tự stylesheet) +
27
+ `[&_svg]:shrink-0`, `whitespace-nowrap`, `style={{width:210}}`. Kiểm chứng bằng
28
+ repro Radix+Tailwind: span trong đổi `display` từ `block` → `flex`.
29
+
3
30
  ## 0.1.34 — Tab navigation: gom nhóm module, tiêu đề tab chi tiết, nút làm tươi
4
31
 
5
32
  **PageTabs:**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.43",
4
+ "version": "0.1.45",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -158,7 +158,7 @@
158
158
  "swr": "^2.3.6",
159
159
  "tailwind-merge": "2.5.2",
160
160
  "vaul": "1.1.2",
161
- "xlsx": "0.18.5",
161
+ "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
162
162
  "zod": "3.23.8"
163
163
  },
164
164
  "scripts": {
@@ -11,7 +11,6 @@
11
11
  // export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.[^/]+$).*)"] };
12
12
 
13
13
  import { NextResponse } from "next/server";
14
- import { getToken } from "next-auth/jwt";
15
14
  import type { NextRequest } from "next/server";
16
15
 
17
16
  export interface AuthProxyOptions {
@@ -35,7 +34,16 @@ export function createAuthProxy(options: AuthProxyOptions = {}) {
35
34
  const publicPages = options.publicPages ?? ["/sign-in"];
36
35
  const signInPath = options.signInPath ?? "/sign-in";
37
36
  const homePath = options.homePath ?? "/";
38
- const readToken = options.getToken ?? ((req: NextRequest) => getToken({ req }));
37
+ // next-auth chỉ được LAZY-load khi app không truyền getToken riêng — app đã
38
+ // sang Better Auth (không cài next-auth) sẽ không dính module-not-found lúc
39
+ // import proxy-gate (trước đây import top-level, next-auth lại chỉ nằm ở
40
+ // devDependencies của core).
41
+ const readToken =
42
+ options.getToken ??
43
+ (async (req: NextRequest) => {
44
+ const { getToken } = await import("next-auth/jwt");
45
+ return getToken({ req });
46
+ });
39
47
 
40
48
  return async function proxy(request: NextRequest) {
41
49
  const { pathname, search } = request.nextUrl;
@@ -22,11 +22,25 @@ type MaybePromise<T> = T | Promise<T>;
22
22
  export interface CrudHandlerDeps {
23
23
  getSession: () => MaybePromise<any | null>;
24
24
  getEntityConfig: (entity: string) => EntityConfig | undefined;
25
- service: ServerCrudService;
25
+ /** Một service dùng chung, HOẶC resolver theo entity — app có service
26
+ * chuyên biệt (auto-gen số phiếu, stock-count…) trả service riêng cho
27
+ * entity đó thay vì viết factory if-chain ngoài core (seam 2026-07). */
28
+ service:
29
+ | ServerCrudService
30
+ | ((entity: string) => MaybePromise<ServerCrudService>);
26
31
  /** Optional error mapper (e.g. app's serverError). Defaults to a 500 JSON. */
27
32
  onError?: (error: unknown, req: Request) => Response | Promise<Response>;
28
33
  }
29
34
 
35
+ function makeServiceResolver(
36
+ service: CrudHandlerDeps["service"],
37
+ ): (entity: string) => Promise<ServerCrudService> {
38
+ if (typeof service === "function") {
39
+ return async (entity) => await service(entity);
40
+ }
41
+ return async () => service;
42
+ }
43
+
30
44
  const json = (data: unknown, status = 200) =>
31
45
  new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
32
46
 
@@ -40,7 +54,8 @@ async function resolvePerms(session: any, config: EntityConfig, entity: string)
40
54
 
41
55
  // GET/POST for the collection route `/api/crud/[entity]`.
42
56
  export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
43
- const { getSession, getEntityConfig, service, onError } = deps;
57
+ const { getSession, getEntityConfig, onError } = deps;
58
+ const resolveService = makeServiceResolver(deps.service);
44
59
  const fail = (e: unknown, req: Request) =>
45
60
  onError ? onError(e, req) : json({ error: "Internal error" }, 500);
46
61
 
@@ -64,6 +79,7 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
64
79
  : undefined,
65
80
  filters: sp.get("filters") ? JSON.parse(sp.get("filters")!) : undefined,
66
81
  };
82
+ const service = await resolveService(entity);
67
83
  const data = await service.list(entity, config, params as any);
68
84
  return json(data);
69
85
  } catch (e) {
@@ -82,6 +98,7 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
82
98
  if (!perms.create) return forbidden();
83
99
 
84
100
  const body = await req.json();
101
+ const service = await resolveService(entity);
85
102
  if (Array.isArray(body)) {
86
103
  const results = [];
87
104
  for (const item of body) results.push(await service.create(entity, item, config));
@@ -98,7 +115,8 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
98
115
 
99
116
  // GET/PUT/PATCH/DELETE for the item route `/api/crud/[entity]/[id]`.
100
117
  export function createCrudItemHandlers(deps: CrudHandlerDeps) {
101
- const { getSession, getEntityConfig, service, onError } = deps;
118
+ const { getSession, getEntityConfig, onError } = deps;
119
+ const resolveService = makeServiceResolver(deps.service);
102
120
  const fail = (e: unknown, req: Request) =>
103
121
  onError ? onError(e, req) : json({ error: "Internal error" }, 500);
104
122
 
@@ -113,7 +131,9 @@ export function createCrudItemHandlers(deps: CrudHandlerDeps) {
113
131
  if (!config) return unknownEntity();
114
132
  const perms = await resolvePerms(session, config, entity);
115
133
  if (!perms.read) return forbidden();
116
- const item = await service.getById(entity, id);
134
+ const service = await resolveService(entity);
135
+ // Truyền config để detail áp cùng include như list (label thay raw FK id).
136
+ const item = await service.getById(entity, id, config);
117
137
  if (!item) return json({ error: "Not found" }, 404);
118
138
  return json(item);
119
139
  } catch (e) {
@@ -131,6 +151,7 @@ export function createCrudItemHandlers(deps: CrudHandlerDeps) {
131
151
  const perms = await resolvePerms(session, config, entity);
132
152
  if (!perms.update) return forbidden();
133
153
  const body = await req.json();
154
+ const service = await resolveService(entity);
134
155
  return json(await service.update(entity, id, body, config));
135
156
  } catch (e) {
136
157
  return fail(e, req);
@@ -146,6 +167,7 @@ export function createCrudItemHandlers(deps: CrudHandlerDeps) {
146
167
  if (!config) return unknownEntity();
147
168
  const perms = await resolvePerms(session, config, entity);
148
169
  if (!perms.delete) return forbidden();
170
+ const service = await resolveService(entity);
149
171
  await service.delete(entity, id);
150
172
  return new Response(null, { status: 204 });
151
173
  } catch (e) {
@@ -1,7 +1,10 @@
1
1
  import type { CrudPermissions, EntityConfig } from "../../types";
2
2
  import type { Session } from "../../types";
3
3
 
4
- import { getCrudPermissionsFromSession } from "../../rbac/permissions";
4
+ // Nguồn permission DUY NHẤT đã harden (BYPASS_AUTH chỉ hiệu lực ngoài
5
+ // production, admin-role check chạy trước empty-permissions guard) — bản
6
+ // divergent rbac/permissions.ts cũ đã xóa 2026-07 vì thiếu guard NODE_ENV.
7
+ import { getCrudPermissionsFromSession } from "../../auth";
5
8
 
6
9
  /**
7
10
  * Get CRUD permissions for a user based on their session
@@ -22,9 +22,12 @@ type PrismaLike = Record<string, any>;
22
22
 
23
23
  export interface ServerCrudService {
24
24
  list(entity: string, config: EntityConfig, params: CrudQueryParams): Promise<CrudResponse>;
25
- getById(entity: string, id: string): Promise<any>;
26
- create(entity: string, data: any, config?: EntityConfig): Promise<any>;
27
- update(entity: string, id: string, data: any, config?: EntityConfig): Promise<any>;
25
+ /** `config` (tùy chọn) để áp cùng `include` như list — detail không hiện raw FK id. */
26
+ getById(entity: string, id: string, config?: EntityConfig): Promise<any>;
27
+ /** `tx` (tùy chọn) để app chạy create CÙNG transaction với sinh số phiếu
28
+ * advisory-lock (document-number yêu cầu cùng tx với insert). */
29
+ create(entity: string, data: any, config?: EntityConfig, tx?: PrismaLike): Promise<any>;
30
+ update(entity: string, id: string, data: any, config?: EntityConfig, tx?: PrismaLike): Promise<any>;
28
31
  delete(entity: string, id: string): Promise<any>;
29
32
  deleteMany(entity: string, ids: string[]): Promise<any>;
30
33
  }
@@ -43,8 +46,35 @@ export interface ServerCrudDeps {
43
46
  * {@link createAuditUserNameResolver} if your User model exposes a name field. */
44
47
  resolveAuditNames?: (records: any[]) => Promise<any[]>;
45
48
  logger?: ServerCrudLogger;
49
+
50
+ // ── Seams 2026-07: đủ điểm cắm để app KHÔNG phải fork nguyên engine ──
51
+
52
+ /** WHERE mặc định theo entity, AND vào mọi list/count — vd 2 entity chia
53
+ * chung 1 model: stock-in → { type: "in" }, stock-out → { type: "out" }. */
54
+ scopeWhere?: (
55
+ entity: string,
56
+ config: EntityConfig,
57
+ ) => Record<string, unknown> | undefined;
58
+ /** Field hệ thống luôn cho qua filterValidFields dù không khai trong
59
+ * config.fields. Default: id + bộ audit (createdAt/updatedAt/createdBy/
60
+ * updatedBy). App có cột riêng (companyId…) thì truyền thêm. */
61
+ systemFields?: string[];
62
+ /** Set updatedAt = new Date() khi create nếu payload chưa có — cho model
63
+ * thiếu `@updatedAt`/default trong schema. Default: false. */
64
+ touchUpdatedAtOnCreate?: boolean;
65
+ /** Các cột `*Id` KHÔNG phải FK — bỏ qua transform connect/disconnect
66
+ * (scalar như số CCCD, mã tra cứu…). Gộp thêm vào skip-list mặc định. */
67
+ relationFieldSkip?: string[];
46
68
  }
47
69
 
70
+ const DEFAULT_SYSTEM_FIELDS = [
71
+ "id",
72
+ "createdAt",
73
+ "updatedAt",
74
+ "createdBy",
75
+ "updatedBy",
76
+ ];
77
+
48
78
  const MAX_PAGE_SIZE = 200;
49
79
 
50
80
  // Default plural→model resolver: strip trailing "s", camelCase kebab. Apps with
@@ -96,16 +126,18 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
96
126
  error: (m, ...r) => console.error(m, ...r),
97
127
  };
98
128
 
99
- const model = (entity: string) => {
129
+ const systemFields = deps.systemFields ?? DEFAULT_SYSTEM_FIELDS;
130
+
131
+ const model = (entity: string, client: PrismaLike = prisma) => {
100
132
  const name = resolveModel(entity);
101
- const m = prisma[name];
133
+ const m = client[name];
102
134
  if (!m) throw new Error(`Prisma model not found for entity: ${entity} (${name})`);
103
135
  return m;
104
136
  };
105
137
 
106
138
  const filterValidFields = (data: any, config: EntityConfig) => {
107
139
  const valid = new Set(config.fields.filter((f) => !(f as any).isDisplayOnly).map((f) => f.name));
108
- valid.add("id");
140
+ for (const f of systemFields) valid.add(f);
109
141
  const out: Record<string, unknown> = {};
110
142
  for (const [k, v] of Object.entries(data)) {
111
143
  if (valid.has(k)) out[k] = v;
@@ -143,7 +175,16 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
143
175
 
144
176
  const transformRelationFields = (data: any, mode: "create" | "update") => {
145
177
  const out = { ...data };
146
- const skip = new Set(["id", "createdBy", "updatedBy", "citizenId", "targetId"]);
178
+ // citizenId/targetId legacy-default từ consumer đầu tiên — app mới khai
179
+ // cột scalar *Id của mình qua deps.relationFieldSkip thay vì sửa core.
180
+ const skip = new Set([
181
+ "id",
182
+ "createdBy",
183
+ "updatedBy",
184
+ "citizenId",
185
+ "targetId",
186
+ ...(deps.relationFieldSkip ?? []),
187
+ ]);
147
188
  for (const key of Object.keys(out)) {
148
189
  if (skip.has(key)) continue;
149
190
  if (key.endsWith("Id") && key.length > 2) {
@@ -181,7 +222,7 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
181
222
  return allowedFields.has(name);
182
223
  };
183
224
 
184
- const where: any = {};
225
+ const where: any = { ...(deps.scopeWhere?.(entity, config) ?? {}) };
185
226
  if (search && search.trim()) {
186
227
  const term = search.trim();
187
228
  const searchFields = config.fields.filter((f) => f.filterable && f.type === "text").map((f) => f.name);
@@ -253,21 +294,28 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
253
294
  }
254
295
  },
255
296
 
256
- async getById(entity, id) {
297
+ async getById(entity, id, config) {
257
298
  const prismaModel = model(entity);
258
- const result = await prismaModel.findUnique({ where: { id } });
299
+ // Áp cùng include như list detail dialog hiện label relation thay raw FK id.
300
+ const include: any = {};
301
+ if (config?.include?.length) config.include.forEach((inc) => (include[inc] = true));
302
+ const includeOption = Object.keys(include).length ? { include } : {};
303
+ const result = await prismaModel.findUnique({ where: { id }, ...includeOption });
259
304
  if (!result) return null;
260
305
  const serialized = serializeDecimalFields(result);
261
306
  const [resolved] = await resolveAuditNames([serialized]);
262
307
  return resolved;
263
308
  },
264
309
 
265
- async create(entity, data, config) {
266
- const prismaModel = model(entity);
310
+ async create(entity, data, config, tx) {
311
+ const prismaModel = model(entity, tx ?? prisma);
267
312
  try {
268
313
  let filtered = config ? filterValidFields(data, config) : data;
269
314
  if (config) filtered = castFieldValues(filtered, config);
270
315
  if (!filtered.id) filtered.id = crypto.randomUUID();
316
+ if (deps.touchUpdatedAtOnCreate && !filtered.updatedAt) {
317
+ filtered.updatedAt = new Date();
318
+ }
271
319
  const prismaData = transformRelationFields(filtered, "create");
272
320
  return serializeDecimalFields(await prismaModel.create({ data: prismaData }));
273
321
  } catch (error) {
@@ -276,8 +324,8 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
276
324
  }
277
325
  },
278
326
 
279
- async update(entity, id, data, config) {
280
- const prismaModel = model(entity);
327
+ async update(entity, id, data, config, tx) {
328
+ const prismaModel = model(entity, tx ?? prisma);
281
329
  try {
282
330
  let filtered = config ? filterValidFields(data, config) : data;
283
331
  if (config) filtered = castFieldValues(filtered, config);
@@ -17,11 +17,27 @@ import { AppError, toAppError } from "./app-error"
17
17
  */
18
18
  export async function withErrorHandler(
19
19
  handler: () => Promise<Response>,
20
- options?: { operation?: string; req?: NextRequest | Request }
20
+ options?: {
21
+ operation?: string
22
+ req?: NextRequest | Request
23
+ /**
24
+ * Seam persist lỗi (2026-07): mặc định chỉ console.error — lỗi KHÔNG
25
+ * vào error_logs. App truyền `onError: (e, req) => serverError(e, req)`
26
+ * (buildServerError ở ./server-error) để lỗi được lưu bền + giữ nguyên
27
+ * shape response (cả hai cùng kết thúc bằng createErrorResponse).
28
+ */
29
+ onError?: (
30
+ error: unknown,
31
+ req?: NextRequest | Request
32
+ ) => Response | Promise<Response>
33
+ }
21
34
  ): Promise<Response> {
22
35
  try {
23
36
  return await handler()
24
37
  } catch (error) {
38
+ if (options?.onError) {
39
+ return options.onError(error, options.req)
40
+ }
25
41
  const appError = toAppError(error)
26
42
 
27
43
  const requestContext = options?.req
@@ -1,5 +1,4 @@
1
1
  import type { NextResponse } from "next/server"
2
- import { createHash } from "crypto"
3
2
 
4
3
  import { AppError, toAppError } from "./app-error"
5
4
  import { createErrorResponse } from "./error-handler"
@@ -68,7 +67,24 @@ function buildFingerprint(
68
67
  /* keep raw */
69
68
  }
70
69
  const raw = [message.slice(0, 200), code || "", moduleTag || "", path].join("|")
71
- return createHash("sha256").update(raw).digest("hex").slice(0, 16)
70
+ return fingerprint16(raw)
71
+ }
72
+
73
+ /**
74
+ * Fingerprint 16 hex để GOM NHÓM lỗi — không phải mật mã học. FNV-1a thuần
75
+ * (không import "crypto" của Node) vì error path bị instrumentation/proxy kéo
76
+ * vào Edge bundle, nơi không có module crypto (2026-07: dev webpack vỡ vì
77
+ * import node:crypto ở file này).
78
+ */
79
+ function fingerprint16(raw: string): string {
80
+ let h1 = 0x811c9dc5
81
+ let h2 = 0xcbf29ce4
82
+ for (let i = 0; i < raw.length; i++) {
83
+ const c = raw.charCodeAt(i)
84
+ h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0
85
+ h2 = Math.imul(h2 ^ ((c << 1) | 1), 0x01000193) >>> 0
86
+ }
87
+ return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0")
72
88
  }
73
89
 
74
90
  /**
@@ -1,8 +1,54 @@
1
- const db: any = { systemConfig: { findUnique: async () => null, upsert: async () => null } }; // Mocked for build
2
-
3
1
  import { cache } from "react";
4
2
  import { revalidateTag, unstable_cache } from "next/cache";
5
3
 
4
+ /**
5
+ * DB được INJECT từ app (core không import prisma client cụ thể). Trước
6
+ * 2026-07 chỗ này là `const db: any = {...} // Mocked for build` — mọi
7
+ * getSettings âm thầm trả default và saveSettings ghi vào hư không (gốc của
8
+ * regression "settings admin chỉnh không ăn"). App gọi
9
+ * `configureSettingsService(db)` MỘT lần ở composition root (cạnh chỗ tạo
10
+ * prisma client).
11
+ */
12
+ // Structural + schema-agnostic (giống PrismaLike ở crud/server-service): args
13
+ // để lỏng vì generated types của Prisma mỗi app một khác — chỉ chốt shape trả
14
+ // về mà service thật sự đọc.
15
+ export interface SettingsDb {
16
+ systemConfig: {
17
+ findUnique(args: any): Promise<{ value: string | null } | null>;
18
+ upsert(args: any): Promise<unknown>;
19
+ };
20
+ }
21
+
22
+ let configuredDb: SettingsDb | null = null;
23
+ let warnedMissingDb = false;
24
+
25
+ export function configureSettingsService(db: SettingsDb): void {
26
+ configuredDb = db;
27
+ }
28
+
29
+ /** Đọc: thiếu db → cảnh báo TO (một lần) + trả default — không sập build/SSR. */
30
+ function dbForRead(): SettingsDb | null {
31
+ if (configuredDb) return configuredDb;
32
+ if (!warnedMissingDb) {
33
+ warnedMissingDb = true;
34
+ console.warn(
35
+ "[SettingsService] Chưa configureSettingsService(db) — mọi getSettings " +
36
+ "đang trả DEFAULT (setting admin chỉnh sẽ không có hiệu lực). " +
37
+ "Gọi configureSettingsService(db) ở composition root của app.",
38
+ );
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /** Ghi: thiếu db → THROW. Ghi-vào-hư-không tệ hơn lỗi ồn ào. */
44
+ function dbForWrite(): SettingsDb {
45
+ if (configuredDb) return configuredDb;
46
+ throw new Error(
47
+ "[SettingsService] saveSettings cần configureSettingsService(db) — " +
48
+ "từ chối ghi vào hư không (trước đây mock db nuốt mất dữ liệu).",
49
+ );
50
+ }
51
+
6
52
  export interface AuditSettings {
7
53
  retentionDays: number;
8
54
  enabledResources: string[];
@@ -52,6 +98,8 @@ const DEFAULT_SALES_RULES_SETTINGS: SalesRulesSettings = {
52
98
  export class SettingsService {
53
99
  /** Get all settings cached */
54
100
  static async getSettings<T>(key: string, defaultValue: T): Promise<T> {
101
+ const db = dbForRead();
102
+ if (!db) return defaultValue;
55
103
  return unstable_cache(
56
104
  async () => {
57
105
  const config = await db.systemConfig.findUnique({
@@ -88,6 +136,7 @@ export class SettingsService {
88
136
  value: T,
89
137
  category = "general",
90
138
  ): Promise<void> {
139
+ const db = dbForWrite();
91
140
  await db.systemConfig.upsert({
92
141
  where: { key },
93
142
  update: {
@@ -21,6 +21,7 @@ import {
21
21
  SelectTrigger,
22
22
  SelectValue,
23
23
  Switch,
24
+ DatePicker,
24
25
  } from "../../ui";
25
26
  import { toast } from "sonner";
26
27
  import { cn } from "../../utils";
@@ -38,6 +39,7 @@ import {
38
39
  Briefcase,
39
40
  ShieldCheck,
40
41
  KeyRound,
42
+ Star,
41
43
  } from "lucide-react";
42
44
 
43
45
  interface UnifiedProfileDialogProps {
@@ -59,6 +61,22 @@ const fetcher = async (url: string) => {
59
61
  return Array.isArray(data) ? data : data.items || data.data || [];
60
62
  };
61
63
 
64
+ // birthday được lưu/gửi dưới dạng chuỗi "yyyy-MM-dd" (như input date cũ).
65
+ // DatePicker làm việc với Date nên cần cầu nối 2 chiều, tránh lệch ngày do TZ
66
+ // bằng cách neo giờ 00:00 địa phương và tự dựng chuỗi (không dùng toISOString).
67
+ const ymdToDate = (s?: string): Date | undefined => {
68
+ if (!s) return undefined;
69
+ const d = new Date(`${s}T00:00:00`);
70
+ return isNaN(d.getTime()) ? undefined : d;
71
+ };
72
+ const dateToYmd = (d?: Date): string => {
73
+ if (!d) return "";
74
+ const y = d.getFullYear();
75
+ const m = String(d.getMonth() + 1).padStart(2, "0");
76
+ const day = String(d.getDate()).padStart(2, "0");
77
+ return `${y}-${m}-${day}`;
78
+ };
79
+
62
80
  export function UnifiedProfileDialog({
63
81
  open,
64
82
  onOpenChange,
@@ -102,6 +120,7 @@ export function UnifiedProfileDialog({
102
120
  const [profileData, setProfileData] = useState<any>({});
103
121
  const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
104
122
  const [selectedBranches, setSelectedBranches] = useState<string[]>([]);
123
+ const [defaultBranchId, setDefaultBranchId] = useState<string | null>(null);
105
124
  const [passwordData, setPasswordData] = useState({
106
125
  password: "",
107
126
  confirm: "",
@@ -173,6 +192,7 @@ export function UnifiedProfileDialog({
173
192
  });
174
193
  setSelectedRoles([]);
175
194
  setSelectedBranches([]);
195
+ setDefaultBranchId(null);
176
196
  setPasswordData({ password: "", confirm: "" });
177
197
  setEnableAccount(false);
178
198
  } else {
@@ -203,6 +223,9 @@ export function UnifiedProfileDialog({
203
223
  const roles = d.roleCodes || [];
204
224
  setSelectedRoles(roles);
205
225
  setSelectedBranches(d.branchIds || []);
226
+ setDefaultBranchId(
227
+ d.defaultBranchId || (d.branchIds && d.branchIds[0]) || null,
228
+ );
206
229
  setPasswordData({ password: "", confirm: "" });
207
230
  // Enable account if user has roles or if it's not a customer (employees always have accounts?)
208
231
  // For now, if roles exist, we assume account is enabled.
@@ -216,11 +239,39 @@ export function UnifiedProfileDialog({
216
239
  if (mode === "create" || (data?.branchIds?.length || 0) === 0) {
217
240
  if (selectedBranches.length === 0) {
218
241
  setSelectedBranches([branches[0].id]);
242
+ setDefaultBranchId(branches[0].id);
219
243
  }
220
244
  }
221
245
  }
222
246
  }, [open, branches, mode, data]);
223
247
 
248
+ // -- Branch selection helpers --
249
+ // Toggle a branch's membership while keeping a valid default branch.
250
+ const toggleBranch = (branchId: string) => {
251
+ if (selectedBranches.includes(branchId)) {
252
+ const next = selectedBranches.filter((id) => id !== branchId);
253
+ setSelectedBranches(next);
254
+ // Removing the current default → promote the first remaining branch.
255
+ if (defaultBranchId === branchId) {
256
+ setDefaultBranchId(next[0] ?? null);
257
+ }
258
+ } else {
259
+ setSelectedBranches([...selectedBranches, branchId]);
260
+ // First branch selected becomes the default automatically.
261
+ if (!defaultBranchId) {
262
+ setDefaultBranchId(branchId);
263
+ }
264
+ }
265
+ };
266
+
267
+ // Mark a (selected) branch as the default working branch.
268
+ const markDefaultBranch = (branchId: string) => {
269
+ if (!selectedBranches.includes(branchId)) {
270
+ setSelectedBranches((prev) => [...prev, branchId]);
271
+ }
272
+ setDefaultBranchId(branchId);
273
+ };
274
+
224
275
  // -- Helpers --
225
276
  const getInitials = (name: string | null) => {
226
277
  if (!name) return "U";
@@ -254,6 +305,10 @@ export function UnifiedProfileDialog({
254
305
  userType, // Add userType to payload
255
306
  roleCodes: selectedRoles,
256
307
  branchIds: selectedBranches,
308
+ defaultBranchId:
309
+ defaultBranchId && selectedBranches.includes(defaultBranchId)
310
+ ? defaultBranchId
311
+ : (selectedBranches[0] ?? null),
257
312
  };
258
313
 
259
314
  // Handle Password for Customer
@@ -394,25 +449,41 @@ export function UnifiedProfileDialog({
394
449
  onValueChange={(val) => setUserType(val)}
395
450
  disabled={mode === "view"}
396
451
  >
397
- <SelectTrigger className="w-[140px] bg-white dark:bg-[#1e293b] border-[#e2e8f0] dark:border-[#334155] focus:ring-[#1641CE] focus:border-[#1641CE] h-9 rounded-md">
452
+ <SelectTrigger
453
+ style={{ width: 210 }}
454
+ // Base SelectTrigger có `[&_span]:line-clamp-none` — selector này biên
455
+ // dịch kèm `display:block` với độ ưu tiên (0,1,1) cao hơn `inline-flex`
456
+ // (0,1,0) của span icon+chữ bên trong, nên icon (svg Preflight display:block)
457
+ // bị đẩy xuống dòng riêng. `!inline-flex` (!important) thắng tuyệt đối.
458
+ className="shrink-0 h-9 min-h-0 whitespace-nowrap [&_span]:!inline-flex [&_span]:!items-center [&_span]:!gap-2 [&_span]:overflow-hidden [&_svg]:shrink-0 bg-white dark:bg-[#1e293b] border-[#e2e8f0] dark:border-[#334155] focus:ring-[#1641CE] focus:border-[#1641CE] rounded-md"
459
+ >
398
460
  <SelectValue placeholder="Chọn loại" />
399
461
  </SelectTrigger>
400
462
  <SelectContent>
401
463
  <SelectItem value="employee">
402
- <span className="flex items-center gap-2">
403
- <UserCog className="h-4 w-4 text-[#1641CE]" />
464
+ <span
465
+ className="inline-flex items-center gap-2"
466
+ style={{ whiteSpace: "nowrap" }}
467
+ >
468
+ <UserCog className="h-4 w-4 text-[#1641CE] shrink-0" />
404
469
  Nhân viên
405
470
  </span>
406
471
  </SelectItem>
407
472
  <SelectItem value="customer">
408
- <span className="flex items-center gap-2">
409
- <User className="h-4 w-4 text-[#059669]" />
473
+ <span
474
+ className="inline-flex items-center gap-2"
475
+ style={{ whiteSpace: "nowrap" }}
476
+ >
477
+ <User className="h-4 w-4 text-[#059669] shrink-0" />
410
478
  Khách hàng
411
479
  </span>
412
480
  </SelectItem>
413
481
  <SelectItem value="supplier">
414
- <span className="flex items-center gap-2">
415
- <Store className="h-4 w-4 text-[#EA580C]" />
482
+ <span
483
+ className="inline-flex items-center gap-2"
484
+ style={{ whiteSpace: "nowrap" }}
485
+ >
486
+ <Store className="h-4 w-4 text-[#EA580C] shrink-0" />
416
487
  Nhà cung cấp
417
488
  </span>
418
489
  </SelectItem>
@@ -587,16 +658,17 @@ export function UnifiedProfileDialog({
587
658
  {/* Additional Info for All */}
588
659
  <div className="space-y-1.5">
589
660
  <Label className="text-[13px] font-medium text-[#334155] dark:text-[#cbd5e1]">Ngày sinh</Label>
590
- <Input
591
- type="date"
592
- value={profileData.birthday}
593
- onChange={(e) =>
661
+ <DatePicker
662
+ value={ymdToDate(profileData.birthday)}
663
+ onValueChange={(d) =>
594
664
  setProfileData({
595
665
  ...profileData,
596
- birthday: e.target.value,
666
+ birthday: dateToYmd(d),
597
667
  })
598
668
  }
599
- className="bg-white dark:bg-[#1e293b] border-[#e2e8f0] dark:border-[#334155] focus:ring-primary focus:border-primary rounded-md h-9 text-[13px]"
669
+ formatStr="dd/MM/yyyy"
670
+ placeholder="dd/mm/yyyy"
671
+ buttonClassName="bg-white dark:bg-[#1e293b] border-[#e2e8f0] dark:border-[#334155] focus:ring-primary focus:border-primary rounded-md h-9 text-[13px]"
600
672
  />
601
673
  </div>
602
674
  <div className="space-y-1.5">
@@ -816,34 +888,56 @@ export function UnifiedProfileDialog({
816
888
  </Select>
817
889
  </div>
818
890
  <div className="col-span-2 space-y-3 pt-2">
819
- <Label className="text-[13px] font-medium text-[#41454d] dark:text-[#9297a0]">Chi nhánh làm việc</Label>
891
+ <div className="flex items-center justify-between gap-3 flex-wrap">
892
+ <Label className="text-[13px] font-medium text-[#41454d] dark:text-[#9297a0]">Chi nhánh làm việc</Label>
893
+ <span className="inline-flex items-center gap-1 text-[12px] text-[#94a3b8]">
894
+ <Star className="h-3 w-3" />
895
+ Đánh dấu một chi nhánh mặc định
896
+ </span>
897
+ </div>
820
898
  <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
821
899
  {branches?.map((branch) => {
822
900
  const isSelected = selectedBranches.includes(branch.id);
901
+ const isDefault =
902
+ isSelected && defaultBranchId === branch.id;
823
903
  return (
824
904
  <div
825
905
  key={branch.id}
826
906
  className={cn(
827
- "flex items-center gap-3 p-3 rounded-md border cursor-pointer hover:bg-slate-50 transition-colors",
907
+ "flex items-center gap-3 p-3 rounded-md border cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/40 transition-colors",
828
908
  isSelected
829
- ? "border-blue-500 bg-blue-50/50"
909
+ ? "border-blue-500 bg-blue-50/50 dark:bg-blue-500/10"
830
910
  : "bg-background border-slate-200 dark:border-slate-800",
831
911
  )}
832
- onClick={() => {
833
- setSelectedBranches((prev) =>
834
- prev.includes(branch.id)
835
- ? prev.filter((id) => id !== branch.id)
836
- : [...prev, branch.id],
837
- );
838
- }}
912
+ onClick={() => toggleBranch(branch.id)}
839
913
  >
840
914
  <Checkbox
841
915
  checked={isSelected}
842
916
  className="data-[state=checked]:bg-blue-600 data-[state=checked]:border-blue-600"
843
917
  />
844
- <span className="text-sm font-medium text-slate-900 dark:text-slate-100">
918
+ <span className="flex-1 text-sm font-medium text-slate-900 dark:text-slate-100">
845
919
  {branch.name}
846
920
  </span>
921
+ {isSelected &&
922
+ (isDefault ? (
923
+ <span className="inline-flex items-center gap-1 rounded-full bg-blue-600 px-2 py-0.5 text-[11px] font-semibold text-white shrink-0">
924
+ <Star className="h-3 w-3 fill-current" />
925
+ Mặc định
926
+ </span>
927
+ ) : (
928
+ <button
929
+ type="button"
930
+ onClick={(e) => {
931
+ e.stopPropagation();
932
+ markDefaultBranch(branch.id);
933
+ }}
934
+ title="Đặt làm chi nhánh mặc định"
935
+ className="inline-flex items-center gap-1 rounded-full border border-slate-200 dark:border-slate-700 px-2 py-0.5 text-[11px] font-medium text-slate-500 dark:text-slate-400 hover:border-blue-400 hover:text-blue-600 transition-colors shrink-0"
936
+ >
937
+ <Star className="h-3 w-3" />
938
+ Đặt mặc định
939
+ </button>
940
+ ))}
847
941
  </div>
848
942
  );
849
943
  })}
@@ -1,134 +0,0 @@
1
- // @goerp/core/rbac/permissions
2
- // Server-only permission utilities - no client/UI imports
3
- // Use this in Server Components and API routes to avoid bundling client code
4
-
5
- import type { Permission, Session } from "../types";
6
-
7
- // ============================================================================
8
- // Action Mapping
9
- // ============================================================================
10
-
11
- export const CRUD_ACTIONS = {
12
- create: "create",
13
- view: "view",
14
- update: "update",
15
- delete: "delete",
16
- export: "export",
17
- import: "import",
18
- approve: "approve",
19
- reject: "reject",
20
- } as const;
21
-
22
- export type CrudAction = keyof typeof CRUD_ACTIONS;
23
-
24
- export function getActionCode(operation: CrudAction): string {
25
- return CRUD_ACTIONS[operation];
26
- }
27
-
28
- // ============================================================================
29
- // Permission Helpers
30
- // ============================================================================
31
-
32
- interface ExtendedUser {
33
- id: string;
34
- name?: string | null;
35
- email?: string | null;
36
- image?: string | null;
37
- roles?: string[];
38
- permissions?: Permission[];
39
- }
40
-
41
- const BYPASS_AUTH =
42
- process.env.BYPASS_AUTH === "true" || process.env.BYPASS_AUTH === "1";
43
-
44
- const ADMIN_ROLE_CODES = ["admin", "SUPER_ADMIN"];
45
-
46
- export function getCrudPermissionsFromSession(
47
- session: Session | null,
48
- entity: string,
49
- ): {
50
- create: boolean;
51
- view: boolean;
52
- update: boolean;
53
- delete: boolean;
54
- export: boolean;
55
- import: boolean;
56
- approve: boolean;
57
- reject: boolean;
58
- } {
59
- if (BYPASS_AUTH) {
60
- return {
61
- create: true,
62
- view: true,
63
- update: true,
64
- delete: true,
65
- export: true,
66
- import: true,
67
- approve: true,
68
- reject: true,
69
- };
70
- }
71
-
72
- if (!session?.user) {
73
- return {
74
- create: false,
75
- view: false,
76
- update: false,
77
- delete: false,
78
- export: false,
79
- import: false,
80
- approve: false,
81
- reject: false,
82
- };
83
- }
84
-
85
- const user = session.user as ExtendedUser;
86
-
87
- if (!user.id) {
88
- return {
89
- create: false,
90
- view: false,
91
- update: false,
92
- delete: false,
93
- export: false,
94
- import: false,
95
- approve: false,
96
- reject: false,
97
- };
98
- }
99
-
100
- const isAdmin = user.roles?.some((role) => ADMIN_ROLE_CODES.includes(role));
101
- if (isAdmin) {
102
- return {
103
- create: true,
104
- view: true,
105
- update: true,
106
- delete: true,
107
- export: true,
108
- import: true,
109
- approve: true,
110
- reject: true,
111
- };
112
- }
113
-
114
- const permissions = user.permissions || [];
115
- const permissionKeys = new Set(
116
- permissions.map((p) => `${p.resourceCode}:${p.actionCode}`),
117
- );
118
-
119
- const hasPermission = (action: string) => {
120
- const key = `${entity}:${action}`;
121
- return permissionKeys.has(key);
122
- };
123
-
124
- return {
125
- create: hasPermission(getActionCode("create")),
126
- view: hasPermission(getActionCode("view")),
127
- update: hasPermission(getActionCode("update")),
128
- delete: hasPermission(getActionCode("delete")),
129
- export: hasPermission(getActionCode("export")),
130
- import: hasPermission(getActionCode("import")),
131
- approve: hasPermission(getActionCode("approve")),
132
- reject: hasPermission(getActionCode("reject")),
133
- };
134
- }