@goplusvn/core 0.1.5 → 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/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/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,333 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react"
|
|
12
|
+
|
|
13
|
+
import { Loader2 } from "lucide-react"
|
|
14
|
+
|
|
15
|
+
import { PrintStyles } from "./print-styles"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generic, document-agnostic print device layer.
|
|
19
|
+
*
|
|
20
|
+
* Two print modes funnel through `triggerDirectPrint`:
|
|
21
|
+
* 1. URL mode `{ printUrl }` — renders the URL in a hidden iframe and prints it
|
|
22
|
+
* (with a same-tab `?autoPrint=1` fallback on mobile, where iframe printing
|
|
23
|
+
* is unreliable). This is the canonical path and is fully generic.
|
|
24
|
+
* 2. Payload mode `{ type, data }` — renders the document INLINE via the
|
|
25
|
+
* app-supplied `renderContent` render-prop. Core owns the print mechanics;
|
|
26
|
+
* the app owns what the document looks like. If `renderContent` is omitted,
|
|
27
|
+
* payload mode is a no-op (URL mode still works).
|
|
28
|
+
*
|
|
29
|
+
* Mount once near the app root and trigger from anywhere via `useDirectPrint`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export interface DirectPrintPayload {
|
|
33
|
+
type: string
|
|
34
|
+
data: unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DirectPrintUrlPayload {
|
|
38
|
+
printUrl: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface DirectPrintContextValue {
|
|
42
|
+
triggerDirectPrint: (
|
|
43
|
+
payload: DirectPrintPayload | DirectPrintUrlPayload | null
|
|
44
|
+
) => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DirectPrintContext = createContext<DirectPrintContextValue | null>(null)
|
|
48
|
+
|
|
49
|
+
export function useDirectPrint(): DirectPrintContextValue {
|
|
50
|
+
const ctx = useContext(DirectPrintContext)
|
|
51
|
+
if (!ctx) {
|
|
52
|
+
return {
|
|
53
|
+
triggerDirectPrint: () => {
|
|
54
|
+
// No-op when used outside provider
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return ctx
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** ~297mm A4 at 96dpi. Only used for payload printing (not iframe printing). */
|
|
62
|
+
const ONE_PAGE_HEIGHT_PX = 1300
|
|
63
|
+
const ONE_PAGE_LIMIT_STYLE_ID = "direct-print-one-page-limit"
|
|
64
|
+
|
|
65
|
+
function isMobilePrintEnvironment(): boolean {
|
|
66
|
+
if (typeof navigator === "undefined") return false
|
|
67
|
+
const ua = navigator.userAgent || ""
|
|
68
|
+
// iPadOS may report as Macintosh with touch points
|
|
69
|
+
const isIpadOs = /Macintosh/.test(ua) && navigator.maxTouchPoints > 1
|
|
70
|
+
return /Android|iPhone|iPad|iPod/i.test(ua) || isIpadOs
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function appendQueryParam(url: string, key: string, value: string): string {
|
|
74
|
+
const hasQuery = url.includes("?")
|
|
75
|
+
const hasHash = url.includes("#")
|
|
76
|
+
if (hasHash) {
|
|
77
|
+
const [base, hash] = url.split("#")
|
|
78
|
+
const sep = hasQuery ? "&" : "?"
|
|
79
|
+
return `${base}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}#${hash}`
|
|
80
|
+
}
|
|
81
|
+
const sep = hasQuery ? "&" : "?"
|
|
82
|
+
return `${url}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isPrintUrlPayload(
|
|
86
|
+
v: DirectPrintPayload | DirectPrintUrlPayload
|
|
87
|
+
): v is DirectPrintUrlPayload {
|
|
88
|
+
return (
|
|
89
|
+
"printUrl" in v && typeof (v as DirectPrintUrlPayload).printUrl === "string"
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface DirectPrintProviderProps {
|
|
94
|
+
children: ReactNode
|
|
95
|
+
/**
|
|
96
|
+
* Render the document for payload-mode prints (`{ type, data }`). Omit if the
|
|
97
|
+
* app only ever prints via URL mode.
|
|
98
|
+
*/
|
|
99
|
+
renderContent?: (payload: DirectPrintPayload) => ReactNode
|
|
100
|
+
/** Loading overlay text (defaults to Vietnamese). */
|
|
101
|
+
loadingText?: string
|
|
102
|
+
loadingSubText?: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function DirectPrintProvider({
|
|
106
|
+
children,
|
|
107
|
+
renderContent,
|
|
108
|
+
loadingText = "Đang chuẩn bị trang in...",
|
|
109
|
+
loadingSubText = "Vui lòng đợi trong giây lát",
|
|
110
|
+
}: DirectPrintProviderProps) {
|
|
111
|
+
const [payload, setPayload] = useState<DirectPrintPayload | null>(null)
|
|
112
|
+
const [printUrlPending, setPrintUrlPending] = useState<string | null>(null)
|
|
113
|
+
const afterprintRef = useRef<(() => void) | null>(null)
|
|
114
|
+
const fallbackRef = useRef<number | null>(null)
|
|
115
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null)
|
|
116
|
+
|
|
117
|
+
const triggerDirectPrint = useCallback(
|
|
118
|
+
(value: DirectPrintPayload | DirectPrintUrlPayload | null) => {
|
|
119
|
+
if (!value) {
|
|
120
|
+
setPayload(null)
|
|
121
|
+
setPrintUrlPending(null)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (isPrintUrlPayload(value)) {
|
|
125
|
+
setPayload(null)
|
|
126
|
+
setPrintUrlPending(value.printUrl)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
setPrintUrlPending(null)
|
|
130
|
+
setPayload(value)
|
|
131
|
+
},
|
|
132
|
+
[]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (!printUrlPending) return
|
|
137
|
+
|
|
138
|
+
// Mobile browsers (esp. iOS Safari) often can't print hidden iframes reliably.
|
|
139
|
+
// Fallback: navigate to the print URL in the same tab and let that page auto-print.
|
|
140
|
+
if (isMobilePrintEnvironment()) {
|
|
141
|
+
const urlWithAutoPrint = appendQueryParam(
|
|
142
|
+
printUrlPending,
|
|
143
|
+
"autoPrint",
|
|
144
|
+
"1"
|
|
145
|
+
)
|
|
146
|
+
window.location.assign(urlWithAutoPrint)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const url =
|
|
151
|
+
printUrlPending.startsWith("http") || printUrlPending.startsWith("//")
|
|
152
|
+
? printUrlPending
|
|
153
|
+
: `${typeof window !== "undefined" ? window.location.origin : ""}${printUrlPending}`
|
|
154
|
+
|
|
155
|
+
const iframe = document.createElement("iframe")
|
|
156
|
+
iframe.setAttribute(
|
|
157
|
+
"style",
|
|
158
|
+
"position:fixed;left:-9999px;top:0;width:210mm;height:297mm;border:none;visibility:hidden;"
|
|
159
|
+
)
|
|
160
|
+
document.body.appendChild(iframe)
|
|
161
|
+
iframeRef.current = iframe
|
|
162
|
+
|
|
163
|
+
let fallbackTimer: ReturnType<typeof setTimeout> | null = null
|
|
164
|
+
let isCleanedUp = false
|
|
165
|
+
|
|
166
|
+
const cleanup = () => {
|
|
167
|
+
if (isCleanedUp) return
|
|
168
|
+
isCleanedUp = true
|
|
169
|
+
if (iframe.parentNode) {
|
|
170
|
+
iframe.parentNode.removeChild(iframe)
|
|
171
|
+
}
|
|
172
|
+
if (iframeRef.current === iframe) {
|
|
173
|
+
iframeRef.current = null
|
|
174
|
+
}
|
|
175
|
+
setPrintUrlPending(null)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const onLoad = () => {
|
|
179
|
+
const win = iframe.contentWindow
|
|
180
|
+
if (!win) {
|
|
181
|
+
cleanup()
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const onAfterPrint = () => {
|
|
186
|
+
win.removeEventListener("afterprint", onAfterPrint)
|
|
187
|
+
cleanup()
|
|
188
|
+
}
|
|
189
|
+
win.addEventListener("afterprint", onAfterPrint)
|
|
190
|
+
|
|
191
|
+
// Delay longer to let Chrome fully render DOM inside iframe
|
|
192
|
+
setTimeout(() => {
|
|
193
|
+
try {
|
|
194
|
+
win.focus()
|
|
195
|
+
win.print()
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.error("Popup Error:", err)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Firefox/Safari fallback (if afterprint doesn't fire)
|
|
201
|
+
fallbackTimer = setTimeout(cleanup, 3000)
|
|
202
|
+
}, 500)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
iframe.addEventListener("load", onLoad, { once: true })
|
|
206
|
+
iframe.src = url
|
|
207
|
+
|
|
208
|
+
return () => {
|
|
209
|
+
iframe.removeEventListener("load", onLoad)
|
|
210
|
+
if (fallbackTimer) clearTimeout(fallbackTimer)
|
|
211
|
+
if (iframe.parentNode) iframe.parentNode.removeChild(iframe)
|
|
212
|
+
if (iframeRef.current === iframe) {
|
|
213
|
+
iframeRef.current = null
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}, [printUrlPending])
|
|
217
|
+
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
if (!payload) return
|
|
220
|
+
|
|
221
|
+
const clearPayload = () => {
|
|
222
|
+
setPayload(null)
|
|
223
|
+
afterprintRef.current = null
|
|
224
|
+
document.getElementById(ONE_PAGE_LIMIT_STYLE_ID)?.remove()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
afterprintRef.current = clearPayload
|
|
228
|
+
|
|
229
|
+
const handleAfterPrint = () => {
|
|
230
|
+
if (fallbackRef.current) {
|
|
231
|
+
clearTimeout(fallbackRef.current)
|
|
232
|
+
fallbackRef.current = null
|
|
233
|
+
}
|
|
234
|
+
document.getElementById(ONE_PAGE_LIMIT_STYLE_ID)?.remove()
|
|
235
|
+
afterprintRef.current?.()
|
|
236
|
+
window.removeEventListener("afterprint", handleAfterPrint)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
window.addEventListener("afterprint", handleAfterPrint)
|
|
240
|
+
|
|
241
|
+
const timer = setTimeout(() => {
|
|
242
|
+
const root = document.getElementById("direct-print-root")
|
|
243
|
+
const printContent = document.getElementById("print-content")
|
|
244
|
+
root?.offsetHeight
|
|
245
|
+
printContent?.offsetHeight
|
|
246
|
+
const contentHeight =
|
|
247
|
+
printContent?.scrollHeight ?? root?.scrollHeight ?? 0
|
|
248
|
+
const isSinglePage =
|
|
249
|
+
contentHeight > 0 && contentHeight <= ONE_PAGE_HEIGHT_PX
|
|
250
|
+
if (isSinglePage) {
|
|
251
|
+
const style = document.createElement("style")
|
|
252
|
+
style.id = ONE_PAGE_LIMIT_STYLE_ID
|
|
253
|
+
style.textContent = `@media print { html, body { height: 297mm !important; max-height: 297mm !important; overflow: hidden !important; } }`
|
|
254
|
+
document.head.appendChild(style)
|
|
255
|
+
}
|
|
256
|
+
window.print()
|
|
257
|
+
fallbackRef.current = window.setTimeout(clearPayload, 1000)
|
|
258
|
+
}, 150)
|
|
259
|
+
|
|
260
|
+
return () => {
|
|
261
|
+
clearTimeout(timer)
|
|
262
|
+
if (fallbackRef.current) clearTimeout(fallbackRef.current)
|
|
263
|
+
window.removeEventListener("afterprint", handleAfterPrint)
|
|
264
|
+
document.getElementById(ONE_PAGE_LIMIT_STYLE_ID)?.remove()
|
|
265
|
+
}
|
|
266
|
+
}, [payload])
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<DirectPrintContext.Provider value={{ triggerDirectPrint }}>
|
|
270
|
+
{children}
|
|
271
|
+
|
|
272
|
+
{/* Screen: hide off-screen. Print: show only #direct-print-root. */}
|
|
273
|
+
<style
|
|
274
|
+
dangerouslySetInnerHTML={{
|
|
275
|
+
__html: [
|
|
276
|
+
"@media screen { #direct-print-root { position: fixed !important; left: -9999px !important; top: 0 !important; width: 210mm !important; max-width: 100% !important; z-index: -1 !important; visibility: hidden !important; pointer-events: none !important; } }",
|
|
277
|
+
"@media print { #direct-print-root { visibility: visible !important; position: absolute !important; left: 0 !important; top: 0 !important; width: 100% !important; z-index: 9999 !important; pointer-events: auto !important; } }",
|
|
278
|
+
].join(" "),
|
|
279
|
+
}}
|
|
280
|
+
/>
|
|
281
|
+
|
|
282
|
+
<div
|
|
283
|
+
id="direct-print-root"
|
|
284
|
+
aria-hidden="true"
|
|
285
|
+
className="print-container"
|
|
286
|
+
>
|
|
287
|
+
{payload && renderContent && (
|
|
288
|
+
<>
|
|
289
|
+
<PrintStyles />
|
|
290
|
+
<div id="print-content">{renderContent(payload)}</div>
|
|
291
|
+
</>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
{/* Loading Overlay */}
|
|
296
|
+
{(printUrlPending || payload) && (
|
|
297
|
+
<div
|
|
298
|
+
className="print-loading-overlay"
|
|
299
|
+
style={{
|
|
300
|
+
position: "fixed",
|
|
301
|
+
top: 0,
|
|
302
|
+
left: 0,
|
|
303
|
+
right: 0,
|
|
304
|
+
bottom: 0,
|
|
305
|
+
backgroundColor: "rgba(255, 255, 255, 0.7)",
|
|
306
|
+
zIndex: 99999,
|
|
307
|
+
display: "flex",
|
|
308
|
+
flexDirection: "column",
|
|
309
|
+
alignItems: "center",
|
|
310
|
+
justifyContent: "center",
|
|
311
|
+
color: "#1e293b",
|
|
312
|
+
}}
|
|
313
|
+
>
|
|
314
|
+
<div className="bg-white p-6 rounded-2xl shadow-xl flex flex-col items-center gap-4">
|
|
315
|
+
<Loader2 className="w-10 h-10 animate-spin text-blue-600" />
|
|
316
|
+
<div className="text-sm font-semibold text-slate-700">
|
|
317
|
+
{loadingText}
|
|
318
|
+
</div>
|
|
319
|
+
<div className="text-xs text-slate-500">{loadingSubText}</div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
|
|
324
|
+
{/* Hide Overlay while printing from same window */}
|
|
325
|
+
<style
|
|
326
|
+
dangerouslySetInnerHTML={{
|
|
327
|
+
__html:
|
|
328
|
+
"@media print { .print-loading-overlay { display: none !important; visibility: hidden !important; } }",
|
|
329
|
+
}}
|
|
330
|
+
/>
|
|
331
|
+
</DirectPrintContext.Provider>
|
|
332
|
+
)
|
|
333
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DirectPrintProvider,
|
|
3
|
+
useDirectPrint,
|
|
4
|
+
} from "./direct-print-provider"
|
|
5
|
+
export type {
|
|
6
|
+
DirectPrintPayload,
|
|
7
|
+
DirectPrintUrlPayload,
|
|
8
|
+
DirectPrintProviderProps,
|
|
9
|
+
} from "./direct-print-provider"
|
|
10
|
+
export { PrintStyles } from "./print-styles"
|
|
11
|
+
export type { PrintStylesProps } from "./print-styles"
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
export interface PrintStylesProps {
|
|
4
|
+
pageSize?: "A4" | "A5-Portrait" | "A5-Landscape"
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function PrintStyles({ pageSize = "A4" }: PrintStylesProps) {
|
|
8
|
+
// A5 Portrait: 148.5mm x 210mm
|
|
9
|
+
// A5 Landscape: 210mm x 148.5mm
|
|
10
|
+
// A4: 210mm x 297mm
|
|
11
|
+
|
|
12
|
+
const getPageConfig = () => {
|
|
13
|
+
switch (pageSize) {
|
|
14
|
+
case "A5-Portrait":
|
|
15
|
+
return {
|
|
16
|
+
size: "A5",
|
|
17
|
+
maxWidth: "148.5mm", // width
|
|
18
|
+
padding: "10mm 8mm", // increased top padding from 5mm
|
|
19
|
+
fontSize: "9.5pt",
|
|
20
|
+
}
|
|
21
|
+
case "A5-Landscape":
|
|
22
|
+
return {
|
|
23
|
+
size: "A5 landscape",
|
|
24
|
+
maxWidth: "210mm", // width
|
|
25
|
+
padding: "12mm 12mm", // increased top padding from 8mm
|
|
26
|
+
fontSize: "10pt",
|
|
27
|
+
}
|
|
28
|
+
case "A4":
|
|
29
|
+
default:
|
|
30
|
+
return {
|
|
31
|
+
size: "A4",
|
|
32
|
+
maxWidth: "210mm",
|
|
33
|
+
padding: "12mm 12mm", // increased top padding from 8mm
|
|
34
|
+
fontSize: "10pt",
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const config = getPageConfig()
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<style dangerouslySetInnerHTML={{ __html: `
|
|
43
|
+
@media print {
|
|
44
|
+
body {
|
|
45
|
+
visibility: hidden;
|
|
46
|
+
}
|
|
47
|
+
.no-print {
|
|
48
|
+
display: none !important;
|
|
49
|
+
}
|
|
50
|
+
.print-container,
|
|
51
|
+
#print-content {
|
|
52
|
+
visibility: visible;
|
|
53
|
+
position: absolute;
|
|
54
|
+
left: 0;
|
|
55
|
+
top: 2mm; /* Added a small offset from the literal top of the page */
|
|
56
|
+
width: 100%;
|
|
57
|
+
padding: ${config.padding};
|
|
58
|
+
font-size: ${config.fontSize};
|
|
59
|
+
line-height: 1.25;
|
|
60
|
+
}
|
|
61
|
+
@page {
|
|
62
|
+
size: ${config.size};
|
|
63
|
+
margin: 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.print-container,
|
|
68
|
+
#print-content {
|
|
69
|
+
max-width: ${config.maxWidth};
|
|
70
|
+
margin: 0 auto;
|
|
71
|
+
padding: 20px;
|
|
72
|
+
font-family: "Times New Roman", serif;
|
|
73
|
+
background: white;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* Base layout */
|
|
77
|
+
${pageSize === "A5-Portrait" ? `
|
|
78
|
+
.header-top h3 { font-size: 9pt; }
|
|
79
|
+
.header-top p { font-size: 8pt; }
|
|
80
|
+
table th, table td { font-size: 8.5pt; padding: 2px 3px; }
|
|
81
|
+
.info-label { min-width: 100px; }
|
|
82
|
+
.voucher-title { font-size: 14pt; margin: 5px 0; }
|
|
83
|
+
` : `
|
|
84
|
+
.header-top h3 { font-size: 10pt; }
|
|
85
|
+
.header-top p { font-size: 9pt; }
|
|
86
|
+
table th, table td { font-size: 10pt; padding: 3px 5px; }
|
|
87
|
+
.info-label { min-width: 120px; }
|
|
88
|
+
`}
|
|
89
|
+
|
|
90
|
+
/* Header & Title */
|
|
91
|
+
.header {
|
|
92
|
+
text-align: center;
|
|
93
|
+
margin-bottom: 10px;
|
|
94
|
+
}
|
|
95
|
+
.header-top {
|
|
96
|
+
text-align: center;
|
|
97
|
+
margin-bottom: 5px;
|
|
98
|
+
}
|
|
99
|
+
.header-top h3 {
|
|
100
|
+
margin: 0;
|
|
101
|
+
text-transform: uppercase;
|
|
102
|
+
font-weight: bold;
|
|
103
|
+
}
|
|
104
|
+
.header-top p {
|
|
105
|
+
margin: 2px 0 0 0;
|
|
106
|
+
font-weight: bold;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* Contract specific */
|
|
110
|
+
.contract-title {
|
|
111
|
+
text-align: center;
|
|
112
|
+
font-weight: bold;
|
|
113
|
+
font-size: 13pt;
|
|
114
|
+
margin: 10px 0;
|
|
115
|
+
}
|
|
116
|
+
.contract-number {
|
|
117
|
+
text-align: center;
|
|
118
|
+
margin-bottom: 10px;
|
|
119
|
+
font-style: italic;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Content sections */
|
|
123
|
+
.section-content {
|
|
124
|
+
margin-bottom: 8px;
|
|
125
|
+
}
|
|
126
|
+
.info-row {
|
|
127
|
+
margin-bottom: 3px;
|
|
128
|
+
}
|
|
129
|
+
.info-label {
|
|
130
|
+
font-weight: bold;
|
|
131
|
+
display: inline-block;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* Tables */
|
|
135
|
+
table {
|
|
136
|
+
width: 100%;
|
|
137
|
+
border-collapse: collapse;
|
|
138
|
+
margin: 5px 0;
|
|
139
|
+
}
|
|
140
|
+
table th,
|
|
141
|
+
table td {
|
|
142
|
+
border: 1px solid #000;
|
|
143
|
+
}
|
|
144
|
+
table th {
|
|
145
|
+
text-align: center;
|
|
146
|
+
font-weight: bold;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Text utilities */
|
|
150
|
+
.text-right {
|
|
151
|
+
text-align: right;
|
|
152
|
+
}
|
|
153
|
+
.text-center {
|
|
154
|
+
text-align: center;
|
|
155
|
+
}
|
|
156
|
+
.text-bold {
|
|
157
|
+
font-weight: bold;
|
|
158
|
+
}
|
|
159
|
+
.amount-in-words {
|
|
160
|
+
margin: 5px 0;
|
|
161
|
+
font-style: italic;
|
|
162
|
+
font-weight: bold;
|
|
163
|
+
}
|
|
164
|
+
.d-flex-between {
|
|
165
|
+
display: flex;
|
|
166
|
+
justify-content: space-between;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* Signature section */
|
|
170
|
+
.signature-section {
|
|
171
|
+
margin-top: 20px;
|
|
172
|
+
display: flex;
|
|
173
|
+
justify-content: space-between;
|
|
174
|
+
}
|
|
175
|
+
.signature-box {
|
|
176
|
+
width: 45%;
|
|
177
|
+
text-align: center;
|
|
178
|
+
}
|
|
179
|
+
.signature-box h4 {
|
|
180
|
+
margin: 0 0 40px 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Company header */
|
|
184
|
+
.company-header {
|
|
185
|
+
font-size: 9pt;
|
|
186
|
+
font-weight: normal;
|
|
187
|
+
}
|
|
188
|
+
.company-header .company-name {
|
|
189
|
+
text-transform: uppercase;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* Template header right (mẫu số) */
|
|
193
|
+
.template-code {
|
|
194
|
+
font-weight: bold;
|
|
195
|
+
font-size: 11pt;
|
|
196
|
+
}
|
|
197
|
+
.template-note {
|
|
198
|
+
font-style: italic;
|
|
199
|
+
font-size: 10pt;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* Voucher title */
|
|
203
|
+
.voucher-title {
|
|
204
|
+
margin: 10px 0;
|
|
205
|
+
font-weight: bold;
|
|
206
|
+
text-align: center;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* Payment method row */
|
|
210
|
+
.payment-method-row {
|
|
211
|
+
display: flex;
|
|
212
|
+
justify-content: center;
|
|
213
|
+
gap: 30px;
|
|
214
|
+
font-weight: bold;
|
|
215
|
+
font-size: 11pt;
|
|
216
|
+
margin-bottom: 10px;
|
|
217
|
+
}
|
|
218
|
+
.payment-method-item {
|
|
219
|
+
display: flex;
|
|
220
|
+
align-items: center;
|
|
221
|
+
gap: 5px;
|
|
222
|
+
}
|
|
223
|
+
.payment-checkbox {
|
|
224
|
+
width: 16px;
|
|
225
|
+
height: 16px;
|
|
226
|
+
border: 1px solid #000;
|
|
227
|
+
display: flex;
|
|
228
|
+
align-items: center;
|
|
229
|
+
justify-content: center;
|
|
230
|
+
font-size: 14px;
|
|
231
|
+
}
|
|
232
|
+
`}} />
|
|
233
|
+
)
|
|
234
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare-and-swap (CAS) update — the platform invariant for money/stock
|
|
3
|
+
* mutations across GoERP apps.
|
|
4
|
+
*
|
|
5
|
+
* Instead of read-modify-write (which double-applies under a lost race), a CAS
|
|
6
|
+
* update writes ONLY when the row still matches its expected current state, via
|
|
7
|
+
* `updateMany({ where: { ...expected }, data })`. When nothing matched (a
|
|
8
|
+
* concurrent writer already moved the state, or it was stale), it throws the
|
|
9
|
+
* caller-supplied `conflictError` so an enclosing `$transaction` rolls back
|
|
10
|
+
* rather than silently corrupting a balance / stock count.
|
|
11
|
+
*
|
|
12
|
+
* Prisma-agnostic on purpose: pass any model delegate (or transaction-bound
|
|
13
|
+
* delegate) — only `updateMany` is required.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The slice of a Prisma model delegate that CAS needs. */
|
|
17
|
+
export interface CasDelegate {
|
|
18
|
+
updateMany(args: {
|
|
19
|
+
where: Record<string, unknown>
|
|
20
|
+
data: Record<string, unknown>
|
|
21
|
+
}): Promise<{ count: number }>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Run a CAS update against an already-built `where` (which must encode the
|
|
26
|
+
* expected current state). Throws `conflictError` if no row matched.
|
|
27
|
+
*/
|
|
28
|
+
export async function casUpdate<R extends { count: number }>(
|
|
29
|
+
delegate: { updateMany(args: { where: any; data: any }): Promise<R> },
|
|
30
|
+
where: Record<string, unknown>,
|
|
31
|
+
data: Record<string, unknown>,
|
|
32
|
+
conflictError: Error
|
|
33
|
+
): Promise<R> {
|
|
34
|
+
const result = await delegate.updateMany({ where, data })
|
|
35
|
+
if (result.count === 0) {
|
|
36
|
+
throw conflictError
|
|
37
|
+
}
|
|
38
|
+
return result
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The common case: CAS a single row by `id` plus an `expectedWhere` describing
|
|
43
|
+
* its expected current state (e.g. `{ paymentStatus: { not: "paid" } }`). Builds
|
|
44
|
+
* `where: { id, ...expectedWhere }` and delegates to {@link casUpdate}.
|
|
45
|
+
*/
|
|
46
|
+
export function casUpdateById<R extends { count: number }>(
|
|
47
|
+
delegate: { updateMany(args: { where: any; data: any }): Promise<R> },
|
|
48
|
+
id: string,
|
|
49
|
+
expectedWhere: Record<string, unknown>,
|
|
50
|
+
data: Record<string, unknown>,
|
|
51
|
+
conflictError: Error
|
|
52
|
+
): Promise<R> {
|
|
53
|
+
return casUpdate(delegate, { id, ...expectedWhere }, data, conflictError)
|
|
54
|
+
}
|