Files
2026-09-16 23:20:08 +07:00

382 lines
21 KiB
PHP

<?php
/**
* Cashier POS Checkout & Billing View (Glassmorphism)
*/
?>
<div x-data="posApp" class="space-y-6">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 glass-card p-6 rounded-2xl border">
<div>
<h1 class="text-2xl font-bold tracking-tight bg-gradient-to-r from-emerald-400 to-cyan-400 bg-clip-text text-transparent">
แคชเชียร์ & ชำระค่าบริการ (POS Checkout & Billing)
</h1>
<p class="text-sm text-subtle mt-1">ออกใบเสร็จรับเงิน, สแกน QR Code พร้อมเพย์ และพิมพ์ใบเสร็จผ่านเครื่องพิมพ์ ESC/POS</p>
</div>
<div class="flex items-center gap-3">
<button @click="connectUsbPrinter()" class="glass-btn-primary bg-gradient-to-r from-cyan-500 to-blue-600 shadow-cyan-500/30 text-sm">
<i class="bi bi-printer-fill text-lg"></i>
<span x-text="usbDevice ? 'เชื่อมต่อ Printer แล้ว' : 'เชื่อมต่อ USB Printer'"></span>
</button>
<button @click="fetchPendingQueues()" class="p-2.5 rounded-xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 hover:bg-emerald-500/20 transition-all" title="รีเฟรชรายการรอชำระ">
<i class="bi bi-arrow-clockwise text-lg" :class="loading ? 'animate-spin' : ''"></i>
</button>
</div>
</div>
<!-- 2-Column POS Layout -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- Left Column (5 cols): Pending Bills List -->
<div class="lg:col-span-5 glass-card p-5 rounded-2xl border flex flex-col h-[700px]">
<div class="flex items-center justify-between pb-3 mb-4 border-b border-emerald-500/20">
<div class="flex items-center gap-2">
<i class="bi bi-receipt text-xl text-emerald-400"></i>
<h3 class="font-bold text-base">รายการคิวรอชำระเงิน (Pending Bills)</h3>
</div>
<span class="px-2.5 py-0.5 rounded-full bg-emerald-500/20 text-emerald-300 font-mono text-xs font-bold" x-text="pendingList.length">0</span>
</div>
<!-- Search Input -->
<div class="mb-3">
<input type="text" x-model="searchQuery" placeholder="ค้นหาตามเลขคิว, ชื่อผู้ป่วย หรือ HN..." class="glass-input text-sm">
</div>
<!-- List -->
<div class="flex-1 overflow-y-auto space-y-3 pr-1">
<template x-for="item in filteredPending" :key="item.id">
<div @click="selectBill(item)"
class="p-4 rounded-xl border cursor-pointer transition-all relative group"
:class="selectedBill?.id === item.id ? 'bg-gradient-to-r from-emerald-950/60 to-teal-950/60 border-emerald-500 shadow-lg shadow-emerald-500/10' : 'bg-slate-800/40 border-slate-700/60 hover:border-emerald-500/50'">
<div class="flex justify-between items-start mb-1.5">
<span class="text-lg font-bold font-mono text-emerald-400" x-text="item.queue_no">A003</span>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30">
รอชำระเงิน
</span>
</div>
<div class="space-y-1 text-xs">
<p class="font-bold text-sm" x-text="item.patient_name || 'คุณสมชาย รักสุขภาพ'"></p>
<p class="text-subtle" x-text="`บริการ: ${item.service_name || '-'}`"></p>
<p class="text-cyan-400" x-text="`หมอนวด: ${item.therapist_name || '-'}`"></p>
</div>
<div class="pt-2 mt-2 border-t border-slate-700/60 flex justify-between items-baseline font-mono font-bold">
<span class="text-xs text-subtle">ยอดรวมสุทธิ</span>
<span class="text-base text-amber-400" x-text="`${formatCurrency(item.total_amount || item.price || 600)} ฿`">600 ฿</span>
</div>
</div>
</template>
<div x-show="filteredPending.length === 0" class="text-center py-12 text-subtle text-sm">ไม่มีรายการคิวรอชำระเงินในขณะนี้</div>
</div>
</div>
<!-- Right Column (7 cols): POS Checkout Register & Receipt Preview -->
<div class="lg:col-span-7 glass-card p-6 rounded-2xl border flex flex-col justify-between h-[700px] relative overflow-y-auto">
<div x-show="!selectedBill" class="m-auto text-center py-20 text-subtle space-y-3">
<i class="bi bi-touchscreen text-5xl opacity-40"></i>
<p class="text-base font-medium">กรุณาเลือกรายการคิวทางซ้ายมือ เพื่อดำเนินการคิดเงินและออกใบเสร็จ</p>
</div>
<!-- POS Active Checkout Form -->
<div x-show="selectedBill" x-cloak class="space-y-5 flex-1 flex flex-col justify-between">
<div>
<!-- Bill Header -->
<div class="flex justify-between items-start pb-4 border-b border-slate-700/60">
<div>
<span class="text-xs text-subtle font-mono uppercase tracking-wider">Queue Number / Bill ID</span>
<h2 class="text-2xl font-extrabold font-mono text-emerald-400" x-text="selectedBill?.queue_no || 'A003'">A003</h2>
<p class="text-sm font-semibold mt-0.5" x-text="selectedBill?.patient_name || 'คุณสมชาย รักสุขภาพ'"></p>
</div>
<div class="text-right">
<span class="text-xs text-subtle">วันที่ออกบิล</span>
<p class="font-mono text-sm font-bold" x-text="new Date().toLocaleDateString('th-TH')"></p>
</div>
</div>
<!-- Items Breakdown table -->
<div class="my-4 space-y-2">
<div class="flex justify-between text-xs text-subtle pb-1 border-b border-slate-700/40">
<span>รายการบริการ / สินค้า (Description)</span>
<span>จำนวนเงิน (THB)</span>
</div>
<div class="flex justify-between items-center py-1.5 text-sm">
<div>
<p class="font-semibold" x-text="selectedBill?.service_name || 'นวดแผนไทยราชสำนัก (120 นาที)'"></p>
<p class="text-xs text-subtle" x-text="`ผู้ให้บริการ: ${selectedBill?.therapist_name || '-'}`"></p>
</div>
<span class="font-mono font-bold" x-text="formatCurrency(selectedBill?.price || 600)">600.00</span>
</div>
<!-- Optional Add-on herbal products -->
<div class="flex justify-between items-center py-1.5 text-sm">
<div class="flex items-center gap-2">
<input type="checkbox" x-model="addonHerbal" @change="recalcTotal()" id="addon1" class="rounded">
<label for="addon1" class="cursor-pointer">ลูกประคบสมุนไพรร้อนออร์แกนิก (1 ลูก)</label>
</div>
<span class="font-mono font-bold text-subtle" x-text="addonHerbal ? '150.00' : '0.00'">150.00</span>
</div>
</div>
<!-- Price Summaries -->
<div class="p-4 rounded-xl bg-slate-900/60 border border-slate-700/60 space-y-2 font-mono text-sm">
<div class="flex justify-between text-subtle">
<span>รวมเป็นเงิน (Subtotal)</span>
<span x-text="formatCurrency(subtotal)">600.00</span>
</div>
<div class="flex justify-between text-subtle items-center">
<span>ส่วนลด (Discount <input type="number" x-model.number="discountPct" @input="recalcTotal()" class="w-12 text-center bg-slate-800 rounded border border-slate-600 px-1 text-white ml-1">%):</span>
<span class="text-red-400" x-text="`- ${formatCurrency(discountAmount)}`">- 0.00</span>
</div>
<div class="flex justify-between text-subtle">
<span>ภาษีมูลค่าเพิ่ม (VAT 7%)</span>
<span x-text="formatCurrency(vatAmount)">42.00</span>
</div>
<div class="flex justify-between text-lg font-bold pt-2 border-t border-slate-700 text-amber-400">
<span>ยอดชำระสุทธิ (Grand Total)</span>
<span x-text="`${formatCurrency(grandTotal)} ฿`">642.00 ฿</span>
</div>
</div>
<!-- Payment Method Tabs -->
<div class="mt-5">
<label class="block text-xs text-subtle mb-2">เลือกวิธีชำระเงิน (Payment Method):</label>
<div class="grid grid-cols-3 gap-3">
<button type="button" @click="paymentMethod = 'Cash'" class="p-3 rounded-xl border text-center font-bold text-sm transition-all flex flex-col items-center gap-1"
:class="paymentMethod === 'Cash' ? 'bg-emerald-500/20 border-emerald-500 text-emerald-400 shadow-md' : 'border-slate-700/60 text-subtle'">
<i class="bi bi-cash-stack text-xl"></i>
<span>เงินสด (Cash)</span>
</button>
<button type="button" @click="paymentMethod = 'PromptPay'" class="p-3 rounded-xl border text-center font-bold text-sm transition-all flex flex-col items-center gap-1"
:class="paymentMethod === 'PromptPay' ? 'bg-cyan-500/20 border-cyan-500 text-cyan-400 shadow-md' : 'border-slate-700/60 text-subtle'">
<i class="bi bi-qr-code text-xl"></i>
<span>สแกนพร้อมเพย์ (QR)</span>
</button>
<button type="button" @click="paymentMethod = 'Credit'" class="p-3 rounded-xl border text-center font-bold text-sm transition-all flex flex-col items-center gap-1"
:class="paymentMethod === 'Credit' ? 'bg-purple-500/20 border-purple-500 text-purple-300 shadow-md' : 'border-slate-700/60 text-subtle'">
<i class="bi bi-credit-card-2-back text-xl"></i>
<span>บัตรเครดิต / ประกัน</span>
</button>
</div>
</div>
<!-- CASH PANEL: Cash received and change -->
<div x-show="paymentMethod === 'Cash'" x-transition class="mt-4 p-4 rounded-xl bg-emerald-950/20 border border-emerald-500/30 space-y-3">
<div class="flex justify-between items-center">
<label class="text-sm font-semibold">รับเงินสดมา (THB):</label>
<div class="flex gap-1">
<button type="button" @click="cashReceived = grandTotal; calcChange()" class="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-300 text-xs font-mono font-bold">พอดี</button>
<button type="button" @click="cashReceived = 1000; calcChange()" class="px-2 py-0.5 rounded bg-slate-700 text-xs font-mono">1,000</button>
<button type="button" @click="cashReceived = 500; calcChange()" class="px-2 py-0.5 rounded bg-slate-700 text-xs font-mono">500</button>
</div>
</div>
<input type="number" x-model.number="cashReceived" @input="calcChange()" placeholder="ระบุจำนวนเงินที่รับมา..." class="glass-input text-right font-mono text-xl font-bold text-amber-400">
<div class="flex justify-between items-center pt-2 border-t border-emerald-500/20 font-mono">
<span class="text-sm">เงินทอน (Change):</span>
<span class="text-xl font-bold" :class="changeAmount < 0 ? 'text-red-400' : 'text-emerald-400'" x-text="`${formatCurrency(changeAmount < 0 ? 0 : changeAmount)} ฿`">0.00 ฿</span>
</div>
</div>
<!-- PROMPTPAY PANEL: Dynamic QR Code Simulator -->
<div x-show="paymentMethod === 'PromptPay'" x-cloak x-transition class="mt-4 p-5 rounded-2xl bg-cyan-950/30 border border-cyan-500/40 text-center space-y-3">
<div class="flex items-center justify-center gap-2 text-cyan-400 font-bold">
<i class="bi bi-qr-code-scan text-xl"></i>
<span>Thai QR Payment (พร้อมเพย์)</span>
</div>
<p class="text-xs text-subtle">สแกนด้วยแอปพลิเคชันธนาคารทุกธนาคาร ยอดชำระจะขึ้นอัตโนมัติ</p>
<!-- Simulated EMVCo PromptPay QR Box -->
<div class="w-48 h-48 mx-auto bg-white p-3 rounded-2xl shadow-xl flex flex-col items-center justify-center border-4 border-cyan-500">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=00020101021229370016A000000677010111011300668999999995802TH5303764540410.006304E07B" alt="PromptPay QR" class="w-full h-full object-contain">
</div>
<p class="font-mono text-base font-bold text-amber-400" x-text="`ยอดชำระ: ${formatCurrency(grandTotal)} บาท`"></p>
<button type="button" @click="simQrPaid()" class="px-4 py-1.5 rounded-full bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500 hover:text-white text-xs font-bold transition-all">
<i class="bi bi-check2-circle"></i> จำลองลูกค้าสแกนจ่ายสำเร็จ (Demo)
</button>
</div>
</div>
<!-- Submit Button -->
<div class="pt-6 mt-6 border-t border-slate-700/60 flex gap-3">
<button type="button" @click="selectedBill = null" class="px-5 py-3 rounded-xl border border-slate-600 text-subtle hover:text-white font-medium text-sm">ยกเลิก</button>
<button type="button" @click="confirmCheckout()" :disabled="processing || (paymentMethod === 'Cash' && cashReceived < grandTotal)" class="flex-1 glass-btn-primary py-3.5 rounded-xl font-bold text-base flex items-center justify-center gap-2">
<span x-show="!processing"><i class="bi bi-printer-fill"></i> ยืนยันชำระเงิน & พิมพ์ใบเสร็จ (ESC/POS)</span>
<span x-show="processing"><i class="bi bi-arrow-repeat animate-spin"></i> กำลังประมวลผล...</span>
</button>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('posApp', () => ({
loading: false,
processing: false,
searchQuery: '',
pendingList: [],
selectedBill: null,
addonHerbal: false,
subtotal: 600,
discountPct: 0,
discountAmount: 0,
vatAmount: 42,
grandTotal: 642,
paymentMethod: 'Cash',
cashReceived: 1000,
changeAmount: 358,
init() {
this.fetchPendingQueues();
},
formatCurrency(val) {
return Number(val || 0).toLocaleString('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
},
get filteredPending() {
if (!this.searchQuery) return this.pendingList;
const q = this.searchQuery.toLowerCase();
return this.pendingList.filter(item =>
(item.queue_no && item.queue_no.toLowerCase().includes(q)) ||
(item.patient_name && item.patient_name.toLowerCase().includes(q)) ||
(item.hn && item.hn.toLowerCase().includes(q))
);
},
async fetchPendingQueues() {
this.loading = true;
try {
const res = await this.apiFetch('/queues?branch_id=1');
if (res && res.success) {
const all = res.data.queues || [];
// แสดงคิวที่Completed รอคิดเงิน หรือ In_Progress ก็คิดเงินล่วงหน้าได้
this.pendingList = all.filter(q => q.status === 'Completed' || q.status === 'In_Progress');
} else {
this.loadMockPending();
}
} catch (e) {
this.loadMockPending();
} finally {
this.loading = false;
}
},
selectBill(item) {
this.selectedBill = item;
this.addonHerbal = false;
this.discountPct = 0;
this.recalcTotal();
this.cashReceived = this.grandTotal;
this.calcChange();
},
recalcTotal() {
if (!this.selectedBill) return;
const base = Number(this.selectedBill.price || 600);
const addon = this.addonHerbal ? 150 : 0;
this.subtotal = base + addon;
this.discountAmount = (this.subtotal * (this.discountPct || 0)) / 100;
const afterDiscount = this.subtotal - this.discountAmount;
this.vatAmount = afterDiscount * 0.07;
this.grandTotal = Math.round(afterDiscount + this.vatAmount);
this.calcChange();
},
calcChange() {
this.changeAmount = (this.cashReceived || 0) - this.grandTotal;
},
simQrPaid() {
Swal.fire({
icon: 'success',
title: 'ได้รับยอดเงินพร้อมเพย์เรียบร้อยแล้ว!',
text: `ยอดเงินโอนเข้าบัญชี: ${this.formatCurrency(this.grandTotal)} บาท`,
timer: 2000,
showConfirmButton: false
});
},
async confirmCheckout() {
this.processing = true;
try {
const res = await this.apiFetch('/billing/checkout', {
method: 'POST',
body: {
queue_id: this.selectedBill.id,
patient_id: this.selectedBill.patient_id || 1,
payment_method: this.paymentMethod,
subtotal: this.subtotal,
discount: this.discountAmount,
vat: this.vatAmount,
grand_total: this.grandTotal,
cash_received: this.cashReceived,
change_amount: this.changeAmount
}
});
if (res && res.success) {
this.showReceiptSuccess(res.data);
} else {
this.showReceiptSuccess({
receipt_no: `INV-20260727-${Math.floor(1000 + Math.random() * 9000)}`,
grand_total: this.grandTotal
});
}
} catch (err) {
this.showReceiptSuccess({
receipt_no: `INV-20260727-${Math.floor(1000 + Math.random() * 9000)}`,
grand_total: this.grandTotal
});
} finally {
this.processing = false;
}
},
showReceiptSuccess(data) {
Swal.fire({
icon: 'success',
title: '🟢 ชำระเงิน & ออกใบเสร็จสำเร็จ!',
html: `
<div class="text-left space-y-2 mt-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-sm font-mono">
<p>• <strong>เลขที่ใบเสร็จ:</strong> ${data.receipt_no}</p>
<p>• <strong>คิวที่ชำระ:</strong> ${this.selectedBill.queue_no} (${this.selectedBill.patient_name})</p>
<p>• <strong>วิธีชำระเงิน:</strong> ${this.paymentMethod}</p>
<p>• <strong>ยอดชำระสุทธิ:</strong> ${this.formatCurrency(data.grand_total || this.grandTotal)} บาท</p>
<p class="text-emerald-400 text-xs mt-2">🖨️ กำลังส่งคำสั่ง ESC/POS Thermal Printer พิมพ์ใบเสร็จ 80mm</p>
</div>
`,
confirmButtonText: '<i class="bi bi-printer"></i> พิมพ์ใบเสร็จซ้ำ',
showCancelButton: true,
cancelButtonText: 'เสร็จสิ้น (กลับหน้ารายการ)',
confirmButtonColor: '#10b981'
}).then(() => {
this.selectedBill = null;
this.fetchPendingQueues();
});
},
loadMockPending() {
this.pendingList = [
{ id: 1, queue_no: 'A003', patient_name: 'คุณสมชาย รักสุขภาพ', hn: 'HN-2407001', service_name: 'นวดแผนไทยราชสำนัก (120 นาที)', therapist_name: 'หมอสมศรี (T-102)', price: 600, status: 'Completed', patient_id: 1 },
{ id: 7, queue_no: 'V002', patient_name: 'คุณอนันต์ มั่งคั่ง', hn: 'HN-2407008', service_name: 'นวดน้ำมันอโรมาเธอราปี (90 นาที)', therapist_name: 'หมอสุชาดา (T-101)', price: 950, status: 'Completed', patient_id: 5 },
{ id: 8, queue_no: 'B005', patient_name: 'คุณวิภาวี ดีเลิศ', hn: 'HN-2407002', service_name: 'นวดกดจุดฝ่าเท้า (60 นาที)', therapist_name: 'หมอใจดี (T-105)', price: 400, status: 'In_Progress', patient_id: 2 }
];
}
}));
});
</script>