Bệnh Viện Đa Khoa Quốc Tế Tân An

Phiếu xác nhận sử dụng voucher

Ngày: ${dateStr} | Mã GD: ${receiptData.txId || ''}

${household ? `` : ''} ${category ? `` : ''} ${patientYob ? `` : ''} ${patientPhone ? `` : ''} ${patientType ? `` : ''} ${staffName ? `` : ''} ${fmtBal ? `` : ''}
Mã Voucher${voucherCode || ''}
Hộ gia đình${household}
Đối tượng${category}
Bệnh nhân${patientName || '______________________'}
Năm sinh${patientYob}
Số điện thoại${patientPhone}
Đối tượng KH${patientType}
Dịch vụ${service || '______________________'}
Nhân viên thực hiện${staffName}
Số dư sau giao dịch${fmtBal}đ
Số tiền voucher thanh toán
${fmtAmt}đ
Nhân viên thực hiện

${staffName || '(Ký và ghi rõ họ tên)'}

Khách hàng / Người nhà
${signature ? `` : '

(Ký và ghi rõ họ tên)

'}
`; // Use hidden iframe to print (avoids popup blockers) const iframe = document.createElement('iframe'); iframe.style.cssText = 'position:fixed;top:-9999px;left:-9999px;width:600px;height:800px;border:none;'; document.body.appendChild(iframe); iframe.contentDocument.open(); iframe.contentDocument.write(htmlContent); iframe.contentDocument.close(); iframe.onload = () => { try { iframe.contentWindow.focus(); iframe.contentWindow.print(); } catch (e) { console.error('Print error:', e); } setTimeout(() => document.body.removeChild(iframe), 2000); }; // Fallback: if onload doesn't fire (some browsers) setTimeout(() => { try { if (document.body.contains(iframe)) { iframe.contentWindow.focus(); iframe.contentWindow.print(); setTimeout(() => { if (document.body.contains(iframe)) document.body.removeChild(iframe); }, 2000); } } catch (e) { } }, 1000); }; // ===== SEED INITIAL DATA TO FIRESTORE ===== const handleSeedData = async () => { if (!window.confirm("Nạp dữ liệu mẫu vào Firebase? (Chỉ dùng lần đầu)")) return; showNotification("Đang nạp dữ liệu mẫu...", "info"); const batch = db.batch(); INITIAL_VOUCHERS.forEach(v => { const ref = db.collection('vouchers').doc(v.code); batch.set(ref, v); }); INITIAL_TRANSACTIONS.forEach(tx => { const ref = db.collection('transactions').doc(tx.id); batch.set(ref, tx); }); await batch.commit(); showNotification("Đã nạp dữ liệu mẫu thành công!"); }; // ===== RESET: Delete all docs & re-seed ===== const handleResetData = async () => { if (!window.confirm("Bạn có chắc chắn muốn xóa toàn bộ dữ liệu và nạp lại mặc định?")) return; showNotification("Đang xóa dữ liệu cũ...", "info"); // Delete all vouchers const vSnap = await db.collection('vouchers').get(); const batch1 = db.batch(); vSnap.docs.forEach(doc => batch1.delete(doc.ref)); await batch1.commit(); // Delete all transactions const tSnap = await db.collection('transactions').get(); const batch2 = db.batch(); tSnap.docs.forEach(doc => batch2.delete(doc.ref)); await batch2.commit(); // Re-seed const batch3 = db.batch(); INITIAL_VOUCHERS.forEach(v => batch3.set(db.collection('vouchers').doc(v.code), v)); INITIAL_TRANSACTIONS.forEach(tx => batch3.set(db.collection('transactions').doc(tx.id), tx)); await batch3.commit(); logActivity('reset: Khôi phục dữ liệu gốc', 'Xóa toàn bộ dữ liệu và nạp lại mặc định'); showNotification("Đã khôi phục dữ liệu gốc.", "info"); setView('list'); }; /* ========== VOUCHER LIST VIEW ========== */ const VoucherListView = () => { const [searchTerm, setSearchTerm] = useState(''); const [selectedCategory, setSelectedCategory] = useState('Tất cả'); const [selectedType, setSelectedType] = useState('ALL'); const excelInputRef = useRef(null); const filteredVouchers = vouchers.filter(v => { const matchesSearch = v.household.toLowerCase().includes(searchTerm.toLowerCase()) || v.code.toLowerCase().includes(searchTerm.toLowerCase()); const matchesCategory = selectedCategory === 'Tất cả' || v.category === selectedCategory; const matchesType = selectedType === 'ALL' || v.type === selectedType; return matchesSearch && matchesCategory && matchesType; }); const availableCategories = ['Tất cả', ...new Set(vouchers.map(v => v.category || 'Khác'))]; const handleExcelUpload = async (e) => { const file = e.target.files[0]; if (!file) return; setIsImporting(true); showNotification("Đang đọc dữ liệu từ file Excel...", "info"); try { const data = await file.arrayBuffer(); const workbook = XLSX.read(data, { type: 'array' }); const sheetName = workbook.SheetNames[0]; const sheet = workbook.Sheets[sheetName]; const rows = XLSX.utils.sheet_to_json(sheet); if (rows.length === 0) { showNotification("File Excel trống hoặc không đúng định dạng.", "error"); setIsImporting(false); return; } const colNames = Object.keys(rows[0]); console.log('=== EXCEL DEBUG ==='); console.log('Column count:', colNames.length); console.log('Column names:', colNames); console.log('Column names (hex):', colNames.map(n => Array.from(n).map(c => c.charCodeAt(0).toString(16)).join(' '))); console.log('First row values:', colNames.map(c => `${c} = ${rows[0][c]} (${typeof rows[0][c]})`)); // Helper: normalize Vietnamese Unicode (NFC) and lowercase for comparison const norm = (s) => String(s).normalize('NFC').toLowerCase().trim(); // Smart column finder with Unicode normalization const findColIdx = (...keywords) => { // 1. Normalized exact match for (const kw of keywords) { const idx = colNames.findIndex(c => norm(c) === norm(kw)); if (idx !== -1) return idx; } // 2. Normalized partial match (column contains keyword) for (const kw of keywords) { const idx = colNames.findIndex(c => norm(c).includes(norm(kw))); if (idx !== -1) return idx; } return -1; }; // Detect column indices let codeIdx = findColIdx('mã voucher', 'ma voucher', 'mã phiếu', 'ma phieu', 'code', 'voucher'); let expiryIdx = findColIdx('hạn sử dụng', 'han su dung', 'hạn sd', 'expiry', 'ngày hết hạn', 'het han'); let balanceIdx = findColIdx('giá trị', 'gia tri', 'số dư', 'so du', 'số tiền', 'so tien', 'balance', 'amount', 'value'); let householdIdx = findColIdx('hộ gia đình', 'ho gia dinh', 'tên', 'ten', 'household', 'chủ hộ', 'họ tên', 'name'); let typeIdx = findColIdx('loại', 'loai', 'type'); let categoryIdx = findColIdx('đối tượng', 'doi tuong', 'category'); // FALLBACK: If key columns not found by name, use position // For 3-column files: col0=code, col1=expiry, col2=balance if (balanceIdx === -1) { console.log('⚠️ Balance column not found by name. Using position-based fallback.'); if (colNames.length === 3) { codeIdx = 0; expiryIdx = 1; balanceIdx = 2; console.log('→ 3 columns: [0]=code, [1]=expiry, [2]=balance'); } else if (colNames.length === 5) { codeIdx = 0; expiryIdx = 1; balanceIdx = 2; categoryIdx = 3; typeIdx = 4; console.log('→ 5 columns: [0]=code, [1]=expiry, [2]=balance, [3]=category, [4]=type'); } else { // Try: find the first column with a numeric value for (let i = 0; i < colNames.length; i++) { const val = rows[0][colNames[i]]; const num = typeof val === 'number' ? val : parseInt(String(val).replace(/[.,\s đ]/g, ''), 10); if (!isNaN(num) && num > 1000) { balanceIdx = i; console.log(`→ Auto-detected numeric balance at col[${i}]="${colNames[i]}"`); break; } } } } console.log('Column mapping:', { codeIdx, expiryIdx, balanceIdx, householdIdx, typeIdx, categoryIdx }); // Parse number helper const parseAmount = (val) => { if (val === undefined || val === null || val === '') return 0; if (typeof val === 'number') return Math.round(val); const cleaned = String(val).replace(/[.,\s]/g, '').replace(/[đĐ₫VND]/gi, '').trim(); const n = parseInt(cleaned, 10); return isNaN(n) ? 0 : n; }; const newVouchers = rows.map((row, idx) => { const vals = colNames.map(c => row[c]); const code = codeIdx !== -1 ? String(vals[codeIdx] || '').trim() : `V-IMP-${Date.now()}-${idx}`; const household = householdIdx !== -1 ? String(vals[householdIdx] || '').trim() : `Voucher #${idx + 1}`; const balance = balanceIdx !== -1 ? parseAmount(vals[balanceIdx]) : 0; const expiry = expiryIdx !== -1 ? String(vals[expiryIdx] || '31/12/2025').trim() : '31/12/2025'; const rawType = typeIdx !== -1 ? String(vals[typeIdx] || '').trim().toLowerCase() : 'standard'; const category = categoryIdx !== -1 ? String(vals[categoryIdx] || 'Khác').trim() : 'Khác'; if (idx < 3) console.log(`Row ${idx}: code="${code}" balance=${balance} (raw: ${balanceIdx !== -1 ? vals[balanceIdx] : 'N/A'})`); return { code: code || `V-IMP-${Date.now()}-${idx}`, household: household.startsWith('Hộ:') ? household : `Hộ: ${household}`, balance: balance, status: balance > 0 ? 'Hoạt động' : 'Hết hạn mức', expiry: expiry, type: rawType === 'one-time' || rawType === '1 lần' || rawType === '1 lan' ? 'one-time' : 'standard', category: category }; }); // Show preview before importing const preview = newVouchers.slice(0, 3).map(v => `• ${v.code}: ${new Intl.NumberFormat('vi-VN').format(v.balance)}đ`).join('\n'); const confirmMsg = `Phát hiện ${newVouchers.length} voucher.\nCột: ${colNames.join(' | ')}\n\nXem trước:\n${preview}\n\nBấm OK để nạp vào Firebase.`; if (!window.confirm(confirmMsg)) { setIsImporting(false); return; } // Write to Firestore in batches let imported = 0; for (let i = 0; i < newVouchers.length; i += 450) { const chunk = newVouchers.slice(i, i + 450); const batch = db.batch(); chunk.forEach(v => batch.set(db.collection('vouchers').doc(v.code), v)); await batch.commit(); imported += chunk.length; } setIsImporting(false); showNotification(`✅ Đã nhập thành công ${imported} phiếu từ Excel vào Firebase!`); logActivity('import: Nạp dữ liệu từ Excel', `Đã import ${imported} voucher từ file Excel`); } catch (err) { console.error('Excel import error:', err); setIsImporting(false); showNotification(`Lỗi đọc file: ${err.message}`, 'error'); } if (excelInputRef.current) excelInputRef.current.value = ''; }; return (
{/* Header */}

Danh sách Phiếu khám

Tổng: {vouchers.length} phiếu | Hiển thị: {filteredVouchers.length} | Firebase
setSearchTerm(e.target.value)} /> {searchTerm && ( )}
{/* Filters */}
Loại Phiếu:
{[['ALL', 'Tất cả'], ['standard', 'Thường (Ví tiền)'], ['one-time', 'Dùng 1 Lần']].map(([val, label]) => ( ))}
Đối Tượng:
{/* Cards */} {filteredVouchers.length === 0 ? (

Không tìm thấy kết quả

Không có phiếu nào khớp với điều kiện lọc.

) : (
{filteredVouchers.map((voucher) => { const categoryClass = CATEGORY_STYLES[voucher.category] || CATEGORY_STYLES["Khác"]; return (
{voucher.category || "Khác"}

{voucher.household}

{voucher.status}

{voucher.code}

{voucher.type === 'one-time' && ( 1 Lần )}
{voucher.code.includes("V-NEW") && (
Mới thêm
)}

Số dư hiện tại

0 && voucher.status === 'Hoạt động' ? 'text-blue-600' : 'text-slate-400'}`}>{formatCurrency(voucher.balance)}

); })}
)}
); }; /* ========== DEDUCTION FORM ========== */ const DeductionForm = () => { const [formData, setFormData] = useState({ patientName: '', patientPhone: '', patientYob: '', patientType: 'Khách thường', service: '', amount: '', staffId: '', pin: '', signatureData: null, attachments: [] }); const [isSigned, setIsSigned] = useState(false); const [signMethod, setSignMethod] = useState('digital'); const [error, setError] = useState(''); const [step, setStep] = useState(1); const fileInputRef = useRef(null); // Compute staff in component scope so print button can access it const currentStaff = INITIAL_STAFF.find(s => s.id === parseInt(formData.staffId)); const handleNext = () => { if (!formData.patientName) { setError("Vui lòng nhập tên người sử dụng dịch vụ."); return; } if (!formData.service || !formData.amount || !formData.staffId || !formData.pin) { setError("Vui lòng điền đầy đủ thông tin dịch vụ và nhân viên."); return; } const amount = parseInt(formData.amount); if (isNaN(amount) || amount <= 0) { setError("Số tiền không hợp lệ."); return; } if (amount > selectedVoucher.balance) { setError("Số dư không đủ."); return; } const staff = INITIAL_STAFF.find(s => s.id === parseInt(formData.staffId)); if (!staff) { setError("Nhân viên không hợp lệ."); return; } if (staff.pin !== formData.pin) { setError("Mã PIN không chính xác."); return; } setError(''); setStep(2); }; const handleFileUpload = (e) => { if (e.target.files && e.target.files.length > 0) { const newFiles = Array.from(e.target.files).map(file => ({ name: file.name, url: URL.createObjectURL(file), type: file.type.startsWith('image') ? 'image' : 'file', size: (file.size / 1024).toFixed(1) + ' KB' })); setFormData(prev => ({ ...prev, attachments: [...prev.attachments, ...newFiles] })); } }; const removeAttachment = (idx) => { setFormData(prev => ({ ...prev, attachments: prev.attachments.filter((_, i) => i !== idx) })); }; const handleSubmit = async () => { if (signMethod === 'digital' && (!isSigned || !formData.signatureData)) { setError("Vui lòng ký tên xác nhận."); return; } // Paper signing: attachments are optional const amount = parseInt(formData.amount); const staff = INITIAL_STAFF.find(s => s.id === parseInt(formData.staffId)); const txId = `TX-${Math.floor(Math.random() * 10000)}`; const newTx = { id: txId, voucherCode: selectedVoucher.code, date: new Date().toISOString(), amount, staffName: staff.name, service: formData.service, patientName: formData.patientName, patientPhone: formData.patientPhone, patientYob: formData.patientYob, patientType: formData.patientType, signature: signMethod === 'digital' ? formData.signatureData : null, attachments: formData.attachments.map(a => ({ name: a.name, type: a.type, url: a.url || '' })) }; // Calculate new balance & status const newBalance = selectedVoucher.balance - amount; let newStatus = selectedVoucher.status; if (selectedVoucher.type === 'one-time') newStatus = "Đã sử dụng"; else if (newBalance === 0) newStatus = "Hết hạn mức"; // Write to Firestore (batch: update voucher + add transaction) const batch = db.batch(); const voucherDocId = selectedVoucher._id || selectedVoucher.code; batch.update(db.collection('vouchers').doc(voucherDocId), { balance: newBalance, status: newStatus }); batch.set(db.collection('transactions').doc(txId), newTx); await batch.commit(); showNotification("Giao dịch thành công. Đã lưu lên Firebase!", "success"); logActivity('transaction: Thanh toán voucher', `Mã ${selectedVoucher.code} - ${formData.patientName} - ${formatCurrency(amount)} - DV: ${formData.service}`); setView('list'); }; return (

Thanh toán

Mã phiếu: {selectedVoucher.code}

{selectedVoucher.category || "Khác"}
{/* Step indicator */}
1
Thông tin
2
Xác nhận
{error && (
{error}
)} {selectedVoucher.type === 'one-time' && (
Lưu ý: Đây là voucher dùng 1 lần. Sẽ tự động khóa sau giao dịch này.
)} {step === 1 ? (

Thông tin người sử dụng

setFormData({ ...formData, patientName: e.target.value })} />
setFormData({ ...formData, patientYob: e.target.value })} />
setFormData({ ...formData, patientPhone: e.target.value })} />

setFormData({ ...formData, service: e.target.value })} />
setFormData({ ...formData, amount: e.target.value })} />
VND

Khả dụng: {formatCurrency(selectedVoucher.balance)}

setFormData({ ...formData, pin: e.target.value })} />
) : (
Khách hàng
{formData.patientName} {formData.patientType === 'Khách VIP' && ( VIP )}
{formData.patientYob ? `Năm sinh: ${formData.patientYob}` : ''} {formData.patientPhone ? ` • SĐT: ${formData.patientPhone}` : ''}
{formData.service}
{formatCurrency(formData.amount)}
{signMethod === 'digital' ? (
setFormData({ ...formData, signatureData: dataUrl })} />
) : (

Bước 1: In phiếu

Bước 2: Đính kèm bằng chứng (không bắt buộc)

{formData.attachments.length > 0 && (
{formData.attachments.map((file, idx) => (
{file.name} {file.size}
))}

Đã đính kèm {formData.attachments.length} file

)}
)}
)}
); }; /* ========== HISTORY VIEW ========== */ const HistoryView = () => { const voucherTxns = transactions.filter(t => t.voucherCode === selectedVoucher.code); return (

Lịch sử Giao dịch

{selectedVoucher.household} • {selectedVoucher.code}

{voucherTxns.length === 0 ? (
Chưa có giao dịch nào được ghi nhận.
) : voucherTxns.map((txn) => (
{txn.patientName || "Khách vãng lai"} {txn.patientType === 'Khách VIP' && ( )} {txn.patientYob && {txn.patientYob}}
{txn.service}
{new Date(txn.date).toLocaleTimeString('vi-VN')} • {new Date(txn.date).toLocaleDateString('vi-VN')}
Thực hiện bởi: {txn.staffName}
-{formatCurrency(txn.amount)}
{txn.id}

Bằng chứng xác nhận

{txn.signature && (

Chữ ký điện tử:

Chữ ký
)} {txn.attachments && txn.attachments.length > 0 && (

Tài liệu đính kèm:

{txn.attachments.map((file, idx) => ( {file.name} ))}
)} {!txn.signature && (!txn.attachments || txn.attachments.length === 0) && (
Chưa có bằng chứng số hóa
)}
))}
); }; /* ========== ALL HISTORY VIEW (Global) ========== */ const AllHistoryView = () => { const [activeTab, setActiveTab] = useState('transactions'); const ACTION_ICONS = { 'import': 'file-excel', 'transaction': 'exchange-alt', 'reset': 'trash-alt', 'seed': 'database', 'delete': 'times-circle' }; const ACTION_COLORS = { 'import': 'text-green-600 bg-green-50', 'transaction': 'text-blue-600 bg-blue-50', 'reset': 'text-red-600 bg-red-50', 'seed': 'text-purple-600 bg-purple-50', 'delete': 'text-orange-600 bg-orange-50' }; return (

Lịch sử hệ thống

Tất cả giao dịch và thao tác trên hệ thống

{/* Tab Navigation */}
{/* Tab Content */} {activeTab === 'transactions' ? (
{transactions.length === 0 ? (
Chưa có giao dịch nào.
) : transactions.map((txn) => (
{txn.patientName || 'Khách vãng lai'} {txn.voucherCode} {txn.patientType === 'Khách VIP' && }
{txn.service}
{new Date(txn.date).toLocaleString('vi-VN')} • NV: {txn.staffName}
-{formatCurrency(txn.amount)}
{txn.id}
{txn.signature && Có chữ ký} {txn.attachments && txn.attachments.length > 0 && {txn.attachments.length} file}
))}
) : (
{activityLog.length === 0 ? (
Chưa có thao tác nào được ghi nhận.
) : activityLog.map((log) => { const actionType = log.action ? log.action.split(':')[0] : 'other'; const colorClass = ACTION_COLORS[actionType] || 'text-slate-600 bg-slate-50'; const iconName = ACTION_ICONS[actionType] || 'info-circle'; return (
{log.action}
{log.details &&
{log.details}
}
{log.timestamp ? new Date(log.timestamp).toLocaleString('vi-VN') : ''} {log.user && • {log.user}}
); })}
)}
); }; /* ========== MAIN RENDER ========== */ return (
Logo Bệnh viện Đa khoa Tân An { e.target.onerror = null; e.target.style.display = 'none'; e.target.parentElement.innerHTML = '
LOGO
'; }} />

Bệnh viện Đa khoa Tân An

Hệ thống Quản lý Voucher • Tan An Hospital

Đã đăng nhập
BVĐK Tân An
{notification && (
{notification.type === 'success' ? : } {notification.message}
)}
{isLoading ? (

Đang kết nối Firebase...

) : vouchers.length === 0 && view === 'list' ? (

Chưa có dữ liệu

Firebase đã kết nối nhưng chưa có voucher nào. Nhấn nút bên dưới để nạp dữ liệu mẫu.

) : ( <> {view === 'list' && } {view === 'deduct' && selectedVoucher && } {view === 'history' && selectedVoucher && } {view === 'allHistory' && } )}
); } // Mount the React app const root = ReactDOM.createRoot(document.getElementById('voucher-root')); root.render();