feat: Add print modal with paper size selection and enhance USB printer connection/disconnection handling with UI feedback.
This commit is contained in:
parent
9a1bd66eba
commit
c74b03fde0
@ -59,18 +59,21 @@ public function delete(Order $order): void
|
|||||||
#[On('fn:print')]
|
#[On('fn:print')]
|
||||||
public function print(Order $order): void
|
public function print(Order $order): void
|
||||||
{
|
{
|
||||||
$order->load(['items', 'user', 'user.employee']);
|
$order->load(['items', 'user', 'user.employee', 'outlet']);
|
||||||
|
|
||||||
$this->dispatch(
|
$this->dispatch(
|
||||||
'printReceipt',
|
'open-print-modal',
|
||||||
cashier: $order->user?->employee?->full_name ?? '',
|
cashier: $order->user?->employee?->full_name ?? '',
|
||||||
customer: $order->customer?->name ?? '',
|
customer: $order->customer?->name ?? '',
|
||||||
|
outlet_name: $order->outlet?->name ?? '',
|
||||||
|
outlet_address: $order->outlet?->address ?? '',
|
||||||
subtotal: $order->subtotal,
|
subtotal: $order->subtotal,
|
||||||
discount: $order->discount,
|
discount: $order->discount,
|
||||||
total: $order->total,
|
total: $order->total,
|
||||||
items: $order->items->map(function ($item) {
|
items: $order->items->map(function ($item) {
|
||||||
$item->name = $item->orderable?->name;
|
$item->name = $item->orderable?->name;
|
||||||
$item->total = $item->unit_price * $item->quantity;
|
$item->total = $item->unit_price * $item->quantity;
|
||||||
|
$item->quality = $item->quality;
|
||||||
|
|
||||||
return $item;
|
return $item;
|
||||||
})->toArray()
|
})->toArray()
|
||||||
|
|||||||
@ -4,13 +4,21 @@
|
|||||||
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
|
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
|
||||||
</div>
|
</div>
|
||||||
@can('create order')
|
@can('create order')
|
||||||
<div class="flex">
|
<div class="flex" x-data="{ isConnected: !!localStorage.getItem('printerDevice') }" @printer-status-change.window="isConnected = $event.detail.connected">
|
||||||
<flux:button href="{{ route('studio.manage.order.create') }}" variant="primary" wire:navigate class="text-sm">
|
<flux:button href="{{ route('studio.manage.order.create') }}" variant="primary" wire:navigate class="text-sm">
|
||||||
Tambah
|
Tambah
|
||||||
</flux:button>
|
</flux:button>
|
||||||
|
<template x-if="!isConnected">
|
||||||
<flux:button variant="primary" color="cyan" class="ms-2" wire:click="$dispatch('connectUSB')">
|
<flux:button variant="primary" color="cyan" class="ms-2" wire:click="$dispatch('connectUSB')">
|
||||||
Connect USB
|
Connect USB
|
||||||
</flux:button>
|
</flux:button>
|
||||||
|
</template>
|
||||||
|
<template x-if="isConnected">
|
||||||
|
<div class="ms-2 flex items-center gap-2 text-green-600 bg-green-50 px-3 py-1 rounded-lg border border-green-200">
|
||||||
|
<flux:icon.check-circle class="size-4" />
|
||||||
|
<span class="text-sm font-medium">Printer Connected</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@endcan
|
@endcan
|
||||||
</div>
|
</div>
|
||||||
@ -27,12 +35,50 @@
|
|||||||
'buttonColor' => 'danger',
|
'buttonColor' => 'danger',
|
||||||
'buttonText' => 'Ya, Hapus',
|
'buttonText' => 'Ya, Hapus',
|
||||||
])
|
])
|
||||||
|
{{-- Print Modal --}}
|
||||||
|
<flux:modal.trigger name="print-modal">
|
||||||
|
<button id="btn-trigger-print-modal" class="hidden"></button>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
|
||||||
|
<flux:modal name="print-modal" class="w-full max-w-sm">
|
||||||
|
<div class="space-y-6" x-data="{ paperSize: '58' }">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Cetak Struk</flux:heading>
|
||||||
|
<flux:subheading>Pilih ukuran kertas printer thermal Anda.</flux:subheading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:radio.group label="Ukuran Kertas" x-model="paperSize">
|
||||||
|
<flux:radio value="58" label="58mm" />
|
||||||
|
<flux:radio value="80" label="80mm" />
|
||||||
|
</flux:radio.group>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<flux:button variant="ghost" x-on:click="$dispatch('modal-close')">Batal</flux:button>
|
||||||
|
<flux:button variant="primary"
|
||||||
|
x-on:click="$dispatch('trigger-print-process', { paperSize: paperSize })">
|
||||||
|
Cetak
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
</flux:main>
|
</flux:main>
|
||||||
|
|
||||||
@script
|
@script
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('livewire:navigated', () => {
|
document.addEventListener('livewire:navigated', () => {
|
||||||
window.printerDevice = null;
|
window.printerDevice = null;
|
||||||
|
let currentOrder = null;
|
||||||
|
|
||||||
|
// Check if device is already stored
|
||||||
|
const savedDevice = localStorage.getItem('printerDevice');
|
||||||
|
if (savedDevice) {
|
||||||
|
// Dispatch event to update UI
|
||||||
|
setTimeout(() => window.dispatchEvent(new CustomEvent('printer-status-change', {
|
||||||
|
detail: {
|
||||||
|
connected: true
|
||||||
|
}
|
||||||
|
})), 100);
|
||||||
|
}
|
||||||
|
|
||||||
Livewire.on('connectUSB', async (event) => {
|
Livewire.on('connectUSB', async (event) => {
|
||||||
try {
|
try {
|
||||||
@ -61,7 +107,11 @@
|
|||||||
localStorage.setItem('printerDevice', JSON.stringify(deviceInfo));
|
localStorage.setItem('printerDevice', JSON.stringify(deviceInfo));
|
||||||
|
|
||||||
console.log('Connected to:', device.productName);
|
console.log('Connected to:', device.productName);
|
||||||
console.log('Saved to localStorage:', deviceInfo);
|
window.dispatchEvent(new CustomEvent('printer-status-change', {
|
||||||
|
detail: {
|
||||||
|
connected: true
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Gagal connect:', err);
|
console.error('Gagal connect:', err);
|
||||||
@ -94,8 +144,20 @@
|
|||||||
window.printerDevice = target;
|
window.printerDevice = target;
|
||||||
|
|
||||||
console.log('Auto reconnected to:', target.productName);
|
console.log('Auto reconnected to:', target.productName);
|
||||||
|
window.dispatchEvent(new CustomEvent('printer-status-change', {
|
||||||
|
detail: {
|
||||||
|
connected: true
|
||||||
|
}
|
||||||
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Gagal autoreconnect:', err);
|
console.error('Gagal autoreconnect:', err);
|
||||||
|
// If fail, remove storage
|
||||||
|
localStorage.removeItem('printerDevice');
|
||||||
|
window.dispatchEvent(new CustomEvent('printer-status-change', {
|
||||||
|
detail: {
|
||||||
|
connected: false
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,7 +165,6 @@
|
|||||||
|
|
||||||
navigator.usb.addEventListener('disconnect', event => {
|
navigator.usb.addEventListener('disconnect', event => {
|
||||||
const device = event.device;
|
const device = event.device;
|
||||||
|
|
||||||
const saved = localStorage.getItem('printerDevice');
|
const saved = localStorage.getItem('printerDevice');
|
||||||
|
|
||||||
if (!saved) return;
|
if (!saved) return;
|
||||||
@ -115,125 +176,163 @@
|
|||||||
|
|
||||||
if (device.vendorId === vendorId && device.productId === productId) {
|
if (device.vendorId === vendorId && device.productId === productId) {
|
||||||
localStorage.removeItem('printerDevice');
|
localStorage.removeItem('printerDevice');
|
||||||
|
|
||||||
window.printerDevice = null;
|
window.printerDevice = null;
|
||||||
|
|
||||||
console.log('USB dicabut, localStorage dibersihkan.');
|
console.log('USB dicabut, localStorage dibersihkan.');
|
||||||
|
window.dispatchEvent(new CustomEvent('printer-status-change', {
|
||||||
|
detail: {
|
||||||
|
connected: false
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function getMaxChar(widthMM) {
|
function getMaxChar(widthMM) {
|
||||||
if (widthMM <= 48) return 32;
|
// 58mm uses approx 32 chars (Font A)
|
||||||
if (widthMM <= 58) return 42;
|
// 80mm uses approx 42-48 chars (Font A)
|
||||||
return 48;
|
if (parseInt(widthMM) === 58) return 32;
|
||||||
|
return 48; // for 80mm
|
||||||
}
|
}
|
||||||
|
|
||||||
Livewire.on('printReceipt', async (event) => {
|
Livewire.on('open-print-modal', (data) => {
|
||||||
try {
|
currentOrder = data; // Store data
|
||||||
const data = {
|
// Trigger modal open
|
||||||
cashier: event.cashier,
|
const btn = document.getElementById('btn-trigger-print-modal');
|
||||||
customer: event.customer,
|
if (btn) btn.click();
|
||||||
items: event.items,
|
|
||||||
subtotal: event.subtotal,
|
|
||||||
discount: event.discount,
|
|
||||||
total: event.total,
|
|
||||||
};
|
|
||||||
await printReceipt(data);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Print error:", e);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function printReceipt(order) {
|
window.addEventListener('trigger-print-process', async (e) => {
|
||||||
|
if (!currentOrder) return;
|
||||||
|
const paperSize = e.detail.paperSize;
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
// Assuming Flux handles close on click or we dispatch close
|
||||||
|
// We will try to close it via dispatching 'close-modal' if configured, but Flux
|
||||||
|
// buttons inside modal might need `data-flux-close` or similar?
|
||||||
|
// The provided code used $dispatch('modal-close') which might be custom.
|
||||||
|
// We will proceed to print.
|
||||||
|
|
||||||
|
// Ideally we close the modal:
|
||||||
|
// document.dispatchEvent(new CustomEvent('close-modal', { detail: 'print-modal' })); // Hypothetical
|
||||||
|
|
||||||
|
await printReceipt(currentOrder, parseInt(paperSize));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function printReceipt(order, paperSize = 58) {
|
||||||
if (!window.printerDevice) {
|
if (!window.printerDevice) {
|
||||||
console.error("Printer belum terkoneksi.");
|
// Try auto reconnect if not connected but saved
|
||||||
|
await autoReconnect();
|
||||||
|
if (!window.printerDevice) {
|
||||||
|
alert("Printer belum terkoneksi via USB.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const device = window.printerDevice;
|
const device = window.printerDevice;
|
||||||
|
|
||||||
// ESC/POS Commands
|
// ESC/POS Commands
|
||||||
|
const ESC = "\x1B";
|
||||||
|
const GS = "\x1D";
|
||||||
const reset = new Uint8Array([0x1B, 0x40]);
|
const reset = new Uint8Array([0x1B, 0x40]);
|
||||||
const alignLeft = new Uint8Array([0x1B, 0x61, 0]);
|
const alignLeft = new Uint8Array([0x1B, 0x61, 0]);
|
||||||
const alignCenter = new Uint8Array([0x1B, 0x61, 1]);
|
const alignCenter = new Uint8Array([0x1B, 0x61, 1]);
|
||||||
const alignRight = new Uint8Array([0x1B, 0x61, 2]);
|
const alignRight = new Uint8Array([0x1B, 0x61, 2]);
|
||||||
const cut = new Uint8Array([0x1D, 0x56, 0x00]);
|
const cut = new Uint8Array([0x1D, 0x56, 0x00]);
|
||||||
|
|
||||||
const MAX = getMaxChar(48);
|
const MAX = getMaxChar(paperSize);
|
||||||
|
|
||||||
const line = "-".repeat(MAX) + "\n";
|
const line = "-".repeat(MAX) + "\n";
|
||||||
|
|
||||||
const formatItem = (name, qty, price, total, isLast) => {
|
// Function to format text rows
|
||||||
let out = name.substring(0, 32) + "\n";
|
const formatRow = (left, right, maxLen) => {
|
||||||
|
let out = "";
|
||||||
const left = `${qty} x ${price.toLocaleString()}`;
|
const leftLen = left.length;
|
||||||
const right = total.toLocaleString();
|
const rightLen = right.length;
|
||||||
|
const space = maxLen - leftLen - rightLen;
|
||||||
const space = 32 - left.length - right.length;
|
|
||||||
out += left + " ".repeat(space > 0 ? space : 1) + right;
|
|
||||||
|
|
||||||
// Kalau terakhir, pakai satu newline.
|
|
||||||
// Kalau bukan, dua newline seperti biasa.
|
|
||||||
out += isLast ? "\n" : "\n\n";
|
|
||||||
|
|
||||||
|
if (space >= 0) {
|
||||||
|
out += left + " ".repeat(space) + right + "\n";
|
||||||
|
} else {
|
||||||
|
// Wrap
|
||||||
|
out += left + "\n";
|
||||||
|
out += " ".repeat(maxLen - rightLen) + right + "\n";
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// BUILD TEXT
|
// BUILD TEXT
|
||||||
// =========================================
|
|
||||||
|
|
||||||
let text = "";
|
let text = "";
|
||||||
|
|
||||||
// HEADER
|
// HEADER
|
||||||
|
text += ESC + "\x61\x01"; // Center
|
||||||
|
// text += ESC + "\x21\x30"; // Double Height Width logic? 0x30 = 00110000 -> bit 4,5 set.
|
||||||
|
// Standard thermal printer double size:
|
||||||
|
text += ESC + "!" + "\x10"; // Double Height
|
||||||
|
text += "Yadi Parfum\n";
|
||||||
|
text += ESC + "!" + "\x00"; // Normal
|
||||||
|
|
||||||
// center and increase font size
|
if (order.outlet_address) {
|
||||||
text += "\x1B\x61\x01";
|
text += order.outlet_address + "\n";
|
||||||
text += "\x1B\x21\x30";
|
}
|
||||||
|
text += "\n";
|
||||||
|
|
||||||
text += "Yadi Parfum\n\n";
|
text += ESC + "\x61\x00"; // Left Align
|
||||||
|
text += `Kasir : ${order.cashier || '-'}\n`;
|
||||||
// reset to normal and left align
|
text += `Customer : ${order.customer || '-'}\n`;
|
||||||
text += "\x1B\x21\x00";
|
text += `Tanggal : ${new Date().toLocaleString('id-ID')}\n`;
|
||||||
text += "\x1B\x61\x00";
|
|
||||||
|
|
||||||
text += "Kasir : " + order.cashier + "\n";
|
|
||||||
text += "Customer : " + order.customer + "\n";
|
|
||||||
text += line;
|
text += line;
|
||||||
|
|
||||||
// CONTENT
|
// CONTENT
|
||||||
order.items.forEach((i, idx) => {
|
order.items.forEach((i, idx) => {
|
||||||
const isLast = idx === order.items.length - 1;
|
const isLast = idx === order.items.length - 1;
|
||||||
text += formatItem(i.name, i.quantity, i.unit_price, i.total, isLast);
|
|
||||||
|
// Item Name
|
||||||
|
text += i.name + "\n";
|
||||||
|
|
||||||
|
// Quality or Quantity logic
|
||||||
|
// If quality is present and not 'Custom', show Quality
|
||||||
|
// Else show numeric math
|
||||||
|
const qualityName = (i.quality && i.quality !== 'Custom') ? i.quality : null;
|
||||||
|
|
||||||
|
if (qualityName) {
|
||||||
|
// Display: QualityName ......... TotalPrice
|
||||||
|
text += formatRow(qualityName, Number(i.total).toLocaleString('id-ID'), MAX);
|
||||||
|
} else {
|
||||||
|
// Display: 2 x 10,000 ......... 20,000
|
||||||
|
// i.quantity might be formatted string in some contexts, but here it's likely number from PHP
|
||||||
|
const qtyStr =
|
||||||
|
`${i.quantity} x ${Number(i.unit_price).toLocaleString('id-ID')}`;
|
||||||
|
text += formatRow(qtyStr, Number(i.total).toLocaleString('id-ID'), MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add extra newline if not packed tight
|
||||||
|
text += "\n";
|
||||||
});
|
});
|
||||||
|
|
||||||
text += line;
|
text += line;
|
||||||
|
|
||||||
// FOOTER
|
// FOOTER
|
||||||
text += "\x1B\x61\x02";
|
text += formatRow("Subtotal", Number(order.subtotal).toLocaleString('id-ID'), MAX);
|
||||||
text += `${order.subtotal.toLocaleString()}\n`;
|
if (order.discount > 0) {
|
||||||
text += `-${order.discount.toLocaleString()}\n`;
|
text += formatRow("Diskon", "-" + Number(order.discount).toLocaleString('id-ID'), MAX);
|
||||||
|
}
|
||||||
// bold teks
|
|
||||||
text += "\x1B\x45\x01";
|
|
||||||
|
|
||||||
text += `${order.total.toLocaleString()}\n`;
|
|
||||||
|
|
||||||
// reset bold
|
|
||||||
text += "\x1B\x45\x00";
|
|
||||||
|
|
||||||
|
text += "\n";
|
||||||
|
text += ESC + "\x61\x01"; // Center
|
||||||
|
text += ESC + "\x45\x01"; // Bold On
|
||||||
|
text += `TOTAL: ${Number(order.total).toLocaleString('id-ID')}\n`;
|
||||||
|
text += ESC + "\x45\x00"; // Bold Off
|
||||||
text += line;
|
text += line;
|
||||||
|
|
||||||
// THANKS
|
text += "Terima kasih telah berbelanja\n";
|
||||||
text += "\x1B\x61\x01";
|
text += "dan memilih Yadi Parfum :)\n";
|
||||||
text += "Terima kasih telah berbelanja dan memilih Yadi Parfum :)\n";
|
|
||||||
|
// Add some feed
|
||||||
|
text += "\n\n\n";
|
||||||
|
|
||||||
const textBytes = encoder.encode(text);
|
const textBytes = encoder.encode(text);
|
||||||
|
|
||||||
// SEND
|
// SEND
|
||||||
await device.transferOut(1, reset);
|
await device.transferOut(1, reset);
|
||||||
await device.transferOut(1, alignLeft);
|
|
||||||
await device.transferOut(1, textBytes);
|
await device.transferOut(1, textBytes);
|
||||||
await device.transferOut(1, cut);
|
await device.transferOut(1, cut);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user