@apptimate/ui 5.9.0 → 6.0.0
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,673 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { getTemplateLines, lookupPrintableTemplates, getItem } from "@apptimate/core-lib";
|
|
4
|
+
import { Button, EmptyState, Modal, Input } from "../..";
|
|
5
|
+
import { CheckCircle2, FileText, Printer, Share2 } from "lucide-react";
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
|
+
import toast from "react-hot-toast";
|
|
8
|
+
import { TemplateRenderer } from "./TemplateRenderer";
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
isOpen: boolean;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
transactionType: string;
|
|
14
|
+
entityId: number | null;
|
|
15
|
+
stockContext?: any;
|
|
16
|
+
batchEntitiesData?: any[];
|
|
17
|
+
filterCode?: string;
|
|
18
|
+
autoPrintTemplateId?: number | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default function TransactionPrintSelector({ isOpen, onClose, transactionType, entityId, stockContext, batchEntitiesData, filterCode, autoPrintTemplateId }: Props) {
|
|
22
|
+
const [templates, setTemplates] = useState<any[]>([]);
|
|
23
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
24
|
+
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(null);
|
|
25
|
+
const [quantity, setQuantity] = useState(1);
|
|
26
|
+
const [entityData, setEntityData] = useState<any>(null);
|
|
27
|
+
const [isPrinting, setIsPrinting] = useState(false);
|
|
28
|
+
const [printLines, setPrintLines] = useState<any[]>([]);
|
|
29
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
30
|
+
const hiddenPrintRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
const [autoPrintTriggered, setAutoPrintTriggered] = useState(false);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!isOpen) {
|
|
35
|
+
// Reset state on close
|
|
36
|
+
setTemplates([]);
|
|
37
|
+
setEntityData(null);
|
|
38
|
+
setSelectedTemplateId(null);
|
|
39
|
+
setIsPrinting(false);
|
|
40
|
+
setAutoPrintTriggered(false);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!entityId && !batchEntitiesData) return;
|
|
44
|
+
|
|
45
|
+
const loadData = async () => {
|
|
46
|
+
setIsLoading(true);
|
|
47
|
+
try {
|
|
48
|
+
// Fetch templates directly
|
|
49
|
+
const tplRes = await lookupPrintableTemplates(transactionType);
|
|
50
|
+
let tpls: any[] = [];
|
|
51
|
+
if (tplRes.is_success) {
|
|
52
|
+
// map to format expected below, or just set it
|
|
53
|
+
let results = tplRes.result?.data || tplRes.result || [];
|
|
54
|
+
if (filterCode) {
|
|
55
|
+
const originalTemplate = results.find((t: any) => t.code === filterCode);
|
|
56
|
+
const originalId = originalTemplate?.id;
|
|
57
|
+
|
|
58
|
+
results = results.filter((t: any) =>
|
|
59
|
+
t.code === filterCode ||
|
|
60
|
+
t.code.startsWith(`${filterCode}_copy`) ||
|
|
61
|
+
(originalId && t.parent_id === originalId)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
tpls = results.map((t: any) => ({
|
|
65
|
+
id: t.id,
|
|
66
|
+
printable_template_id: t.id,
|
|
67
|
+
is_primary: t.is_default || false,
|
|
68
|
+
transaction_type: transactionType,
|
|
69
|
+
printable_template: t
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setTemplates(tpls);
|
|
74
|
+
|
|
75
|
+
if (tpls.length > 0) {
|
|
76
|
+
// Auto-select primary or first
|
|
77
|
+
const primary = tpls.find((t: any) => t.is_primary) || tpls[0];
|
|
78
|
+
setSelectedTemplateId(primary.printable_template_id);
|
|
79
|
+
// Default to template grid size (e.g. 2 for 2-column roll) to fill the row
|
|
80
|
+
setQuantity(Number(primary.printable_template?.grid) || 1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (batchEntitiesData && batchEntitiesData.length > 0) {
|
|
84
|
+
setEntityData(batchEntitiesData);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let entRes;
|
|
89
|
+
switch (transactionType) {
|
|
90
|
+
case 'item':
|
|
91
|
+
case 'barcode_label':
|
|
92
|
+
entRes = await getItem(entityId as number);
|
|
93
|
+
break;
|
|
94
|
+
// Add other inventory types here in the future
|
|
95
|
+
default:
|
|
96
|
+
entRes = await getItem(entityId as number);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (entRes && entRes.is_success) {
|
|
100
|
+
let data = entRes.result;
|
|
101
|
+
|
|
102
|
+
// Alias generic price field if missing
|
|
103
|
+
data.price = data.price || data.selling_price || data.retail_price || data.cost_price || "";
|
|
104
|
+
|
|
105
|
+
if (stockContext) {
|
|
106
|
+
const unit = stockContext.stock_unit || {};
|
|
107
|
+
const batch = unit.batch || {};
|
|
108
|
+
data = {
|
|
109
|
+
...data,
|
|
110
|
+
stock_unit_id: unit.id,
|
|
111
|
+
serial_number: unit.serial_number,
|
|
112
|
+
batch_number: batch.batch_number,
|
|
113
|
+
code: unit.serial_number || batch.batch_number || data.code, // Override code so barcode prints correct badge
|
|
114
|
+
cost_price: unit.cost_price || batch.cost_per_unit,
|
|
115
|
+
price: unit.selling_price || unit.retail_price || unit.price || batch.cost_per_unit || data.price || "", // Badge specific price
|
|
116
|
+
stock_qty: stockContext.qty_on_hand,
|
|
117
|
+
stock_weight: stockContext.weight_on_hand,
|
|
118
|
+
warehouse: stockContext.warehouse,
|
|
119
|
+
purity: unit.purity_percentage ? `${unit.purity_percentage}%` : (unit.primary_purity?.purity_percentage ? `${unit.primary_purity.purity_percentage}%` : (data.purity_id ? `${data.purity_id}%` : "")),
|
|
120
|
+
...unit
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
setEntityData(data);
|
|
124
|
+
}
|
|
125
|
+
} catch (err: any) {
|
|
126
|
+
toast.error("Failed to load print configuration");
|
|
127
|
+
} finally {
|
|
128
|
+
setIsLoading(false);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
loadData();
|
|
133
|
+
}, [isOpen, transactionType, entityId]);
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (
|
|
137
|
+
autoPrintTemplateId &&
|
|
138
|
+
!autoPrintTriggered &&
|
|
139
|
+
!isLoading &&
|
|
140
|
+
entityData &&
|
|
141
|
+
templates.length > 0 &&
|
|
142
|
+
selectedTemplateId === autoPrintTemplateId
|
|
143
|
+
) {
|
|
144
|
+
setAutoPrintTriggered(true);
|
|
145
|
+
handlePrint();
|
|
146
|
+
} else if (
|
|
147
|
+
autoPrintTemplateId &&
|
|
148
|
+
!autoPrintTriggered &&
|
|
149
|
+
!isLoading &&
|
|
150
|
+
entityData &&
|
|
151
|
+
templates.length > 0 &&
|
|
152
|
+
selectedTemplateId !== autoPrintTemplateId
|
|
153
|
+
) {
|
|
154
|
+
const templateExists = templates.find(t => t.printable_template_id === autoPrintTemplateId);
|
|
155
|
+
if (templateExists) {
|
|
156
|
+
setSelectedTemplateId(autoPrintTemplateId);
|
|
157
|
+
setQuantity(Number(templateExists.printable_template?.grid) || 1);
|
|
158
|
+
} else {
|
|
159
|
+
toast.error("Assigned print template not found.");
|
|
160
|
+
onClose();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}, [autoPrintTemplateId, autoPrintTriggered, isLoading, entityData, templates, selectedTemplateId]);
|
|
164
|
+
|
|
165
|
+
const handlePrint = async () => {
|
|
166
|
+
if (!selectedTemplateId || !entityData) {
|
|
167
|
+
toast.error("Please select a template and ensure data is loaded.");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
setIsPrinting(true);
|
|
172
|
+
|
|
173
|
+
const template = templates.find(t => t.printable_template_id === selectedTemplateId)?.printable_template;
|
|
174
|
+
const templateName = template?.name || 'Document';
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
// Fetch lines
|
|
178
|
+
const linesRes = await getTemplateLines(selectedTemplateId);
|
|
179
|
+
if (!linesRes.is_success) {
|
|
180
|
+
toast.error("Failed to load template layout");
|
|
181
|
+
setIsPrinting(false);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const lines = linesRes.result || linesRes.result?.data || [];
|
|
185
|
+
|
|
186
|
+
setPrintLines(lines);
|
|
187
|
+
|
|
188
|
+
// Wait for React to render the hidden component and react-barcode to mount
|
|
189
|
+
setTimeout(() => {
|
|
190
|
+
if (!hiddenPrintRef.current) {
|
|
191
|
+
setIsPrinting(false);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const reactHtml = hiddenPrintRef.current.innerHTML;
|
|
196
|
+
|
|
197
|
+
let generatedHtml = reactHtml;
|
|
198
|
+
const cfg = template.content_config || {};
|
|
199
|
+
const mTop = cfg.margins?.top !== undefined ? cfg.margins.top : (template.margin_mm !== undefined && template.margin_mm !== null && template.margin_mm !== "" ? template.margin_mm : 15);
|
|
200
|
+
const mBottom = cfg.margins?.bottom !== undefined ? cfg.margins.bottom : (template.margin_mm !== undefined && template.margin_mm !== null && template.margin_mm !== "" ? template.margin_mm : 15);
|
|
201
|
+
const mLeft = cfg.margins?.left !== undefined ? cfg.margins.left : (template.margin_mm !== undefined && template.margin_mm !== null && template.margin_mm !== "" ? template.margin_mm : 15);
|
|
202
|
+
const mRight = cfg.margins?.right !== undefined ? cfg.margins.right : (template.margin_mm !== undefined && template.margin_mm !== null && template.margin_mm !== "" ? template.margin_mm : 15);
|
|
203
|
+
|
|
204
|
+
const isReceipt = Number(template.width_mm) === 80 || Number(template.width_mm) === 58 || template.is_default === true || template.is_default === 1 || template.type === 'receipt' || template.type === 'barcode' || transactionType === 'barcode_label';
|
|
205
|
+
const pageWidth = template.width_mm || template.page_width_mm || 210;
|
|
206
|
+
const effHeight = parseFloat(template.height_mm || template.page_height_mm) || 0;
|
|
207
|
+
let pageHeight = effHeight > 0 ? effHeight : 297;
|
|
208
|
+
|
|
209
|
+
if (isReceipt && effHeight <= 0) {
|
|
210
|
+
try {
|
|
211
|
+
const tempDiv = document.createElement("div");
|
|
212
|
+
tempDiv.style.width = `${pageWidth}mm`;
|
|
213
|
+
tempDiv.style.padding = `${mTop}mm ${mRight}mm ${mBottom}mm ${mLeft}mm`;
|
|
214
|
+
tempDiv.style.boxSizing = "border-box";
|
|
215
|
+
tempDiv.style.position = "absolute";
|
|
216
|
+
tempDiv.style.visibility = "hidden";
|
|
217
|
+
tempDiv.style.top = "-9999px";
|
|
218
|
+
// Use a default styling context similar to the print iframe
|
|
219
|
+
tempDiv.style.fontFamily = "'Inter', sans-serif";
|
|
220
|
+
tempDiv.innerHTML = reactHtml;
|
|
221
|
+
document.body.appendChild(tempDiv);
|
|
222
|
+
|
|
223
|
+
const pxHeight = tempDiv.offsetHeight;
|
|
224
|
+
document.body.removeChild(tempDiv);
|
|
225
|
+
|
|
226
|
+
// Convert px to mm (approx 3.78 pixels per mm at 96 DPI)
|
|
227
|
+
// Add buffer for receipts, but no buffer for barcodes to avoid blank space
|
|
228
|
+
const bufferMm = (template.type === 'barcode' || transactionType === 'barcode_label') ? 0 : 5;
|
|
229
|
+
pageHeight = Math.ceil(pxHeight / 3.78) + bufferMm;
|
|
230
|
+
} catch (e) {
|
|
231
|
+
pageHeight = 297; // Fallback
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let pageStyle = isReceipt
|
|
236
|
+
? `@page { margin: 0mm; size: ${pageWidth}mm ${pageHeight}mm; } body { width: ${pageWidth}mm; margin: 0; padding: ${mTop}mm ${mRight}mm ${mBottom}mm ${mLeft}mm; box-sizing: border-box; }`
|
|
237
|
+
: `@page { size: ${pageWidth}mm ${pageHeight}mm; margin: 0mm; } body { margin: 0; }`;
|
|
238
|
+
|
|
239
|
+
const gridCols = Number(template.grid) || 1;
|
|
240
|
+
const gapX = template.label_gap_x_mm || 0;
|
|
241
|
+
const gapY = template.label_gap_y_mm || 0;
|
|
242
|
+
// width_mm = individual label width; compute total sheet width from it
|
|
243
|
+
const colWidth = template.width_mm || template.page_width_mm || 210;
|
|
244
|
+
const totalWidth = gridCols > 1 ? (colWidth * gridCols) + (gapX * (gridCols - 1)) : colWidth;
|
|
245
|
+
|
|
246
|
+
if (template.type === 'barcode' || transactionType === 'barcode_label' || gridCols > 1) {
|
|
247
|
+
let labelsHtml = "";
|
|
248
|
+
if (Array.isArray(entityData)) {
|
|
249
|
+
const batchItems = hiddenPrintRef.current.querySelectorAll('.batch-print-item');
|
|
250
|
+
batchItems.forEach((itemNode) => {
|
|
251
|
+
const qty = parseInt(itemNode.getAttribute('data-qty') || "1", 10);
|
|
252
|
+
const itemHtml = itemNode.innerHTML;
|
|
253
|
+
labelsHtml += Array(qty).fill(`<div style="width:100%;min-width:0;max-width:100%;height:${pageHeight}mm;box-sizing:border-box;overflow:hidden;page-break-inside:avoid;padding:${mTop}mm ${mRight}mm ${mBottom}mm ${mLeft}mm;">${itemHtml}</div>`).join("");
|
|
254
|
+
});
|
|
255
|
+
} else {
|
|
256
|
+
labelsHtml = Array(quantity).fill(`<div style="width:100%;min-width:0;max-width:100%;height:${pageHeight}mm;box-sizing:border-box;overflow:hidden;page-break-inside:avoid;padding:${mTop}mm ${mRight}mm ${mBottom}mm ${mLeft}mm;">${reactHtml}</div>`).join("");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
generatedHtml = `
|
|
260
|
+
<div style="display: grid; grid-template-columns: repeat(${gridCols > 1 ? gridCols : 'auto-fill'}, ${colWidth}mm); gap: ${gapY}mm ${gapX}mm; justify-content: center; justify-items: center; align-content: start;">
|
|
261
|
+
${labelsHtml}
|
|
262
|
+
</div>
|
|
263
|
+
`;
|
|
264
|
+
// For barcode rolls, set exact label size. For sheets, set full sheet size.
|
|
265
|
+
const finalPrintWidth = template.page_width_mm ? template.page_width_mm : totalWidth;
|
|
266
|
+
const finalPrintHeight = template.page_height_mm ? template.page_height_mm : pageHeight;
|
|
267
|
+
pageStyle = `@page { size: ${finalPrintWidth}mm ${finalPrintHeight}mm; margin: 0mm; } body { margin: 0; }`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const finalHtmlContent = (isReceipt || template.type === 'barcode' || transactionType === 'barcode_label' || gridCols > 1) ? generatedHtml : `
|
|
271
|
+
<table style="width: 100%; border-collapse: collapse; border: none;">
|
|
272
|
+
<thead style="display: table-header-group;">
|
|
273
|
+
<tr><td style="height: ${mTop}mm; padding: 0; border: none;"></td></tr>
|
|
274
|
+
</thead>
|
|
275
|
+
<tfoot style="display: table-footer-group;">
|
|
276
|
+
<tr><td style="height: ${mBottom}mm; padding: 0; border: none;"></td></tr>
|
|
277
|
+
</tfoot>
|
|
278
|
+
<tbody>
|
|
279
|
+
<tr><td style="padding: 0 ${mRight}mm 0 ${mLeft}mm; border: none;">
|
|
280
|
+
${generatedHtml}
|
|
281
|
+
</td></tr>
|
|
282
|
+
</tbody>
|
|
283
|
+
</table>
|
|
284
|
+
`;
|
|
285
|
+
|
|
286
|
+
const htmlContent = `
|
|
287
|
+
<!DOCTYPE html>
|
|
288
|
+
<html>
|
|
289
|
+
<head>
|
|
290
|
+
<title>Print ${templateName}</title>
|
|
291
|
+
<style>
|
|
292
|
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&display=swap');
|
|
293
|
+
body {
|
|
294
|
+
margin: 0;
|
|
295
|
+
padding: 0;
|
|
296
|
+
background: #fff;
|
|
297
|
+
-webkit-print-color-adjust: exact !important;
|
|
298
|
+
print-color-adjust: exact !important;
|
|
299
|
+
}
|
|
300
|
+
* { box-sizing: border-box; }
|
|
301
|
+
${pageStyle}
|
|
302
|
+
</style>
|
|
303
|
+
</head>
|
|
304
|
+
<body>
|
|
305
|
+
${finalHtmlContent}
|
|
306
|
+
</body>
|
|
307
|
+
</html>
|
|
308
|
+
`;
|
|
309
|
+
|
|
310
|
+
if (iframeRef.current) {
|
|
311
|
+
const iframeDoc = iframeRef.current.contentWindow?.document;
|
|
312
|
+
if (iframeDoc) {
|
|
313
|
+
iframeDoc.open();
|
|
314
|
+
iframeDoc.write(htmlContent);
|
|
315
|
+
iframeDoc.close();
|
|
316
|
+
|
|
317
|
+
setTimeout(() => {
|
|
318
|
+
iframeRef.current?.contentWindow?.focus();
|
|
319
|
+
iframeRef.current?.contentWindow?.print();
|
|
320
|
+
setIsPrinting(false);
|
|
321
|
+
setPrintLines([]);
|
|
322
|
+
onClose();
|
|
323
|
+
}, 800);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}, 100);
|
|
327
|
+
} catch (e: any) {
|
|
328
|
+
toast.error("Failed to print template");
|
|
329
|
+
setIsPrinting(false);
|
|
330
|
+
setPrintLines([]);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const handleShare = async () => {
|
|
335
|
+
if (!selectedTemplateId || !entityData) {
|
|
336
|
+
toast.error("Please select a template and ensure data is loaded.");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const template = templates.find(t => t.printable_template_id === selectedTemplateId)?.printable_template;
|
|
341
|
+
if (!template) {
|
|
342
|
+
toast.error("Template not found.");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let title = "";
|
|
347
|
+
switch (transactionType) {
|
|
348
|
+
case 'item':
|
|
349
|
+
case 'barcode_label':
|
|
350
|
+
title = `Item_${entityData.sku || entityData.name}`;
|
|
351
|
+
break;
|
|
352
|
+
default:
|
|
353
|
+
title = `Document_${entityId}`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
setIsPrinting(true);
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
// Fetch template layout lines
|
|
360
|
+
const linesRes = await getTemplateLines(selectedTemplateId);
|
|
361
|
+
if (!linesRes.is_success) {
|
|
362
|
+
toast.error("Failed to load template layout");
|
|
363
|
+
setIsPrinting(false);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const lines = linesRes.result || linesRes.result?.data || [];
|
|
367
|
+
|
|
368
|
+
setPrintLines(lines);
|
|
369
|
+
|
|
370
|
+
// Wait for React to render the hidden component and react-barcode to mount
|
|
371
|
+
setTimeout(async () => {
|
|
372
|
+
if (!hiddenPrintRef.current) {
|
|
373
|
+
setIsPrinting(false);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const reactHtml = hiddenPrintRef.current.innerHTML;
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
const marginMm = template.margin_mm !== undefined && template.margin_mm !== null && template.margin_mm !== "" ? template.margin_mm : 15;
|
|
381
|
+
const cfg = template.content_config || {};
|
|
382
|
+
const mTop = cfg.margins?.top !== undefined ? cfg.margins.top : marginMm;
|
|
383
|
+
const mBottom = cfg.margins?.bottom !== undefined ? cfg.margins.bottom : marginMm;
|
|
384
|
+
const mLeft = cfg.margins?.left !== undefined ? cfg.margins.left : marginMm;
|
|
385
|
+
const mRight = cfg.margins?.right !== undefined ? cfg.margins.right : marginMm;
|
|
386
|
+
|
|
387
|
+
const isReceipt = Number(template.width_mm) === 80 || Number(template.width_mm) === 58 || template.is_default == true || template.is_default === 1 || template.type === 'receipt' || template.type === 'barcode' || transactionType === 'barcode_label';
|
|
388
|
+
const pageWidth = template.width_mm || template.page_width_mm || 210;
|
|
389
|
+
const effHeight = parseFloat(template.height_mm || template.page_height_mm) || 0;
|
|
390
|
+
let pageHeight = effHeight > 0 ? effHeight : 297;
|
|
391
|
+
|
|
392
|
+
if (isReceipt && effHeight <= 0) {
|
|
393
|
+
try {
|
|
394
|
+
const tempDiv = document.createElement("div");
|
|
395
|
+
tempDiv.style.width = `${pageWidth}mm`;
|
|
396
|
+
tempDiv.style.padding = `${mTop}mm ${mRight}mm ${mBottom}mm ${mLeft}mm`;
|
|
397
|
+
tempDiv.style.boxSizing = "border-box";
|
|
398
|
+
tempDiv.style.position = "absolute";
|
|
399
|
+
tempDiv.style.visibility = "hidden";
|
|
400
|
+
tempDiv.style.top = "-9999px";
|
|
401
|
+
tempDiv.style.fontFamily = "'Inter', sans-serif";
|
|
402
|
+
tempDiv.innerHTML = reactHtml;
|
|
403
|
+
document.body.appendChild(tempDiv);
|
|
404
|
+
|
|
405
|
+
const pxHeight = tempDiv.offsetHeight;
|
|
406
|
+
document.body.removeChild(tempDiv);
|
|
407
|
+
|
|
408
|
+
const bufferMm = (template.type === 'barcode' || transactionType === 'barcode_label') ? 0 : 5;
|
|
409
|
+
pageHeight = Math.ceil(pxHeight / 3.78) + bufferMm;
|
|
410
|
+
} catch (e) {
|
|
411
|
+
pageHeight = 297;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
let generatedHtml = reactHtml;
|
|
416
|
+
const gridCols = Number(template.grid) || 1;
|
|
417
|
+
const gapX = template.label_gap_x_mm || 0;
|
|
418
|
+
const gapY = template.label_gap_y_mm || 0;
|
|
419
|
+
// width_mm = individual label width; compute total sheet width from it
|
|
420
|
+
const colWidth = template.width_mm || template.page_width_mm || 210;
|
|
421
|
+
const totalPageWidth = gridCols > 1 ? (colWidth * gridCols) + (gapX * (gridCols - 1)) : colWidth;
|
|
422
|
+
|
|
423
|
+
if (template.type === 'barcode' || transactionType === 'barcode_label' || gridCols > 1) {
|
|
424
|
+
const labelsHtml = Array(quantity).fill(`<div style="width:100%;min-width:0;max-width:100%;height:${pageHeight}mm;box-sizing:border-box;overflow:hidden;page-break-inside:avoid;padding:${mTop}mm ${mRight}mm ${mBottom}mm ${mLeft}mm;">${reactHtml}</div>`).join("");
|
|
425
|
+
generatedHtml = `
|
|
426
|
+
<div style="display: grid; grid-template-columns: repeat(${gridCols > 1 ? gridCols : 'auto-fill'}, ${colWidth}mm); gap: ${gapY}mm ${gapX}mm; justify-content: center; justify-items: center; align-content: start;">
|
|
427
|
+
${labelsHtml}
|
|
428
|
+
</div>
|
|
429
|
+
`;
|
|
430
|
+
}
|
|
431
|
+
const marginStr = (template.type === 'barcode' || transactionType === 'barcode_label' || gridCols > 1) ? '0' : `${mTop}mm ${mRight}mm ${mBottom}mm ${mLeft}mm`;
|
|
432
|
+
|
|
433
|
+
const finalHtmlContent = `
|
|
434
|
+
<div style="
|
|
435
|
+
width: ${totalPageWidth}mm;
|
|
436
|
+
padding: ${marginStr};
|
|
437
|
+
background: #fff;
|
|
438
|
+
box-sizing: border-box;
|
|
439
|
+
font-family: 'Inter', sans-serif;
|
|
440
|
+
">
|
|
441
|
+
${generatedHtml}
|
|
442
|
+
</div>
|
|
443
|
+
`;
|
|
444
|
+
|
|
445
|
+
const fullHtmlString = `
|
|
446
|
+
<!DOCTYPE html>
|
|
447
|
+
<html>
|
|
448
|
+
<head>
|
|
449
|
+
<style>
|
|
450
|
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
|
451
|
+
body { margin: 0; padding: 0; background: #fff; font-family: 'Inter', sans-serif; }
|
|
452
|
+
* { box-sizing: border-box; }
|
|
453
|
+
</style>
|
|
454
|
+
</head>
|
|
455
|
+
<body>
|
|
456
|
+
<div style="width: ${totalPageWidth}mm; padding: ${marginStr}; background: #fff; box-sizing: border-box;">
|
|
457
|
+
${generatedHtml}
|
|
458
|
+
</div>
|
|
459
|
+
</body>
|
|
460
|
+
</html>
|
|
461
|
+
`;
|
|
462
|
+
|
|
463
|
+
// Dynamically import html2pdf to avoid SSR issues
|
|
464
|
+
const html2pdf = (await import('html2pdf.js')).default;
|
|
465
|
+
|
|
466
|
+
const finalPrintWidth = template.page_width_mm ? template.page_width_mm : totalPageWidth;
|
|
467
|
+
const finalPrintHeight = template.page_height_mm ? template.page_height_mm : pageHeight;
|
|
468
|
+
|
|
469
|
+
const opt = {
|
|
470
|
+
margin: 0,
|
|
471
|
+
filename: `${title}.pdf`,
|
|
472
|
+
image: { type: 'jpeg' as const, quality: 0.98 },
|
|
473
|
+
html2canvas: {
|
|
474
|
+
scale: 2,
|
|
475
|
+
useCORS: true,
|
|
476
|
+
scrollY: 0,
|
|
477
|
+
scrollX: 0,
|
|
478
|
+
backgroundColor: '#ffffff'
|
|
479
|
+
},
|
|
480
|
+
jsPDF: { unit: 'mm', format: [finalPrintWidth, finalPrintHeight] as [number, number], orientation: 'portrait' as const }
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const pdfBlob = await html2pdf().set(opt).from(fullHtmlString).output('blob');
|
|
484
|
+
|
|
485
|
+
const file = new File([pdfBlob], `${title}.pdf`, { type: 'application/pdf' });
|
|
486
|
+
|
|
487
|
+
if (navigator.share) {
|
|
488
|
+
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
|
489
|
+
await navigator.share({
|
|
490
|
+
title: title,
|
|
491
|
+
text: `Please find the attached document for ${title}.`,
|
|
492
|
+
files: [file]
|
|
493
|
+
});
|
|
494
|
+
toast.success("Shared successfully");
|
|
495
|
+
} else {
|
|
496
|
+
// Fallback if device doesn't support file sharing
|
|
497
|
+
await navigator.share({
|
|
498
|
+
title: title,
|
|
499
|
+
text: `Sharing document details: ${title}. (Note: File sharing not supported on this device)`
|
|
500
|
+
});
|
|
501
|
+
toast.success("Shared successfully (without file)");
|
|
502
|
+
}
|
|
503
|
+
} else {
|
|
504
|
+
// Fallback: download the file if Share API is completely unavailable
|
|
505
|
+
const url = URL.createObjectURL(pdfBlob);
|
|
506
|
+
const a = document.createElement('a');
|
|
507
|
+
a.href = url;
|
|
508
|
+
a.download = `${title}.pdf`;
|
|
509
|
+
document.body.appendChild(a);
|
|
510
|
+
a.click();
|
|
511
|
+
document.body.removeChild(a);
|
|
512
|
+
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
|
513
|
+
toast.success("Downloaded successfully (Share API not supported)");
|
|
514
|
+
}
|
|
515
|
+
} catch (err) {
|
|
516
|
+
console.error(err);
|
|
517
|
+
toast.error("Failed to generate and share document");
|
|
518
|
+
} finally {
|
|
519
|
+
setIsPrinting(false);
|
|
520
|
+
setPrintLines([]);
|
|
521
|
+
onClose();
|
|
522
|
+
}
|
|
523
|
+
}, 100);
|
|
524
|
+
} catch (err) {
|
|
525
|
+
console.error(err);
|
|
526
|
+
toast.error("Failed to initialize share");
|
|
527
|
+
setIsPrinting(false);
|
|
528
|
+
setPrintLines([]);
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
if (!isOpen) return null;
|
|
533
|
+
|
|
534
|
+
return (
|
|
535
|
+
<Modal isOpen={isOpen} onClose={onClose} title="Print Document" size="md" zIndex={9999}>
|
|
536
|
+
<div className="flex flex-col gap-5 p-2">
|
|
537
|
+
{isLoading ? (
|
|
538
|
+
<div className="flex items-center justify-center py-12">
|
|
539
|
+
<div className="w-6 h-6 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
|
540
|
+
</div>
|
|
541
|
+
) : autoPrintTemplateId ? (
|
|
542
|
+
<div className="flex flex-col items-center justify-center py-10 gap-3">
|
|
543
|
+
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
|
544
|
+
<p className="text-sm font-medium text-gray-500">Preparing to print...</p>
|
|
545
|
+
</div>
|
|
546
|
+
) : templates.length === 0 ? (
|
|
547
|
+
<div className="flex flex-col items-center justify-center gap-3">
|
|
548
|
+
<EmptyState message="No print templates configured." />
|
|
549
|
+
<Button color="primary" variant="flat" onClick={() => window.location.href = '/jewelry/billing-templates'}>Configure Templates</Button>
|
|
550
|
+
</div>
|
|
551
|
+
) : (
|
|
552
|
+
<>
|
|
553
|
+
<div>
|
|
554
|
+
<p className="text-[13px] text-gray-500 mb-4">
|
|
555
|
+
Select a template to print this {transactionType.replace('_', ' ')}.
|
|
556
|
+
</p>
|
|
557
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
558
|
+
{templates.map(tpl => {
|
|
559
|
+
const isSelected = selectedTemplateId === tpl.printable_template_id;
|
|
560
|
+
const templateInfo = tpl.printable_template || {};
|
|
561
|
+
|
|
562
|
+
return (
|
|
563
|
+
<div
|
|
564
|
+
key={tpl.printable_template_id || tpl.id}
|
|
565
|
+
onClick={() => {
|
|
566
|
+
setSelectedTemplateId(tpl.printable_template_id);
|
|
567
|
+
// Default to template grid size
|
|
568
|
+
setQuantity(Number(templateInfo.grid) || 1);
|
|
569
|
+
}}
|
|
570
|
+
className={`relative cursor-pointer flex flex-col items-start gap-2 p-4 rounded-xl border-2 text-left transition-all hover:shadow-md ${isSelected
|
|
571
|
+
? "border-primary-500 bg-primary-50/60 shadow-sm"
|
|
572
|
+
: "border-gray-200 bg-white hover:border-primary-300"
|
|
573
|
+
}`}
|
|
574
|
+
>
|
|
575
|
+
{isSelected && (
|
|
576
|
+
<div className="absolute top-3 right-3 text-primary-600">
|
|
577
|
+
<CheckCircle2 size={16} className="fill-primary-100" />
|
|
578
|
+
</div>
|
|
579
|
+
)}
|
|
580
|
+
<div className="flex items-center gap-3 w-full min-w-0">
|
|
581
|
+
<div className={`shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${isSelected ? 'bg-primary-100 text-primary-600' : 'bg-gray-50 text-gray-400'}`}>
|
|
582
|
+
<FileText size={18} />
|
|
583
|
+
</div>
|
|
584
|
+
<div className="flex-1 min-w-0 pr-6">
|
|
585
|
+
<p className="text-[13px] font-bold text-gray-900 truncate">{templateInfo.name || 'Template'}</p>
|
|
586
|
+
<p className="text-[11px] font-mono text-gray-500 truncate mt-0.5">{templateInfo.code || tpl.transaction_type}</p>
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
589
|
+
</div>
|
|
590
|
+
);
|
|
591
|
+
})}
|
|
592
|
+
</div>
|
|
593
|
+
</div>
|
|
594
|
+
|
|
595
|
+
{/* Quantity Input for Barcodes */}
|
|
596
|
+
{!Array.isArray(entityData) && (
|
|
597
|
+
<div className="pt-2">
|
|
598
|
+
<Input
|
|
599
|
+
label="Number of Labels to Print"
|
|
600
|
+
type="number"
|
|
601
|
+
min={1}
|
|
602
|
+
value={quantity.toString()}
|
|
603
|
+
onChange={(e) => {
|
|
604
|
+
const val = parseInt(e.target.value, 10);
|
|
605
|
+
if (!isNaN(val) && val >= 1) {
|
|
606
|
+
setQuantity(val);
|
|
607
|
+
} else if (e.target.value === "") {
|
|
608
|
+
setQuantity(1);
|
|
609
|
+
}
|
|
610
|
+
}}
|
|
611
|
+
className="w-full"
|
|
612
|
+
/>
|
|
613
|
+
</div>
|
|
614
|
+
)}
|
|
615
|
+
|
|
616
|
+
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-2">
|
|
617
|
+
<Button
|
|
618
|
+
color="default"
|
|
619
|
+
variant="flat"
|
|
620
|
+
onClick={handleShare}
|
|
621
|
+
icon={<Share2 size={16} />}
|
|
622
|
+
isDisabled={!entityData}
|
|
623
|
+
>
|
|
624
|
+
Share
|
|
625
|
+
</Button>
|
|
626
|
+
<div className="flex gap-2">
|
|
627
|
+
<Button color="secondary" variant="flat" onClick={onClose}>Cancel</Button>
|
|
628
|
+
<Button
|
|
629
|
+
color="primary"
|
|
630
|
+
onClick={handlePrint}
|
|
631
|
+
isDisabled={!selectedTemplateId || isPrinting || !entityData}
|
|
632
|
+
icon={<Printer size={16} />}
|
|
633
|
+
>
|
|
634
|
+
{isPrinting ? "Printing..." : "Print"}
|
|
635
|
+
</Button>
|
|
636
|
+
</div>
|
|
637
|
+
</div>
|
|
638
|
+
</>
|
|
639
|
+
)}
|
|
640
|
+
</div>
|
|
641
|
+
|
|
642
|
+
{/* Hidden iframe for printing */}
|
|
643
|
+
<iframe ref={iframeRef} className="hidden" title="print-frame" />
|
|
644
|
+
|
|
645
|
+
{/* Hidden container for rendering react-barcode and other DOM-dependent components */}
|
|
646
|
+
<div className="hidden">
|
|
647
|
+
<div ref={hiddenPrintRef}>
|
|
648
|
+
{isPrinting && printLines.length > 0 && entityData && (
|
|
649
|
+
Array.isArray(entityData) ? (
|
|
650
|
+
entityData.map((data: any, idx: number) => (
|
|
651
|
+
<div key={idx} className="batch-print-item" data-qty={data._print_qty || quantity}>
|
|
652
|
+
<TemplateRenderer
|
|
653
|
+
template={templates.find(t => t.printable_template_id === selectedTemplateId)?.printable_template}
|
|
654
|
+
lines={printLines}
|
|
655
|
+
entityData={data}
|
|
656
|
+
noPadding={true}
|
|
657
|
+
/>
|
|
658
|
+
</div>
|
|
659
|
+
))
|
|
660
|
+
) : (
|
|
661
|
+
<TemplateRenderer
|
|
662
|
+
template={templates.find(t => t.printable_template_id === selectedTemplateId)?.printable_template}
|
|
663
|
+
lines={printLines}
|
|
664
|
+
entityData={entityData}
|
|
665
|
+
noPadding={true}
|
|
666
|
+
/>
|
|
667
|
+
)
|
|
668
|
+
)}
|
|
669
|
+
</div>
|
|
670
|
+
</div>
|
|
671
|
+
</Modal>
|
|
672
|
+
);
|
|
673
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -76,3 +76,5 @@ export { default as AttendanceTimesheets } from './common-components/attendance-
|
|
|
76
76
|
export * from './common-components/attendance-shifts/AttendanceTimesheets';
|
|
77
77
|
|
|
78
78
|
export * from "./base-components/ChartOfAccountPicker";
|
|
79
|
+
export * from './common-components/print/TemplateRenderer';
|
|
80
|
+
export { default as TransactionPrintSelector } from './common-components/print/TransactionPrintSelector';
|