@goplusvn/core 0.1.4 → 0.1.6
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 +49 -0
- package/PLATFORM.md +111 -0
- package/package.json +17 -2
- package/src/crud/components/crud-dialog.tsx +16 -0
- package/src/crud/components/crud-sheet.tsx +16 -0
- package/src/crud/lib/import-export-service.ts +4 -9
- package/src/errors/app-error.ts +236 -0
- package/src/errors/error-handler.ts +89 -0
- package/src/errors/index.ts +17 -0
- package/src/errors/server-error.ts +316 -0
- package/src/export/download-file.ts +46 -0
- package/src/export/index.ts +7 -0
- package/src/export/to-csv.ts +35 -0
- package/src/export/use-export.ts +68 -0
- package/src/print/direct-print-provider.tsx +333 -0
- package/src/print/index.ts +11 -0
- package/src/print/print-styles.tsx +234 -0
- package/src/repository/cas-update.ts +54 -0
- package/src/repository/index.ts +2 -0
- package/src/styles/base.css +585 -0
- package/src/styles/radix-themes.css +160 -0
- package/src/ui/feedback/sheet.tsx +2 -0
- package/src/ui/forms/editor/index.tsx +4 -1
- package/src/ui/primitives/dialog.tsx +6 -2
- package/src/ui/primitives/use-release-stuck-body-lock.ts +29 -0
- package/src/utils/cccd-parser.ts +62 -0
- package/src/utils/date-utils.ts +175 -0
- package/src/utils/fetcher.ts +46 -0
- package/src/utils/serialize.ts +47 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/* Auto-generated Radix Colors (HSL) */
|
|
2
|
+
:root {
|
|
3
|
+
--slate-1: 240 20% 99%;
|
|
4
|
+
--slate-2: 240 20% 98%;
|
|
5
|
+
--slate-3: 240 11.1% 94.7%;
|
|
6
|
+
--slate-4: 240 9.5% 91.8%;
|
|
7
|
+
--slate-5: 230 10.7% 89%;
|
|
8
|
+
--slate-6: 240 10.1% 86.5%;
|
|
9
|
+
--slate-7: 233 9.9% 82.2%;
|
|
10
|
+
--slate-8: 231 10.2% 75.1%;
|
|
11
|
+
--slate-9: 231 5.9% 57.1%;
|
|
12
|
+
--slate-10: 226 5.4% 52.7%;
|
|
13
|
+
--slate-11: 220 5.9% 40%;
|
|
14
|
+
--slate-12: 210 12.5% 12.5%;
|
|
15
|
+
|
|
16
|
+
--indigo-1: 240 33.3% 99.4%;
|
|
17
|
+
--indigo-2: 225 100% 98.4%;
|
|
18
|
+
--indigo-3: 222 89.5% 96.3%;
|
|
19
|
+
--indigo-4: 224 100% 94.1%;
|
|
20
|
+
--indigo-5: 224 100% 91.2%;
|
|
21
|
+
--indigo-6: 225 100% 87.8%;
|
|
22
|
+
--indigo-7: 226 86.7% 82.4%;
|
|
23
|
+
--indigo-8: 226 75.4% 74.5%;
|
|
24
|
+
--indigo-9: 226 70% 55.5%;
|
|
25
|
+
--indigo-10: 226 65.2% 51.6%;
|
|
26
|
+
--indigo-11: 226 55.7% 50.4%;
|
|
27
|
+
--indigo-12: 226 49.6% 24.1%;
|
|
28
|
+
|
|
29
|
+
--red-1: 0 100% 99.4%;
|
|
30
|
+
--red-2: 0 100% 98.4%;
|
|
31
|
+
--red-3: 357 90.5% 95.9%;
|
|
32
|
+
--red-4: 358 100% 92.9%;
|
|
33
|
+
--red-5: 359 100% 90.2%;
|
|
34
|
+
--red-6: 359 94.1% 86.7%;
|
|
35
|
+
--red-7: 359 77.3% 81%;
|
|
36
|
+
--red-8: 359 69.9% 73.9%;
|
|
37
|
+
--red-9: 358 75.1% 59%;
|
|
38
|
+
--red-10: 358 69.3% 55.3%;
|
|
39
|
+
--red-11: 358 64.8% 49%;
|
|
40
|
+
--red-12: 351 62.6% 24.1%;
|
|
41
|
+
|
|
42
|
+
--green-1: 140 60% 99%;
|
|
43
|
+
--green-2: 137 46.7% 97.1%;
|
|
44
|
+
--green-3: 139 47.1% 93.3%;
|
|
45
|
+
--green-4: 140 49.1% 89.2%;
|
|
46
|
+
--green-5: 142 43.9% 83.9%;
|
|
47
|
+
--green-6: 144 41.4% 77.3%;
|
|
48
|
+
--green-7: 146 39.5% 68.2%;
|
|
49
|
+
--green-8: 151 40.2% 54.1%;
|
|
50
|
+
--green-9: 151 54.7% 41.6%;
|
|
51
|
+
--green-10: 152 56.3% 38.6%;
|
|
52
|
+
--green-11: 154 59.8% 32.2%;
|
|
53
|
+
--green-12: 155 40.5% 16.5%;
|
|
54
|
+
|
|
55
|
+
--orange-1: 20 60% 99%;
|
|
56
|
+
--orange-2: 33 100% 96.5%;
|
|
57
|
+
--orange-3: 37 100% 92%;
|
|
58
|
+
--orange-4: 34 100% 85.5%;
|
|
59
|
+
--orange-5: 33 100% 80.2%;
|
|
60
|
+
--orange-6: 30 100% 75.5%;
|
|
61
|
+
--orange-7: 27 86.7% 70.6%;
|
|
62
|
+
--orange-8: 25 79.9% 62.9%;
|
|
63
|
+
--orange-9: 23 93.4% 52.5%;
|
|
64
|
+
--orange-10: 24 100% 46.9%;
|
|
65
|
+
--orange-11: 23 100% 40%;
|
|
66
|
+
--orange-12: 16 50.4% 22.9%;
|
|
67
|
+
|
|
68
|
+
--blue-1: 210 100% 99.2%;
|
|
69
|
+
--blue-2: 207 100% 97.8%;
|
|
70
|
+
--blue-3: 205 92.3% 94.9%;
|
|
71
|
+
--blue-4: 203 100% 91.8%;
|
|
72
|
+
--blue-5: 206 100% 88%;
|
|
73
|
+
--blue-6: 207 93% 83.1%;
|
|
74
|
+
--blue-7: 207 85.2% 76.1%;
|
|
75
|
+
--blue-8: 206 81.9% 65.3%;
|
|
76
|
+
--blue-9: 206 100% 50%;
|
|
77
|
+
--blue-10: 207 95.9% 48%;
|
|
78
|
+
--blue-11: 208 88.1% 42.9%;
|
|
79
|
+
--blue-12: 216 70.9% 22.9%;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.dark {
|
|
83
|
+
--slate-1: 240 5.6% 7.1%;
|
|
84
|
+
--slate-2: 220 5.9% 10%;
|
|
85
|
+
--slate-3: 225 5.7% 13.7%;
|
|
86
|
+
--slate-4: 210 7.1% 16.5%;
|
|
87
|
+
--slate-5: 214 7.1% 19.4%;
|
|
88
|
+
--slate-6: 213 7.7% 22.9%;
|
|
89
|
+
--slate-7: 213 7.6% 28.4%;
|
|
90
|
+
--slate-8: 212 7.7% 38.2%;
|
|
91
|
+
--slate-9: 219 6.3% 43.9%;
|
|
92
|
+
--slate-10: 222 5.2% 49.2%;
|
|
93
|
+
--slate-11: 216 6.8% 71%;
|
|
94
|
+
--slate-12: 220 9.1% 93.5%;
|
|
95
|
+
|
|
96
|
+
--indigo-1: 231 29.2% 9.4%;
|
|
97
|
+
--indigo-2: 230 31% 11.4%;
|
|
98
|
+
--indigo-3: 225 50.5% 19%;
|
|
99
|
+
--indigo-4: 225 54.3% 24.9%;
|
|
100
|
+
--indigo-5: 225 51.6% 30%;
|
|
101
|
+
--indigo-6: 226 46.7% 35.3%;
|
|
102
|
+
--indigo-7: 226 44.5% 41%;
|
|
103
|
+
--indigo-8: 226 45.1% 47.8%;
|
|
104
|
+
--indigo-9: 226 70% 55.5%;
|
|
105
|
+
--indigo-10: 228 72.7% 61.2%;
|
|
106
|
+
--indigo-11: 228 100% 81%;
|
|
107
|
+
--indigo-12: 224 100% 92%;
|
|
108
|
+
|
|
109
|
+
--red-1: 0 19% 8.2%;
|
|
110
|
+
--red-2: 355 25.5% 10%;
|
|
111
|
+
--red-3: 350 53.2% 15.1%;
|
|
112
|
+
--red-4: 348 68.4% 18.6%;
|
|
113
|
+
--red-5: 350 63% 23.3%;
|
|
114
|
+
--red-6: 352 53% 29.2%;
|
|
115
|
+
--red-7: 355 46.6% 37.5%;
|
|
116
|
+
--red-8: 358 44.8% 49%;
|
|
117
|
+
--red-9: 358 75.1% 59%;
|
|
118
|
+
--red-10: 0 79% 64.5%;
|
|
119
|
+
--red-11: 2 100% 78.6%;
|
|
120
|
+
--red-12: 350 100% 91%;
|
|
121
|
+
|
|
122
|
+
--green-1: 154 20% 6.9%;
|
|
123
|
+
--green-2: 153 20% 8.8%;
|
|
124
|
+
--green-3: 152 40.6% 12.5%;
|
|
125
|
+
--green-4: 154 55.3% 14.9%;
|
|
126
|
+
--green-5: 154 52.1% 18.8%;
|
|
127
|
+
--green-6: 153 46.2% 23.3%;
|
|
128
|
+
--green-7: 152 44.4% 28.2%;
|
|
129
|
+
--green-8: 151 45% 33.5%;
|
|
130
|
+
--green-9: 151 54.7% 41.6%;
|
|
131
|
+
--green-10: 151 55.1% 44.5%;
|
|
132
|
+
--green-11: 151 65.1% 53.9%;
|
|
133
|
+
--green-12: 144 69.6% 82%;
|
|
134
|
+
|
|
135
|
+
--orange-1: 27 24.3% 7.3%;
|
|
136
|
+
--orange-2: 28 33.3% 8.8%;
|
|
137
|
+
--orange-3: 29 64.5% 12.2%;
|
|
138
|
+
--orange-4: 28 100% 13.7%;
|
|
139
|
+
--orange-5: 28 100% 16.9%;
|
|
140
|
+
--orange-6: 27 78.9% 22.4%;
|
|
141
|
+
--orange-7: 25 62.6% 30.4%;
|
|
142
|
+
--orange-8: 23 59.8% 40%;
|
|
143
|
+
--orange-9: 23 93.4% 52.5%;
|
|
144
|
+
--orange-10: 26 100% 56.1%;
|
|
145
|
+
--orange-11: 26 100% 67.1%;
|
|
146
|
+
--orange-12: 30 100% 88%;
|
|
147
|
+
|
|
148
|
+
--blue-1: 215 42.2% 8.8%;
|
|
149
|
+
--blue-2: 218 39.3% 11%;
|
|
150
|
+
--blue-3: 212 69% 16.5%;
|
|
151
|
+
--blue-4: 209 100% 19.2%;
|
|
152
|
+
--blue-5: 207 100% 22.7%;
|
|
153
|
+
--blue-6: 209 78.8% 29.6%;
|
|
154
|
+
--blue-7: 211 66.3% 37.3%;
|
|
155
|
+
--blue-8: 211 65.1% 44.9%;
|
|
156
|
+
--blue-9: 206 100% 50%;
|
|
157
|
+
--blue-10: 210 100% 61.6%;
|
|
158
|
+
--blue-11: 210 100% 72%;
|
|
159
|
+
--blue-12: 205 100% 88%;
|
|
160
|
+
}
|
|
@@ -4,6 +4,7 @@ import * as SheetPrimitive from "@radix-ui/react-dialog";
|
|
|
4
4
|
import { cva } from "class-variance-authority";
|
|
5
5
|
import type { ComponentProps } from "react";
|
|
6
6
|
import { cn } from "../../utils";
|
|
7
|
+
import { useReleaseStuckBodyLock } from "../primitives/use-release-stuck-body-lock";
|
|
7
8
|
|
|
8
9
|
export function Sheet({
|
|
9
10
|
...props
|
|
@@ -84,6 +85,7 @@ export function SheetContent({
|
|
|
84
85
|
side = "right",
|
|
85
86
|
...props
|
|
86
87
|
}: SheetContentProps) {
|
|
88
|
+
useReleaseStuckBodyLock();
|
|
87
89
|
return (
|
|
88
90
|
<SheetPortal>
|
|
89
91
|
<SheetOverlay />
|
|
@@ -72,7 +72,10 @@ export function Editor({
|
|
|
72
72
|
showOnlyCurrent: true,
|
|
73
73
|
}),
|
|
74
74
|
Typography,
|
|
75
|
-
|
|
75
|
+
// Cast: các gói @tiptap/* có thể lệch version @tiptap/core trong monorepo,
|
|
76
|
+
// gây lỗi type (Extension không khớp AnyExtension) khi build dts. An toàn
|
|
77
|
+
// vì runtime giống nhau; cast về đúng kiểu options yêu cầu.
|
|
78
|
+
] as unknown as UseEditorOptions["extensions"],
|
|
76
79
|
content: value,
|
|
77
80
|
onUpdate: ({ editor }) => {
|
|
78
81
|
onValueChange?.(editor.getHTML());
|
|
@@ -4,6 +4,7 @@ import * as React from "react";
|
|
|
4
4
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
5
5
|
import { X } from "lucide-react";
|
|
6
6
|
import { cn } from "../../utils";
|
|
7
|
+
import { useReleaseStuckBodyLock } from "./use-release-stuck-body-lock";
|
|
7
8
|
|
|
8
9
|
const Dialog = DialogPrimitive.Root;
|
|
9
10
|
|
|
@@ -31,7 +32,9 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|
|
31
32
|
const DialogContent = React.forwardRef<
|
|
32
33
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
33
34
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
|
34
|
-
>(({ className, children, ...props }, ref) =>
|
|
35
|
+
>(({ className, children, ...props }, ref) => {
|
|
36
|
+
useReleaseStuckBodyLock();
|
|
37
|
+
return (
|
|
35
38
|
<DialogPortal>
|
|
36
39
|
<DialogOverlay />
|
|
37
40
|
<DialogPrimitive.Content
|
|
@@ -49,7 +52,8 @@ const DialogContent = React.forwardRef<
|
|
|
49
52
|
</DialogPrimitive.Close>
|
|
50
53
|
</DialogPrimitive.Content>
|
|
51
54
|
</DialogPortal>
|
|
52
|
-
)
|
|
55
|
+
);
|
|
56
|
+
});
|
|
53
57
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
|
54
58
|
|
|
55
59
|
const DialogHeader = ({
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lưới an toàn cho "lỗi conflict event kinh điển": Radix (Dialog/AlertDialog/Sheet
|
|
7
|
+
* + Select/Combobox/Popover lồng bên trong) đôi khi để sót `pointer-events: none`
|
|
8
|
+
* / scroll-lock trên <body> sau khi đóng, khiến TOÀN trang không click được.
|
|
9
|
+
*
|
|
10
|
+
* Gọi hook này trong *Content của mỗi primitive overlay. Content chỉ mount khi
|
|
11
|
+
* overlay mở; khi nó unmount (đã đóng) ta gỡ khoá — nhưng CHỈ khi không còn lớp
|
|
12
|
+
* dialog/popover nào đang mở, để không phá modal khác đang chồng lên.
|
|
13
|
+
*/
|
|
14
|
+
export function useReleaseStuckBodyLock() {
|
|
15
|
+
React.useEffect(() => {
|
|
16
|
+
return () => {
|
|
17
|
+
setTimeout(() => {
|
|
18
|
+
const hasOpenLayer = document.querySelector(
|
|
19
|
+
'[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"], [data-radix-popper-content-wrapper]',
|
|
20
|
+
);
|
|
21
|
+
if (!hasOpenLayer && document.body.style.pointerEvents === "none") {
|
|
22
|
+
document.body.style.pointerEvents = "";
|
|
23
|
+
document.body.style.overflow = "";
|
|
24
|
+
document.body.removeAttribute("data-scroll-locked");
|
|
25
|
+
}
|
|
26
|
+
}, 0);
|
|
27
|
+
};
|
|
28
|
+
}, []);
|
|
29
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export interface ParsedCCCD {
|
|
2
|
+
idNumber: string
|
|
3
|
+
oldIdNumber: string
|
|
4
|
+
name: string
|
|
5
|
+
birthday: string // yyyy-MM-dd
|
|
6
|
+
gender: string // 'male' | 'female' | 'other'
|
|
7
|
+
address: string
|
|
8
|
+
issueDate: string // yyyy-MM-dd
|
|
9
|
+
raw: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseCCCD(rawData: string): ParsedCCCD | null {
|
|
13
|
+
try {
|
|
14
|
+
if (!rawData || typeof rawData !== "string") return null
|
|
15
|
+
|
|
16
|
+
// Định dạng phổ biến: 12Số|9Số|Họ Tên|ddMMyyyy|Giới tính|Địa chỉ|ddMMyyyy
|
|
17
|
+
const parts = rawData.split("|")
|
|
18
|
+
|
|
19
|
+
// Một vài thẻ có thể thiếu số CMND cũ nên mảng độ dài từ 6 hoặc 7
|
|
20
|
+
if (parts.length < 6) return null
|
|
21
|
+
|
|
22
|
+
// Dữ liệu tuỳ biến theo index có thể lệch nếu không có oldIdNumber (tuy nhiên thẻ chuẩn VN luôn có hoặc bỏ trống field)
|
|
23
|
+
// Format chuẩn nhất (7 parts): [0]số CCCD, [1]số CMND, [2]Họ Tên, [3]Ngày sinh, [4]Giới tính, [5]Địa chỉ, [6]Ngày cấp
|
|
24
|
+
const idNumber = parts[0] || ""
|
|
25
|
+
const oldIdNumber = parts[1] || ""
|
|
26
|
+
const name = parts[2] || ""
|
|
27
|
+
|
|
28
|
+
// Convert ddMMyyyy -> yyyy-MM-dd
|
|
29
|
+
const rawDob = parts[3] || ""
|
|
30
|
+
let birthday = ""
|
|
31
|
+
if (rawDob.length === 8) {
|
|
32
|
+
birthday = `${rawDob.substring(4, 8)}-${rawDob.substring(2, 4)}-${rawDob.substring(0, 2)}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rawGender = parts[4] || ""
|
|
36
|
+
let gender = "other"
|
|
37
|
+
if (rawGender.toLowerCase() === "nam") gender = "male"
|
|
38
|
+
if (rawGender.toLowerCase() === "nữ" || rawGender.toLowerCase() === "nu") gender = "female"
|
|
39
|
+
|
|
40
|
+
const address = parts[5] || ""
|
|
41
|
+
|
|
42
|
+
const rawIssueDate = parts[6] || ""
|
|
43
|
+
let issueDate = ""
|
|
44
|
+
if (rawIssueDate.length === 8) {
|
|
45
|
+
issueDate = `${rawIssueDate.substring(4, 8)}-${rawIssueDate.substring(2, 4)}-${rawIssueDate.substring(0, 2)}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
idNumber,
|
|
50
|
+
oldIdNumber,
|
|
51
|
+
name,
|
|
52
|
+
birthday,
|
|
53
|
+
gender,
|
|
54
|
+
address,
|
|
55
|
+
issueDate,
|
|
56
|
+
raw: rawData
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error("Failed to parse CCCD:", error)
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date/Time utility functions for Vietnam timezone (GMT+7)
|
|
3
|
+
* Always formats dates/times according to Asia/Ho_Chi_Minh timezone
|
|
4
|
+
* regardless of server timezone
|
|
5
|
+
*
|
|
6
|
+
* Standard formats:
|
|
7
|
+
* - Date only: dd/MM/yyyy (e.g. 03/02/2026)
|
|
8
|
+
* - Date + time: dd/MM/yyyy HH:mm (e.g. 03/02/2026 08:05)
|
|
9
|
+
* - Full datetime: dd/MM/yyyy HH:mm:ss (e.g. 03/02/2026 08:05:09)
|
|
10
|
+
* - Time only: HH:mm (e.g. 08:05)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const VIETNAM_TIMEZONE = "Asia/Ho_Chi_Minh"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Internal helper: extract date/time parts in Vietnam timezone
|
|
17
|
+
*/
|
|
18
|
+
function getVietnamParts(date: Date) {
|
|
19
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
20
|
+
timeZone: VIETNAM_TIMEZONE,
|
|
21
|
+
year: "numeric",
|
|
22
|
+
month: "2-digit",
|
|
23
|
+
day: "2-digit",
|
|
24
|
+
hour: "2-digit",
|
|
25
|
+
minute: "2-digit",
|
|
26
|
+
second: "2-digit",
|
|
27
|
+
hour12: false,
|
|
28
|
+
}).formatToParts(date)
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
year: parts.find((p) => p.type === "year")?.value ?? "0000",
|
|
32
|
+
month: parts.find((p) => p.type === "month")?.value ?? "00",
|
|
33
|
+
day: parts.find((p) => p.type === "day")?.value ?? "00",
|
|
34
|
+
hour: parts.find((p) => p.type === "hour")?.value ?? "00",
|
|
35
|
+
minute: parts.find((p) => p.type === "minute")?.value ?? "00",
|
|
36
|
+
second: parts.find((p) => p.type === "second")?.value ?? "00",
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Safely convert input to Date object
|
|
42
|
+
*/
|
|
43
|
+
function toDateObj(date: Date | string): Date {
|
|
44
|
+
return typeof date === "string" ? new Date(date) : date
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get current date/time in Vietnam timezone
|
|
49
|
+
*/
|
|
50
|
+
export function getVietnamNow(): Date {
|
|
51
|
+
const p = getVietnamParts(new Date())
|
|
52
|
+
const dateString = `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}+07:00`
|
|
53
|
+
return new Date(dateString)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Format time in Vietnam timezone
|
|
58
|
+
* Output: HH:mm (e.g. 08:05)
|
|
59
|
+
*/
|
|
60
|
+
export function formatVietnamTime(date: Date | string): string {
|
|
61
|
+
const p = getVietnamParts(toDateObj(date))
|
|
62
|
+
return `${p.hour}:${p.minute}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Format date in Vietnam timezone
|
|
67
|
+
* Output: dd/MM/yyyy (e.g. 03/02/2026)
|
|
68
|
+
*/
|
|
69
|
+
export function formatVietnamDate(date: Date | string): string {
|
|
70
|
+
const p = getVietnamParts(toDateObj(date))
|
|
71
|
+
return `${p.day}/${p.month}/${p.year}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Format date and time in Vietnam timezone
|
|
76
|
+
* Output: dd/MM/yyyy HH:mm (e.g. 03/02/2026 08:05)
|
|
77
|
+
*/
|
|
78
|
+
export function formatVietnamDateTime(date: Date | string): string {
|
|
79
|
+
const p = getVietnamParts(toDateObj(date))
|
|
80
|
+
return `${p.day}/${p.month}/${p.year} ${p.hour}:${p.minute}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Format date and time with seconds in Vietnam timezone
|
|
85
|
+
* Output: dd/MM/yyyy HH:mm:ss (e.g. 03/02/2026 08:05:09)
|
|
86
|
+
*/
|
|
87
|
+
export function formatVietnamDateTimeWithSeconds(date: Date | string): string {
|
|
88
|
+
const p = getVietnamParts(toDateObj(date))
|
|
89
|
+
return `${p.day}/${p.month}/${p.year} ${p.hour}:${p.minute}:${p.second}`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Format time for date-fns with Vietnam timezone
|
|
94
|
+
* Returns date object adjusted to Vietnam timezone for use with date-fns format()
|
|
95
|
+
* @deprecated Use formatVietnamDate/DateTime/Time instead for display
|
|
96
|
+
*/
|
|
97
|
+
export function getVietnamDateForFormat(date: Date | string): Date {
|
|
98
|
+
const dateObj = toDateObj(date)
|
|
99
|
+
|
|
100
|
+
// Get timezone offset difference
|
|
101
|
+
const utcTime = dateObj.getTime()
|
|
102
|
+
const vietnamOffset = 7 * 60 * 60 * 1000 // GMT+7 in milliseconds
|
|
103
|
+
const serverOffset = dateObj.getTimezoneOffset() * 60 * 1000
|
|
104
|
+
|
|
105
|
+
// Adjust to Vietnam timezone
|
|
106
|
+
const vietnamTime = utcTime + vietnamOffset - serverOffset
|
|
107
|
+
return new Date(vietnamTime)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get date prefix in VN timezone (YYMMDD)
|
|
112
|
+
* @param date optional date, default is now
|
|
113
|
+
*/
|
|
114
|
+
export function getVNDatePrefix(date: Date = new Date()): string {
|
|
115
|
+
const p = getVietnamParts(date)
|
|
116
|
+
return `${p.year.slice(-2)}${p.month}${p.day}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get filename-safe timestamp in VN timezone (yyyyMMdd_HHmm)
|
|
121
|
+
* Used for generating export filenames
|
|
122
|
+
* @param date optional date, default is now
|
|
123
|
+
*/
|
|
124
|
+
export function getVietnamFilenameTimestamp(date: Date = new Date()): string {
|
|
125
|
+
const p = getVietnamParts(date)
|
|
126
|
+
return `${p.year}${p.month}${p.day}_${p.hour}${p.minute}`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Lấy mốc giới hạn giờ của Việt Nam (UTC+7) cho một ngày bất kỳ (từ 00:00:00.000 đến 23:59:59.999),
|
|
131
|
+
* đảm bảo Prisma an toàn khỏi việc lùi múi giờ server
|
|
132
|
+
* @param dateStringOrDate chuỗi ngày dạng "yyyy-MM-dd" hoặc Date object
|
|
133
|
+
*/
|
|
134
|
+
export function getVietnamDayBounds(dateStringOrDate: string | Date | undefined): { start: Date; end: Date } | undefined {
|
|
135
|
+
if (!dateStringOrDate) return undefined;
|
|
136
|
+
|
|
137
|
+
let year, month, day;
|
|
138
|
+
if (typeof dateStringOrDate === 'string' && dateStringOrDate.includes('-')) {
|
|
139
|
+
const parts = dateStringOrDate.split('T')[0].split('-');
|
|
140
|
+
year = parts[0];
|
|
141
|
+
month = parts[1];
|
|
142
|
+
day = parts[2];
|
|
143
|
+
} else {
|
|
144
|
+
// Nếu truyền vào Date, parse lại mốc "nguyên ngày" ở giờ máy chủ
|
|
145
|
+
const p = getVietnamParts(toDateObj(dateStringOrDate));
|
|
146
|
+
year = p.year;
|
|
147
|
+
month = p.month;
|
|
148
|
+
day = p.day;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Nửa đêm và cuối ngày ở múi giờ Việt Nam (+07:00)
|
|
152
|
+
// Khi server nhận chuỗi này, Date object sẽ map chuẩn xác đến UTC milliseconds cụ thể.
|
|
153
|
+
const start = new Date(`${year}-${month}-${day}T00:00:00.000+07:00`);
|
|
154
|
+
const end = new Date(`${year}-${month}-${day}T23:59:59.999+07:00`);
|
|
155
|
+
|
|
156
|
+
return { start, end };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Lấy chuỗi định dạng "yyyy-MM-dd" cho ngày đầu tháng và cuối tháng
|
|
161
|
+
* Dựa trên giờ cố định của Việt Nam (tránh lùi/tiến ngày khi server lệch múi giờ)
|
|
162
|
+
*/
|
|
163
|
+
export function getVietnamMonthBoundsString(): { from: string; to: string } {
|
|
164
|
+
const p = getVietnamParts(new Date());
|
|
165
|
+
const year = parseInt(p.year, 10);
|
|
166
|
+
const month = parseInt(p.month, 10); // 1-12
|
|
167
|
+
|
|
168
|
+
// Tính ngày cuối tháng bằng cách khai báo mốc ngày "0" của tháng tiếp theo
|
|
169
|
+
const lastDay = new Date(year, month, 0).getDate();
|
|
170
|
+
|
|
171
|
+
const from = `${p.year}-${p.month}-01`;
|
|
172
|
+
const to = `${p.year}-${p.month}-${lastDay.toString().padStart(2, '0')}`;
|
|
173
|
+
|
|
174
|
+
return { from, to };
|
|
175
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetcher dùng chung cho SWR + helper bóc danh sách từ response API.
|
|
3
|
+
*
|
|
4
|
+
* Trước đây mỗi nơi tự viết `(url) => fetch(url).then(r => r.json())` KHÔNG check
|
|
5
|
+
* `res.ok`, nên khi API lỗi (4xx/5xx) SWR vẫn coi là "thành công" với data rỗng/
|
|
6
|
+
* `{error}` thay vì vào trạng thái error. Fetcher này throw khi `!res.ok` để SWR
|
|
7
|
+
* xử lý đúng, và tự bóc các shape `{items}` / `{data}` / mảng thuần.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Bóc danh sách từ nhiều shape response: `{items}` | `{data}` | mảng | []. */
|
|
11
|
+
export function unwrapList<T = unknown>(data: unknown): T[] {
|
|
12
|
+
if (Array.isArray(data)) return data as T[]
|
|
13
|
+
if (data && typeof data === "object") {
|
|
14
|
+
const obj = data as Record<string, unknown>
|
|
15
|
+
if (Array.isArray(obj.items)) return obj.items as T[]
|
|
16
|
+
if (Array.isArray(obj.data)) return obj.data as T[]
|
|
17
|
+
}
|
|
18
|
+
return []
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* SWR fetcher chuẩn: throw khi response không ok, trả JSON đã parse.
|
|
23
|
+
* Trả `any` để giữ đúng hành vi cũ (code tự bóc `.data`/`.items` sau đó) và để
|
|
24
|
+
* SWR suy luận `data` là `any` thay vì `{}`. Muốn an toàn kiểu thì khai báo ở
|
|
25
|
+
* lời gọi useSWR: `useSWR<MyType>(key, swrFetcher)`.
|
|
26
|
+
*/
|
|
27
|
+
export async function swrFetcher(url: string): Promise<any> {
|
|
28
|
+
const res = await fetch(url)
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
let message = `Request failed: ${res.status}`
|
|
31
|
+
try {
|
|
32
|
+
const body = await res.json()
|
|
33
|
+
if (body?.error || body?.message) message = body.error || body.message
|
|
34
|
+
} catch {
|
|
35
|
+
// body không phải JSON — giữ message mặc định
|
|
36
|
+
}
|
|
37
|
+
throw new Error(message)
|
|
38
|
+
}
|
|
39
|
+
return res.json()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** SWR fetcher trả về MẢNG đã bóc shape (kèm check res.ok). */
|
|
43
|
+
export async function swrListFetcher<T = unknown>(url: string): Promise<T[]> {
|
|
44
|
+
const data = await swrFetcher(url)
|
|
45
|
+
return unwrapList<T>(data)
|
|
46
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursively serialize Prisma Decimal objects to strings
|
|
3
|
+
* This is needed because Decimal objects cannot be passed to Client Components
|
|
4
|
+
* @param obj - The object to serialize
|
|
5
|
+
* @returns Serialized object with Decimal fields converted to strings
|
|
6
|
+
*/
|
|
7
|
+
export function serializeDecimalFields<T>(obj: T): T {
|
|
8
|
+
if (obj === null || obj === undefined) {
|
|
9
|
+
return obj
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Use native JSON serialization loop for performance.
|
|
13
|
+
// The recursive JS for-in loop causes blocking on the event loop for large objects (e.g list of orders)
|
|
14
|
+
try {
|
|
15
|
+
const serialized = JSON.stringify(obj, function (key, value) {
|
|
16
|
+
if (value !== null && typeof value === "object") {
|
|
17
|
+
// Detect Prisma Decimal
|
|
18
|
+
const isDecimalInstance =
|
|
19
|
+
value.constructor?.name === "Decimal" ||
|
|
20
|
+
value.constructor?.name === "Decimal2" ||
|
|
21
|
+
typeof value.toNumber === "function"
|
|
22
|
+
|
|
23
|
+
if (isDecimalInstance) {
|
|
24
|
+
return value.toString()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// If it's a POJO Decimal (lost prototype), we cannot easily convert to string
|
|
28
|
+
// without the Prisma.Decimal constructor, which rejects POJOs.
|
|
29
|
+
// We leave it as is to avoid returning "[object Object]"
|
|
30
|
+
const isPojoDecimal = value.s !== undefined && value.e !== undefined && Array.isArray(value.d);
|
|
31
|
+
if (isPojoDecimal) {
|
|
32
|
+
// Attempt a basic conversion if it has only one digit block and e is positive (integers)
|
|
33
|
+
if (value.d.length === 1 && value.e >= 0) {
|
|
34
|
+
const str = value.d[0].toString();
|
|
35
|
+
// Assuming basic case for simple numbers, otherwise fallback to returning POJO
|
|
36
|
+
if (value.e === str.length - 1) return (value.s < 0 ? "-" : "") + str;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return value
|
|
41
|
+
})
|
|
42
|
+
return JSON.parse(serialized)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error("Failed to serialize decimal fields:", error)
|
|
45
|
+
return obj
|
|
46
|
+
}
|
|
47
|
+
}
|