@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';