@goplusvn/core 0.1.87 → 0.1.89

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 (30) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +1 -1
  3. package/src/audit/__tests__/prisma-audit-extension.test.ts +113 -0
  4. package/src/audit/prisma-audit-extension.ts +41 -13
  5. package/src/configs/status.ts +67 -0
  6. package/src/cron/__tests__/cron-single-runner.test.ts +260 -0
  7. package/src/cron/cron-schedule.ts +113 -20
  8. package/src/cron/db-cron-manager.ts +90 -7
  9. package/src/cron/simple-cron-job.ts +17 -6
  10. package/src/crud/__tests__/status-boolean-display.test.tsx +186 -0
  11. package/src/crud/components/crud-detail-dialog.tsx +21 -8
  12. package/src/crud/components/crud-field-renderer.tsx +20 -13
  13. package/src/crud/components/crud-filter-chips.tsx +14 -39
  14. package/src/crud/components/crud-filters/datetime-filter.tsx +58 -17
  15. package/src/crud/components/crud-provider.tsx +10 -2
  16. package/src/crud/components/crud-table-toolbar.tsx +38 -40
  17. package/src/crud/components/crud-table.tsx +13 -8
  18. package/src/crud/lib/coerce.ts +36 -10
  19. package/src/crud/lib/filter-defaults.test.ts +115 -0
  20. package/src/crud/lib/filter-defaults.ts +56 -0
  21. package/src/crud/lib/filter-display.ts +59 -0
  22. package/src/crud/lib/query-builder.ts +4 -0
  23. package/src/types/index.ts +16 -9
  24. package/src/ui/data-display/data-table/data-table-toolbar.tsx +8 -2
  25. package/src/ui/data-display/data-table/data-table.tsx +32 -4
  26. package/src/ui/layout/user-dropdown.tsx +30 -23
  27. package/src/ui/primitives/status-badge.tsx +14 -10
  28. package/src/ui/shared/status-indicator.tsx +266 -74
  29. package/src/utils/index.ts +87 -38
  30. package/src/workspace/__tests__/workspace-delegation.test.ts +29 -11
@@ -23,7 +23,11 @@ export interface CronFields {
23
23
  dowIsWildcard: boolean;
24
24
  }
25
25
 
26
- function parseField(field: string, min: number, max: number): Set<number> | null {
26
+ function parseField(
27
+ field: string,
28
+ min: number,
29
+ max: number,
30
+ ): Set<number> | null {
27
31
  const values = new Set<number>();
28
32
  for (const part of field.split(",")) {
29
33
  const stepMatch = part.match(/^(.+)\/(\d+)$/);
@@ -79,46 +83,135 @@ export function parseCronExpression(expr: string): CronFields | null {
79
83
  };
80
84
  }
81
85
 
82
- export function cronMatches(fields: CronFields, date: Date): boolean {
83
- if (!fields.minute.has(date.getMinutes())) return false;
84
- if (!fields.hour.has(date.getHours())) return false;
85
- if (!fields.month.has(date.getMonth() + 1)) return false;
86
+ /** Các thành phần lịch của một mốc thời gian, ĐỌC THEO MÚI GIỜ chỉ định. */
87
+ export interface ZonedParts {
88
+ minute: number;
89
+ hour: number;
90
+ dayOfMonth: number;
91
+ month: number;
92
+ dayOfWeek: number;
93
+ }
94
+
95
+ const DOW_INDEX: Record<string, number> = {
96
+ Sun: 0,
97
+ Mon: 1,
98
+ Tue: 2,
99
+ Wed: 3,
100
+ Thu: 4,
101
+ Fri: 5,
102
+ Sat: 6,
103
+ };
86
104
 
87
- const domMatch = fields.dayOfMonth.has(date.getDate());
88
- const dowMatch = fields.dayOfWeek.has(date.getDay());
105
+ /**
106
+ * Đọc mốc thời gian theo `timeZone` (bỏ trống = giờ địa phương của tiến trình).
107
+ *
108
+ * Vì sao cần: container goerp khai `TZ=Asia/Ho_Chi_Minh`, nhưng CHỈ CẦN một
109
+ * container cũ còn sống với TZ mặc định UTC là job "30 22 * * *" nổ lúc 05:30
110
+ * sáng VN — đã xảy ra thật và chốt nhầm "không đi làm" cho cả nhà máy. Khai
111
+ * `timezone` cho job thì lịch bám múi giờ NGHIỆP VỤ, không bám cấu hình máy.
112
+ */
113
+ export function zonedParts(date: Date, timeZone?: string): ZonedParts {
114
+ if (!timeZone) {
115
+ return {
116
+ minute: date.getMinutes(),
117
+ hour: date.getHours(),
118
+ dayOfMonth: date.getDate(),
119
+ month: date.getMonth() + 1,
120
+ dayOfWeek: date.getDay(),
121
+ };
122
+ }
123
+ const parts = new Intl.DateTimeFormat("en-US", {
124
+ timeZone,
125
+ hourCycle: "h23",
126
+ month: "numeric",
127
+ day: "numeric",
128
+ hour: "numeric",
129
+ minute: "numeric",
130
+ weekday: "short",
131
+ }).formatToParts(date);
132
+ const get = (type: string) =>
133
+ parts.find((p) => p.type === type)?.value ?? "0";
134
+ return {
135
+ minute: Number(get("minute")),
136
+ hour: Number(get("hour")),
137
+ dayOfMonth: Number(get("day")),
138
+ month: Number(get("month")),
139
+ dayOfWeek: DOW_INDEX[get("weekday")] ?? 0,
140
+ };
141
+ }
142
+
143
+ export function cronMatches(
144
+ fields: CronFields,
145
+ date: Date,
146
+ timeZone?: string,
147
+ ): boolean {
148
+ const at = zonedParts(date, timeZone);
149
+ if (!fields.minute.has(at.minute)) return false;
150
+ if (!fields.hour.has(at.hour)) return false;
151
+ if (!fields.month.has(at.month)) return false;
152
+
153
+ const domMatch = fields.dayOfMonth.has(at.dayOfMonth);
154
+ const dowMatch = fields.dayOfWeek.has(at.dayOfWeek);
89
155
  // POSIX: cả dom lẫn dow bị giới hạn → OR; ngược lại → AND (vế wildcard luôn đúng).
90
- if (!fields.domIsWildcard && !fields.dowIsWildcard) return domMatch || dowMatch;
156
+ if (!fields.domIsWildcard && !fields.dowIsWildcard)
157
+ return domMatch || dowMatch;
91
158
  return domMatch && dowMatch;
92
159
  }
93
160
 
94
161
  /**
95
162
  * Lần khớp KẾ TIẾP sau `from` (đầu phút, không tính chính `from`).
96
163
  * Trả null nếu không có trong 2 năm tới (biểu thức bất khả thi, vd 30/2).
164
+ * `timeZone` bỏ trống = giờ địa phương của tiến trình (hành vi cũ).
97
165
  */
98
- export function nextCronDate(fields: CronFields, from = new Date()): Date | null {
166
+ export function nextCronDate(
167
+ fields: CronFields,
168
+ from = new Date(),
169
+ timeZone?: string,
170
+ ): Date | null {
99
171
  const cursor = new Date(from.getTime());
100
172
  cursor.setSeconds(0, 0);
101
173
  cursor.setMinutes(cursor.getMinutes() + 1);
102
174
 
103
175
  const LIMIT = 2 * 366 * 24 * 60; // phút
104
176
  for (let i = 0; i < LIMIT; i++) {
105
- if (cronMatches(fields, cursor)) return cursor;
177
+ if (cronMatches(fields, cursor, timeZone)) return cursor;
178
+
106
179
  // Nhảy nhanh: sai tháng → đầu tháng sau; sai ngày → đầu ngày sau; sai giờ
107
180
  // → đầu giờ sau. Giảm vòng lặp từ hàng trăm nghìn xuống vài trăm.
108
- if (!fields.month.has(cursor.getMonth() + 1)) {
181
+ //
182
+ // Có `timeZone` thì KHÔNG dùng setMonth/setHours (chúng đặt theo giờ máy,
183
+ // không phải giờ nghiệp vụ) — nhảy bằng cách CỘNG số phút còn lại tới
184
+ // biên giờ/ngày ĐỌC THEO múi giờ đó. Sai lệch do DST cùng lắm làm vòng
185
+ // lặp chạy thêm vài nhịp phút, không làm sai kết quả.
186
+ const at = zonedParts(cursor, timeZone);
187
+ const dayOk =
188
+ !fields.domIsWildcard && !fields.dowIsWildcard
189
+ ? fields.dayOfMonth.has(at.dayOfMonth) ||
190
+ fields.dayOfWeek.has(at.dayOfWeek)
191
+ : fields.dayOfMonth.has(at.dayOfMonth) &&
192
+ fields.dayOfWeek.has(at.dayOfWeek);
193
+
194
+ if (timeZone) {
195
+ if (!fields.month.has(at.month) || !dayOk) {
196
+ // Sang đầu ngày kế tiếp theo múi giờ nghiệp vụ.
197
+ cursor.setMinutes(
198
+ cursor.getMinutes() + (24 * 60 - (at.hour * 60 + at.minute)),
199
+ );
200
+ } else if (!fields.hour.has(at.hour)) {
201
+ cursor.setMinutes(cursor.getMinutes() + (60 - at.minute));
202
+ } else {
203
+ cursor.setMinutes(cursor.getMinutes() + 1);
204
+ }
205
+ continue;
206
+ }
207
+
208
+ if (!fields.month.has(at.month)) {
109
209
  cursor.setMonth(cursor.getMonth() + 1, 1);
110
210
  cursor.setHours(0, 0, 0, 0);
111
- } else if (
112
- !(fields.domIsWildcard && fields.dowIsWildcard) &&
113
- !(
114
- (!fields.domIsWildcard && !fields.dowIsWildcard
115
- ? fields.dayOfMonth.has(cursor.getDate()) || fields.dayOfWeek.has(cursor.getDay())
116
- : fields.dayOfMonth.has(cursor.getDate()) && fields.dayOfWeek.has(cursor.getDay()))
117
- )
118
- ) {
211
+ } else if (!(fields.domIsWildcard && fields.dowIsWildcard) && !dayOk) {
119
212
  cursor.setDate(cursor.getDate() + 1);
120
213
  cursor.setHours(0, 0, 0, 0);
121
- } else if (!fields.hour.has(cursor.getHours())) {
214
+ } else if (!fields.hour.has(at.hour)) {
122
215
  cursor.setHours(cursor.getHours() + 1, 0, 0, 0);
123
216
  } else {
124
217
  cursor.setMinutes(cursor.getMinutes() + 1);
@@ -1,10 +1,6 @@
1
1
  import { SimpleCronJob } from "./simple-cron-job";
2
2
 
3
- import type {
4
- CronJob,
5
- CronJobManager,
6
- CronJobOptions,
7
- } from "./types";
3
+ import type { CronJob, CronJobManager, CronJobOptions } from "./types";
8
4
 
9
5
  /**
10
6
  * Cron CÓ TRẠNG THÁI TRONG DB — engine dùng chung mọi app goerp (bảng
@@ -51,6 +47,12 @@ export interface CronDb {
51
47
  systemJob?: {
52
48
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
49
  count(args?: any): Promise<number>;
50
+ /**
51
+ * Optional: có thì bật GIÀNH LƯỢT CHẠY (một tiến trình / một nhịp). Thiếu
52
+ * thì manager chạy như cũ — app cũ không vỡ.
53
+ */
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ updateMany?(args: any): Promise<{ count: number }>;
54
56
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
57
  findUnique(args: any): Promise<any>;
56
58
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -110,12 +112,41 @@ export function getJobExecutionContext(): JobExecutionContext | null {
110
112
  return currentExecutionContext;
111
113
  }
112
114
 
115
+ /**
116
+ * GIÀNH LƯỢT CHẠY — chặn job chạy nhiều lần cùng một nhịp.
117
+ *
118
+ * Cron ở đây là timer TRONG TIẾN TRÌNH: mỗi tiến trình boot lên là tự đăng ký
119
+ * đủ bộ job. Hai container (hoặc `next dev` chạy song song bản deploy, hoặc
120
+ * một container cũ chưa bị gỡ) ⇒ MỌI job chạy hai lần mỗi nhịp. Đã gây hậu
121
+ * quả thật trên prod: hai dòng `job_execution_logs` cho mỗi nhịp, và job chốt
122
+ * ngày chạy lượt thứ hai ghi đè dữ liệu của lượt đầu.
123
+ *
124
+ * Cách chốt: trước khi chạy, mỗi tiến trình cố CẬP NHẬT CÓ ĐIỀU KIỆN dòng
125
+ * `system_jobs` — chỉ ăn khi `lastRun` còn cũ hơn nhịp hiện tại. Postgres bảo
126
+ * đảm một câu UPDATE có điều kiện là nguyên tử, nên đúng MỘT tiến trình nhận
127
+ * được `count = 1`; các tiến trình khác thấy 0 và bỏ nhịp. Không cần bảng
128
+ * khoá riêng, không cần leader election.
129
+ *
130
+ * Tắt bằng `GOERP_CRON_SINGLE_RUNNER=off` (chạy nhiều node CỐ Ý, hiếm).
131
+ *
132
+ * LƯU Ý: cái này chống trùng theo NHỊP, không sửa được tiến trình chạy SAI
133
+ * MÚI GIỜ — container UTC nổ job "30 22" vào một nhịp hoàn toàn khác nên vẫn
134
+ * giành được lượt. Vế đó do `timezone` của job lo (xem cron-schedule.ts).
135
+ */
136
+ function singleRunnerEnabled(): boolean {
137
+ return (process.env.GOERP_CRON_SINGLE_RUNNER ?? "").toLowerCase() !== "off";
138
+ }
139
+
113
140
  export class DbCronJobManager implements CronJobManager {
114
141
  private jobs = new Map<string, CronJob>();
115
142
  /** onTick GỐC của app — giữ để updateJob dựng lại job với cronTime mới. */
116
143
  private jobCallbacks = new Map<string, () => void | Promise<void>>();
117
- /** onTick ĐÃ BỌC (ghi log) — "chạy ngay" phải dùng bản này đểlịch sử. */
144
+ /** onTick ĐÃ BỌC (ghi log) — bản cho LỊCH,giành lượt chạy. */
118
145
  private wrappedCallbacks = new Map<string, () => void | Promise<void>>();
146
+ /** Bản cho nút "Chạy ngay": vẫn ghi log nhưng KHÔNG giành lượt. */
147
+ private manualCallbacks = new Map<string, () => void | Promise<void>>();
148
+ /** Múi giờ nghiệp vụ đã khai của từng job — giữ qua updateJob. */
149
+ private jobTimezones = new Map<string, string | undefined>();
119
150
  private isInitialized = false;
120
151
 
121
152
  async init(): Promise<void> {
@@ -146,12 +177,21 @@ export class DbCronJobManager implements CronJobManager {
146
177
  private wrapOnTick(
147
178
  name: string,
148
179
  onTick: () => void | Promise<void>,
180
+ /** false cho lượt chạy TAY: người bấm thì phải chạy, không giành lượt. */
181
+ claimTick = true,
149
182
  ): () => Promise<void> {
150
183
  return async () => {
151
184
  const { db } = requireConfig();
152
185
  const startedAt = new Date();
153
186
  let logId: string | null = null;
154
187
 
188
+ if (claimTick && !(await this.claimTick(name, startedAt))) {
189
+ log().info(
190
+ `Bỏ nhịp job ${name}: tiến trình khác đã nhận lượt này (single-runner)`,
191
+ );
192
+ return;
193
+ }
194
+
155
195
  if (db.jobExecutionLog) {
156
196
  try {
157
197
  const entry = await db.jobExecutionLog.create({
@@ -202,6 +242,36 @@ export class DbCronJobManager implements CronJobManager {
202
242
  };
203
243
  }
204
244
 
245
+ /**
246
+ * Nhận lượt chạy của nhịp hiện tại chưa? Xem docblock `singleRunnerEnabled`.
247
+ * Trả `true` (cho chạy) ở mọi trường hợp không chắc — cron là hạ tầng nền,
248
+ * thà chạy trùng còn hơn im lặng không chạy gì.
249
+ */
250
+ private async claimTick(name: string, startedAt: Date): Promise<boolean> {
251
+ if (!singleRunnerEnabled()) return true;
252
+ const { db } = requireConfig();
253
+ if (!db.systemJob?.updateMany) return true;
254
+
255
+ // Mốc nhịp = đầu phút. Hai tiến trình cùng nổ trong một phút sẽ tính ra
256
+ // CÙNG một mốc, nên chỉ một câu UPDATE khớp điều kiện.
257
+ const bucket = new Date(startedAt);
258
+ bucket.setSeconds(0, 0);
259
+
260
+ try {
261
+ const result = await db.systemJob.updateMany({
262
+ where: {
263
+ name,
264
+ OR: [{ lastRun: null }, { lastRun: { lt: bucket } }],
265
+ },
266
+ data: { lastRun: bucket, status: "running" },
267
+ });
268
+ return (result?.count ?? 1) > 0;
269
+ } catch (e) {
270
+ log().error(`Giành lượt chạy job ${name} lỗi — vẫn cho chạy`, e);
271
+ return true;
272
+ }
273
+ }
274
+
205
275
  private async closeExecutionLog(
206
276
  logId: string | null,
207
277
  name: string,
@@ -239,6 +309,10 @@ export class DbCronJobManager implements CronJobManager {
239
309
  this.jobCallbacks.set(options.name, options.onTick);
240
310
 
241
311
  const wrappedOnTick = this.wrapOnTick(options.name, options.onTick);
312
+ this.manualCallbacks.set(
313
+ options.name,
314
+ this.wrapOnTick(options.name, options.onTick, false),
315
+ );
242
316
  const wrappedOptions: CronJobOptions = {
243
317
  ...options,
244
318
  onTick: wrappedOnTick,
@@ -247,6 +321,7 @@ export class DbCronJobManager implements CronJobManager {
247
321
  const job = new SimpleCronJob(wrappedOptions);
248
322
  this.jobs.set(options.name, job);
249
323
  this.wrappedCallbacks.set(options.name, wrappedOnTick);
324
+ this.jobTimezones.set(options.name, options.timezone);
250
325
 
251
326
  // Đồng bộ DB bất đồng bộ: DB quyết định job có chạy hay không.
252
327
  void this.syncJobWithDb(wrappedOptions, job).catch((e) =>
@@ -340,6 +415,7 @@ export class DbCronJobManager implements CronJobManager {
340
415
  this.jobs.delete(name);
341
416
  this.jobCallbacks.delete(name);
342
417
  this.wrappedCallbacks.delete(name);
418
+ this.manualCallbacks.delete(name);
343
419
  }
344
420
 
345
421
  getJob(name: string): CronJob | undefined {
@@ -399,7 +475,8 @@ export class DbCronJobManager implements CronJobManager {
399
475
 
400
476
  /** Chạy tay (fire-and-forget) — vẫn đi qua bản BỌC nên có execution log. */
401
477
  async runJobNow(name: string): Promise<boolean> {
402
- const callback = this.wrappedCallbacks.get(name);
478
+ const callback =
479
+ this.manualCallbacks.get(name) ?? this.wrappedCallbacks.get(name);
403
480
  if (!callback) return false;
404
481
  void Promise.resolve(callback()).catch((e) =>
405
482
  log().error(`Chạy tay job ${name} lỗi`, e),
@@ -442,12 +519,18 @@ export class DbCronJobManager implements CronJobManager {
442
519
  cronTime: newCronTime,
443
520
  onTick: wrappedOnTick,
444
521
  start: finalEnabled ?? false,
522
+ // Đổi lịch KHÔNG được làm rơi múi giờ nghiệp vụ đã khai lúc đăng ký.
523
+ timezone: this.jobTimezones.get(name),
445
524
  };
446
525
 
447
526
  const newJob = new SimpleCronJob(newJobOptions);
448
527
  this.jobs.set(name, newJob);
449
528
  this.jobCallbacks.set(name, originalOnTick);
450
529
  this.wrappedCallbacks.set(name, wrappedOnTick);
530
+ this.manualCallbacks.set(
531
+ name,
532
+ this.wrapOnTick(name, originalOnTick, false),
533
+ );
451
534
 
452
535
  await this.syncJobWithDb(newJobOptions, newJob, enabled);
453
536
  log().info(`Đổi lịch job ${name} → ${newCronTime}`);
@@ -32,11 +32,18 @@ class SimpleCronJob implements CronJob {
32
32
  /** Chế độ khoảng đơn giản ("5m"…); null nghĩa là chạy theo cron thật. */
33
33
  private intervalMs: number | null = null;
34
34
  private cronFields: CronFields | null = null;
35
+ /**
36
+ * Múi giờ NGHIỆP VỤ của lịch. Bỏ trống = giờ địa phương tiến trình (hành vi
37
+ * cũ). Khai ra thì "30 22 * * *" là 22:30 ở múi giờ đó, dù container đang
38
+ * chạy UTC — chính là cách chặn hẳn lỗi job hằng ngày nổ lệch 7 tiếng.
39
+ */
40
+ private timezone?: string;
35
41
 
36
42
  constructor(options: CronJobOptions) {
37
43
  this.name = options.name;
38
44
  this.cronTime = options.cronTime;
39
45
  this.onTick = options.onTick;
46
+ this.timezone = options.timezone;
40
47
 
41
48
  const simpleMatch = options.cronTime.match(/^(\d+)([smhd])$/);
42
49
  if (simpleMatch) {
@@ -73,7 +80,7 @@ class SimpleCronJob implements CronJob {
73
80
  /** Hẹn giờ tới lần khớp cron kế tiếp; chạy xong tự hẹn tiếp. */
74
81
  private scheduleNextCron(): void {
75
82
  if (!this.running || !this.cronFields) return;
76
- const next = nextCronDate(this.cronFields);
83
+ const next = nextCronDate(this.cronFields, new Date(), this.timezone);
77
84
  if (!next) {
78
85
  logger.warn(
79
86
  `Cron job "${this.name}": biểu thức "${this.cronTime}" không có lần chạy nào trong 2 năm tới — dừng.`,
@@ -88,10 +95,13 @@ class SimpleCronJob implements CronJob {
88
95
  this.timerId = setTimeout(() => this.scheduleNextCron(), MAX_CHUNK);
89
96
  return;
90
97
  }
91
- this.timerId = setTimeout(async () => {
92
- await this.fire();
93
- this.scheduleNextCron();
94
- }, Math.max(0, delay));
98
+ this.timerId = setTimeout(
99
+ async () => {
100
+ await this.fire();
101
+ this.scheduleNextCron();
102
+ },
103
+ Math.max(0, delay),
104
+ );
95
105
  }
96
106
 
97
107
  start(): void {
@@ -131,7 +141,8 @@ class SimpleCronJob implements CronJob {
131
141
 
132
142
  nextDate(): Date | null {
133
143
  if (!this.running) return null;
134
- if (this.cronFields) return nextCronDate(this.cronFields);
144
+ if (this.cronFields)
145
+ return nextCronDate(this.cronFields, new Date(), this.timezone);
135
146
  return new Date(Date.now() + this.intervalMs!);
136
147
  }
137
148
  }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * HỒI QUY — Ô "Trạng thái" với dữ liệu boolean.
3
+ *
4
+ * Cùng một field khai `type: "switch"` + cặp option chữ
5
+ * (`Hoạt động` / `Tạm ngưng`), nhưng CỘT trong DB mỗi app một kiểu:
6
+ *
7
+ * - text → "active" / "inactive" (spartronics, phần lớn bảng)
8
+ * - boolean → true / false (`is_active`, `enabled`)
9
+ * - smallint → 1 / 0
10
+ *
11
+ * Trước bản này chỗ nào cũng so TUYỆT ĐỐI (`optValue === value`) và form thì
12
+ * so cứng `value === "active"`, nên với cột boolean:
13
+ *
14
+ * - bảng danh sách hiện chữ "true"/"false" trong chip xám;
15
+ * - `getStatusMeta(true)` ném TypeError (`true.toLowerCase is not a function`);
16
+ * - hộp thoại Sửa hiện công tắc TẮT cho bản ghi đang BẬT.
17
+ *
18
+ * Bộ test này khoá cả ba đường: chuẩn hoá giá trị, chip hiển thị, và cặp
19
+ * bật/tắt mà công tắc form đọc/ghi.
20
+ */
21
+ import { describe, it, expect } from "vitest";
22
+ import { render, screen } from "@testing-library/react";
23
+
24
+ import {
25
+ normalizeStatusValue,
26
+ isTruthyStatus,
27
+ matchesOptionValue,
28
+ } from "../../configs/status";
29
+ import { getStatusMeta } from "../../ui/shared/status-indicator";
30
+ import { StatusBadge } from "../../ui/primitives/status-badge";
31
+ import { coerceFieldValue, resolveSwitchPair } from "../lib/coerce";
32
+ import type { FieldConfig } from "../../types";
33
+
34
+ const statusField = (over: Partial<FieldConfig> = {}): FieldConfig => ({
35
+ name: "status",
36
+ label: "Trạng thái",
37
+ type: "switch",
38
+ options: [
39
+ { label: "Hoạt động", value: "active" },
40
+ { label: "Tạm ngưng", value: "inactive" },
41
+ ],
42
+ ...over,
43
+ });
44
+
45
+ describe("normalizeStatusValue — mọi biến thể về một key", () => {
46
+ it("boolean → active/inactive", () => {
47
+ expect(normalizeStatusValue(true)).toBe("active");
48
+ expect(normalizeStatusValue(false)).toBe("inactive");
49
+ });
50
+
51
+ it("số 1/0 (cột smallint, BigInt của Prisma) → active/inactive", () => {
52
+ expect(normalizeStatusValue(1)).toBe("active");
53
+ expect(normalizeStatusValue(0)).toBe("inactive");
54
+ expect(normalizeStatusValue(BigInt(1))).toBe("active");
55
+ });
56
+
57
+ it("chuỗi đi qua form/URL → active/inactive", () => {
58
+ for (const v of ["true", "1", "on", "yes", "ACTIVE", " Active "]) {
59
+ expect(normalizeStatusValue(v)).toBe("active");
60
+ }
61
+ for (const v of ["false", "0", "off", "no", "INACTIVE"]) {
62
+ expect(normalizeStatusValue(v)).toBe("inactive");
63
+ }
64
+ });
65
+
66
+ it("trạng thái KHÁC bật/tắt giữ nguyên key để tra bảng màu", () => {
67
+ expect(normalizeStatusValue("pending")).toBe("pending");
68
+ expect(normalizeStatusValue("Partially Paid")).toBe("partially_paid");
69
+ });
70
+
71
+ it("rỗng → null (không quy bừa về inactive)", () => {
72
+ expect(normalizeStatusValue(null)).toBeNull();
73
+ expect(normalizeStatusValue(undefined)).toBeNull();
74
+ expect(normalizeStatusValue("")).toBeNull();
75
+ expect(normalizeStatusValue(" ")).toBeNull();
76
+ });
77
+
78
+ it("isTruthyStatus dùng chung một bảng", () => {
79
+ expect(isTruthyStatus(true)).toBe(true);
80
+ expect(isTruthyStatus("active")).toBe(true);
81
+ expect(isTruthyStatus(0)).toBe(false);
82
+ expect(isTruthyStatus("pending")).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe("matchesOptionValue — giá trị dòng khớp option đã khai", () => {
87
+ it("boolean khớp option chữ", () => {
88
+ expect(matchesOptionValue("active", true)).toBe(true);
89
+ expect(matchesOptionValue("inactive", false)).toBe(true);
90
+ expect(matchesOptionValue("active", false)).toBe(false);
91
+ });
92
+
93
+ it("số khớp option chuỗi và ngược lại", () => {
94
+ expect(matchesOptionValue("1", 1)).toBe(true);
95
+ expect(matchesOptionValue(1, "1")).toBe(true);
96
+ expect(matchesOptionValue(true, "active")).toBe(true);
97
+ });
98
+
99
+ it("hai giá trị lạ KHÔNG khớp bừa vì cùng chuẩn hoá ra null", () => {
100
+ expect(matchesOptionValue("", null)).toBe(false);
101
+ expect(matchesOptionValue(null, undefined)).toBe(false);
102
+ expect(matchesOptionValue({}, [])).toBe(false);
103
+ });
104
+ });
105
+
106
+ describe("getStatusMeta — nhận cả giá trị không phải chuỗi", () => {
107
+ it("boolean true → Hoạt động, không ném lỗi", () => {
108
+ expect(() => getStatusMeta(true)).not.toThrow();
109
+ expect(getStatusMeta(true).label).toBe("Hoạt động");
110
+ expect(getStatusMeta(true).badgeVariant).toBe("success");
111
+ });
112
+
113
+ it("boolean false → Ngừng hoạt động", () => {
114
+ expect(getStatusMeta(false).label).toBe("Ngừng hoạt động");
115
+ expect(getStatusMeta(false).badgeVariant).toBe("danger");
116
+ });
117
+
118
+ it("key lạ báo matched=false để nơi gọi tự quyết nhãn", () => {
119
+ expect(getStatusMeta("khong_co_trong_bang").matched).toBe(false);
120
+ expect(getStatusMeta("active").matched).toBe(true);
121
+ });
122
+ });
123
+
124
+ describe("StatusBadge — chip trạng thái", () => {
125
+ it("giá trị boolean KHÔNG được hiện ra chữ true/false", () => {
126
+ render(<StatusBadge status={true} />);
127
+ expect(screen.getByText("Hoạt động")).toBeTruthy();
128
+ expect(screen.queryByText("true")).toBeNull();
129
+ });
130
+
131
+ it("boolean false → nhãn tiếng Việt", () => {
132
+ render(<StatusBadge status={false} />);
133
+ expect(screen.getByText("Ngừng hoạt động")).toBeTruthy();
134
+ });
135
+
136
+ it("nhãn do nơi gọi truyền vào vẫn thắng", () => {
137
+ render(<StatusBadge status={true} label="Đang bật" />);
138
+ expect(screen.getByText("Đang bật")).toBeTruthy();
139
+ });
140
+ });
141
+
142
+ describe("resolveSwitchPair — cặp bật/tắt của công tắc form", () => {
143
+ it("có cặp option → ghi đúng giá trị cột chữ", () => {
144
+ expect(resolveSwitchPair(statusField())).toEqual({
145
+ on: "active",
146
+ off: "inactive",
147
+ });
148
+ });
149
+
150
+ it("không khai option → cột Boolean thật", () => {
151
+ expect(resolveSwitchPair(statusField({ options: undefined }))).toEqual({
152
+ on: true,
153
+ off: false,
154
+ });
155
+ });
156
+
157
+ it("khai valueType boolean → ghi true/false dù option là chữ", () => {
158
+ expect(resolveSwitchPair(statusField({ valueType: "boolean" }))).toEqual({
159
+ on: true,
160
+ off: false,
161
+ });
162
+ });
163
+
164
+ it("công tắc BẬT cho bản ghi boolean true dù option khai chữ", () => {
165
+ const { on } = resolveSwitchPair(statusField());
166
+ expect(matchesOptionValue(on, true)).toBe(true);
167
+ expect(matchesOptionValue(on, "active")).toBe(true);
168
+ expect(matchesOptionValue(on, false)).toBe(false);
169
+ });
170
+ });
171
+
172
+ describe("coerceFieldValue — lưu xuống đúng kiểu cột", () => {
173
+ it("valueType boolean: chuỗi 'active' của form → true", () => {
174
+ const f = statusField({ valueType: "boolean" });
175
+ expect(coerceFieldValue(f, "active")).toBe(true);
176
+ expect(coerceFieldValue(f, "inactive")).toBe(false);
177
+ expect(coerceFieldValue(f, true)).toBe(true);
178
+ });
179
+
180
+ it("cột chữ: boolean lọt vào vẫn ra đúng option, không thành 'true'", () => {
181
+ const f = statusField();
182
+ expect(coerceFieldValue(f, true)).toBe("active");
183
+ expect(coerceFieldValue(f, false)).toBe("inactive");
184
+ expect(coerceFieldValue(f, "active")).toBe("active");
185
+ });
186
+ });
@@ -31,6 +31,8 @@ import {
31
31
  isFieldVisibleInDetail,
32
32
  sortFieldsByOrder,
33
33
  } from "../lib/crud-utils";
34
+ import { matchesOptionValue } from "../../utils";
35
+ import { humanizeDictKey } from "../lib/translate-config";
34
36
  import { formatFieldValue } from "../lib/field-formatter";
35
37
  import { dataLoader } from "../lib/data-loader";
36
38
 
@@ -111,14 +113,23 @@ function renderFieldValue(
111
113
  />
112
114
  );
113
115
  }
116
+ // SO LỎNG như ở bảng: `true` phải khớp option `"active"` (cột Boolean),
117
+ // `1` phải khớp `"1"` (cột smallint).
114
118
  const option = field.options?.find((opt) => {
115
119
  const optValue = typeof opt === "object" ? opt.value : opt;
116
- return optValue === value;
120
+ return matchesOptionValue(optValue, value);
117
121
  });
118
- const label =
119
- (typeof option === "object" ? option.label : option) ?? String(value);
120
122
  if (isEmpty) return <span className="text-muted-foreground">—</span>;
121
- return <StatusBadge status={value} label={String(label)} />;
123
+ const rawLabel = typeof option === "object" ? option.label : option;
124
+ // Không khớp option nào thì ĐỪNG ép nhãn "true"/"false" — để StatusBadge
125
+ // tự tra nhãn tiếng Việt theo taxonomy chung.
126
+ const label =
127
+ typeof rawLabel === "string" && rawLabel !== ""
128
+ ? rawLabel.startsWith("crud.")
129
+ ? humanizeDictKey(rawLabel)
130
+ : rawLabel
131
+ : undefined;
132
+ return <StatusBadge status={value} label={label} />;
122
133
  }
123
134
 
124
135
  if (isEmpty) {
@@ -138,7 +149,11 @@ function renderFieldValue(
138
149
  return (
139
150
  <div className="flex flex-wrap gap-1">
140
151
  {labels.map((l, i) => (
141
- <Badge key={`${l}-${i}`} variant="secondary" className="font-normal">
152
+ <Badge
153
+ key={`${l}-${i}`}
154
+ variant="secondary"
155
+ className="font-normal"
156
+ >
142
157
  {l}
143
158
  </Badge>
144
159
  ))}
@@ -193,9 +208,7 @@ function renderFieldValue(
193
208
  field.type === "json" && typeof value === "object"
194
209
  ? JSON.stringify(value, null, 2)
195
210
  : formatFieldValue(value, field) || String(value);
196
- return (
197
- <span className="whitespace-pre-wrap break-words">{text}</span>
198
- );
211
+ return <span className="whitespace-pre-wrap break-words">{text}</span>;
199
212
  }
200
213
 
201
214
  return formatFieldValue(value, field) || String(value);