@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.
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export { casUpdate, casUpdateById } from "./cas-update"
2
+ export type { CasDelegate } from "./cas-update"