diff --git a/klik_pos/api/pos_profile.py b/klik_pos/api/pos_profile.py index 4ef586a..1541dc4 100644 --- a/klik_pos/api/pos_profile.py +++ b/klik_pos/api/pos_profile.py @@ -183,6 +183,7 @@ def get_pos_details(): "custom_allow_duplicate_items_in_pos": int( getattr(pos, "custom_allow_duplicate_items_in_pos", 0) or 0 ), + "cost_center": pos.cost_center or "", } return details diff --git a/klik_spa/src/components/InsuranceModal.tsx b/klik_spa/src/components/InsuranceModal.tsx index 8bb8244..56511f5 100644 --- a/klik_spa/src/components/InsuranceModal.tsx +++ b/klik_spa/src/components/InsuranceModal.tsx @@ -10,6 +10,7 @@ export interface HealthInsuranceOption { mode_of_payment?: string | null; /** When true, patient pays their portion now; insurance portion is credit (invoice partially paid). */ isCredit?: boolean; + userCoverage?: number; // New field for user-entered coverage percentage } interface InsuranceModalProps { @@ -31,6 +32,7 @@ export default function InsuranceModal({ const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [selected, setSelected] = useState(selectedInsurance); const [isCredit, setIsCredit] = useState(true); + const [userCoverage, setUserCoverage] = useState("0"); useEffect(() => { if (isOpen) { @@ -55,12 +57,12 @@ export default function InsuranceModal({ } }, [isOpen, selectedInsurance]); - const handleConfirm = () => { - if (selected) { - onSelect({ ...selected, isCredit }); - onClose(); - } - }; + const handleConfirm = () => { + if (selected) { + onSelect({ ...selected, isCredit, userCoverage }); // add userCoverage + onClose(); + } +}; const handleClear = () => { setSelected(null); @@ -141,12 +143,32 @@ export default function InsuranceModal({ {selected && (
-

- Coverage: - - {selected.insurance_coverage_ ?? 0}% - -

+
+ +
+ { + const val = e.target.value; + if (val === "" || val === "-") { + setUserCoverage(""); + return; + } + const num = Math.min(100, Math.max(0, Number(val))); + setUserCoverage(String(num)); + }} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white pr-8" +/> + + % + +
+
{selected.mode_of_payment && (

Mode of payment: diff --git a/klik_spa/src/components/PaymentDialog.tsx b/klik_spa/src/components/PaymentDialog.tsx index 4b6e040..b89b15c 100644 --- a/klik_spa/src/components/PaymentDialog.tsx +++ b/klik_spa/src/components/PaymentDialog.tsx @@ -164,6 +164,7 @@ export default function PaymentDialog({ const [invoiceData, setInvoiceData] = useState(null); const [roundOffInput, setRoundOffInput] = useState(roundOffAmount.toFixed(3)); const [isAutoPrinting, setIsAutoPrinting] = useState(false); + const [insuranceCoveragePercent, setInsuranceCoveragePercent] = useState(0); const [sharingMode, setSharingMode] = useState( initialSharingMode ); // 'email', 'sms', 'whatsapp' @@ -879,41 +880,61 @@ export default function PaymentDialog({ }; // Auto-fill payment method with grand total and clear others - const handleAutoFillPayment = (methodId: string) => { - if (invoiceSubmitted || isProcessingPayment) return; +const handleAutoFillPayment = (methodId: string) => { + if (invoiceSubmitted || isProcessingPayment) return; - const grandTotal = effectiveGrandTotal; - const newPaymentAmounts: PaymentAmount = {}; + // If insurance is selected, the amount to fill should be the patient's portion only + // not the full grand total + const insuranceMode = selectedHealthInsurance?.mode_of_payment || null; + const isInsuranceMethod = methodId === insuranceMode; - // Set all payment methods to 0 first - paymentMethods.forEach(method => { - newPaymentAmounts[method.id] = 0; - }); + let amountToFill: number; - // Set the selected method to grand total - newPaymentAmounts[methodId] = grandTotal; + if (selectedHealthInsurance) { + const coverage = Number(selectedHealthInsurance.userCoverage) || 0; + const insuranceAmount = roundCurrency((effectiveGrandTotal * coverage) / 100); + const patientAmount = roundCurrency(effectiveGrandTotal - insuranceAmount); + if (isInsuranceMethod) { + // Insurance method gets the insurance portion + amountToFill = insuranceAmount; + } else { + // Patient/cash method gets the patient portion + amountToFill = patientAmount; + } + } else { + // No insurance — fill with full grand total + amountToFill = effectiveGrandTotal; + } - setLastModifiedMethodId(methodId); // Track which method was just modified - setPaymentAmounts(newPaymentAmounts); - setActiveMethodId(methodId); - }; + const newPaymentAmounts: PaymentAmount = {}; - // Handle manual amount adjustment - const handleManualAmountChange = (methodId: string, amount: string) => { - if (invoiceSubmitted || isProcessingPayment) return; + // Zero out all methods first + paymentMethods.forEach(method => { + newPaymentAmounts[method.id] = 0; + }); - const numericAmount = roundCurrency(parseFloat(amount) || 0); - // const grandTotal = effectiveGrandTotal; + // Keep insurance method amount if it exists and we're filling a non-insurance method + if (selectedHealthInsurance && insuranceMode && !isInsuranceMethod) { + const coverage = Number(selectedHealthInsurance.userCoverage) || 0; + const isCredit = selectedHealthInsurance.isCredit !== false; + if (!isCredit) { + // Insurance is paying now — keep its amount + const insuranceAmount = roundCurrency((effectiveGrandTotal * coverage) / 100); + newPaymentAmounts[insuranceMode] = insuranceAmount; + } else { + newPaymentAmounts[insuranceMode] = 0; + } + } + + newPaymentAmounts[methodId] = amountToFill; + + setLastModifiedMethodId(methodId); + setPaymentAmounts(newPaymentAmounts); + setActiveMethodId(methodId); +}; - // Update the payment amount and let the adjustment useEffect handle the logic - setLastModifiedMethodId(methodId); - setPaymentAmounts((prev) => ({ - ...prev, - [methodId]: numericAmount, - })); - }; const handleRoundOff = () => { if (invoiceSubmitted || isProcessingPayment) return; @@ -1200,7 +1221,7 @@ export default function PaymentDialog({ loyaltyPoints: redeemLoyaltyPoints ?? 0, healthInsurance: selectedHealthInsurance?.name || null, insuranceAmount: selectedHealthInsurance - ? roundCurrency((effectiveGrandTotal * (Number(selectedHealthInsurance.insurance_coverage_) || 0)) / 100) + ? roundCurrency((effectiveGrandTotal * (Number(selectedHealthInsurance.userCoverage) || 0)) / 100) : 0, insuranceIsCredit: selectedHealthInsurance?.isCredit !== false, }; @@ -1341,33 +1362,25 @@ export default function PaymentDialog({ }; const handleInsuranceSelect = (insurance: HealthInsuranceOption | null) => { - setSelectedHealthInsurance(insurance); - setShowInsuranceModal(false); - if (!insurance || invoiceSubmitted || isProcessingPayment) return; - const coverage = Number(insurance.insurance_coverage_) || 0; - const insuranceMode = insurance.mode_of_payment || null; - if (!insuranceMode || coverage <= 0) return; - const grandTotal = effectiveGrandTotal; - const insuranceAmount = roundCurrency((grandTotal * coverage) / 100); - const patientAmount = roundCurrency(grandTotal - insuranceAmount); - const cashMode = modes.find((m) => (m.type || "").toLowerCase() === "cash")?.mode_of_payment - || modes[0]?.mode_of_payment; - if (!cashMode) return; - const isCredit = insurance.isCredit !== false; - if (isCredit) { - // Patient pays their portion now; insurance pays later (invoice partially paid) - setPaymentAmounts({ - [insuranceMode]: 0, - [cashMode]: patientAmount, - }); - } else { - setPaymentAmounts({ - [insuranceMode]: insuranceAmount, - [cashMode]: patientAmount, - }); - } - }; - + setSelectedHealthInsurance(insurance); + setShowInsuranceModal(false); + if (!insurance || invoiceSubmitted || isProcessingPayment) return; + const coverage = Number(insurance.userCoverage) || 0; + const insuranceMode = insurance.mode_of_payment || null; + if (!insuranceMode || coverage <= 0) return; + const grandTotal = effectiveGrandTotal; + const insuranceAmount = roundCurrency((grandTotal * coverage) / 100); // ← use coverage directly + const patientAmount = roundCurrency(grandTotal - insuranceAmount); + const cashMode = modes.find((m) => (m.type || "").toLowerCase() === "cash")?.mode_of_payment + || modes[0]?.mode_of_payment; + if (!cashMode) return; + const isCredit = insurance.isCredit !== false; + if (isCredit) { + setPaymentAmounts({ [insuranceMode]: 0, [cashMode]: patientAmount }); + } else { + setPaymentAmounts({ [insuranceMode]: insuranceAmount, [cashMode]: patientAmount }); + } +}; const clearInsuranceSelection = () => { if (invoiceSubmitted || isProcessingPayment) return; setSelectedHealthInsurance(null); diff --git a/klik_spa/src/pages/ClosingShiftPage.tsx b/klik_spa/src/pages/ClosingShiftPage.tsx index d2bd5d8..883e04d 100644 --- a/klik_spa/src/pages/ClosingShiftPage.tsx +++ b/klik_spa/src/pages/ClosingShiftPage.tsx @@ -7,7 +7,8 @@ import { MonitorX, X, RotateCcw, - AlertCircle + AlertCircle, + Download, } from "lucide-react"; import InvoiceViewModal from "../components/InvoiceViewModal"; @@ -27,6 +28,7 @@ import { ConfirmDialog } from "../components/ui/ConfirmDialog"; import { formatCurrency } from "../utils/currency"; import { isToday, isThisWeek, isThisMonth, isThisYear } from "../utils/time"; import { clearAllCache } from "../utils/clearCache"; +import { exportToCSV, exportToPDF } from "../utils/exportInvoice"; export default function ClosingShiftPage() { const navigate = useNavigate(); @@ -39,6 +41,7 @@ export default function ClosingShiftPage() { const [showInvoiceModal, setShowInvoiceModal] = useState(false); const [showCloseModal, setShowCloseModal] = useState(false); const [closingAmounts, setClosingAmounts] = useState({}); + const [showExportMenu, setShowExportMenu] = useState(false); // Draft Invoice Edit states // const [showEditOptions, setShowEditOptions] = useState(false); @@ -547,11 +550,36 @@ export default function ClosingShiftPage() { {/* Invoices Table */}

-
-

- All Invoices ({filteredInvoices.length}) -

-
+
+

+ All Invoices ({filteredInvoices.length}) +

+
+ + {showExportMenu && ( +
+ + +
+ )} +
+
@@ -891,11 +919,36 @@ export default function ClosingShiftPage() { {/* Invoices Table */}
-
-

- All Invoices ({filteredInvoices.length}) -

-
+
+

+ All Invoices ({filteredInvoices.length}) +

+
+ + {showExportMenu && ( +
+ + +
+ )} +
+
diff --git a/klik_spa/src/utils/exportInvoice.ts b/klik_spa/src/utils/exportInvoice.ts new file mode 100644 index 0000000..a10a226 --- /dev/null +++ b/klik_spa/src/utils/exportInvoice.ts @@ -0,0 +1,122 @@ +import { formatCurrency } from "./currency"; +import type { SalesInvoice } from "../../types"; + +export const fetchLetterHead = async (costCenter: string): Promise => { + if (!costCenter) return null; + try { + const res = await fetch( + `/api/resource/Cost Center/${encodeURIComponent(costCenter)}?fields=["custom_letter_head"]`, + { credentials: "include" } + ); + const data = await res.json(); + return data?.data?.custom_letter_head || null; + } catch { + return null; + } +}; + +export const fetchLetterHeadHtml = async (letterHeadName: string): Promise => { + if (!letterHeadName) return null; + try { + const res = await fetch( + `/api/resource/Letter Head/${encodeURIComponent(letterHeadName)}?fields=["content", "footer"]`, + { credentials: "include" } + ); + const data = await res.json(); + return data?.data?.content || null; + } catch { + return null; + } +}; + +export const exportToCSV = (filteredInvoices: SalesInvoice[]) => { + const headers = ["Invoice", "Date", "Customer", "Cashier", "Payment", "Amount", "Status"]; + const rows = filteredInvoices.map(inv => [ + inv.id, + `${inv.date} ${inv.time}`, + inv.customer, + inv.cashier || "", + inv.paymentMethod || "", + inv.totalAmount, + inv.status, + ]); + + const csvContent = [headers, ...rows] + .map(row => row.map(cell => `"${cell}"`).join(",")) + .join("\n"); + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `invoices_${new Date().toISOString().slice(0, 10)}.csv`; + link.click(); + URL.revokeObjectURL(url); +}; + +export const exportToPDF = async ( + filteredInvoices: SalesInvoice[], + currency: string = "USD", + costCenter?: string | null +) => { + const printWindow = window.open("", "_blank"); + if (!printWindow) return; + + // Fetch letterhead if cost center is provided + let letterHeadHtml = ""; + if (costCenter) { + const letterHeadName = await fetchLetterHead(costCenter); + if (letterHeadName) { + const html = await fetchLetterHeadHtml(letterHeadName); + if (html) letterHeadHtml = html; + } + } + + const rows = filteredInvoices.map(inv => ` + + + + + + + + + + `).join(""); + + printWindow.document.write(` + + + Invoices Export + + + + ${letterHeadHtml ? `
${letterHeadHtml}
` : ""} +
+

Invoices (${filteredInvoices.length})

+
${inv.id}${inv.date} ${inv.time}${inv.customer}${inv.cashier || ""}${inv.paymentMethod || ""}${formatCurrency(inv.totalAmount, inv.currency || currency)}${inv.status}
+ + + + + + + ${rows} +
InvoiceDateCustomerCashierPaymentAmountStatus
+
+ + + `); + printWindow.document.close(); + printWindow.print(); +}; \ No newline at end of file diff --git a/klik_spa/types/index.ts b/klik_spa/types/index.ts index 55a141f..dde2237 100644 --- a/klik_spa/types/index.ts +++ b/klik_spa/types/index.ts @@ -279,6 +279,7 @@ export interface POSProfile { write_off_account?: string; write_off_cost_center?: string; payment_methods?: PaymentMode[]; + cost_center?: string; // Add other fields as needed }