Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions klik_pos/api/pos_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 34 additions & 12 deletions klik_spa/src/components/InsuranceModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -31,6 +32,7 @@ export default function InsuranceModal({
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [selected, setSelected] = useState<HealthInsuranceOption | null>(selectedInsurance);
const [isCredit, setIsCredit] = useState(true);
const [userCoverage, setUserCoverage] = useState<string>("0");

useEffect(() => {
if (isOpen) {
Expand All @@ -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);
Expand Down Expand Up @@ -141,12 +143,32 @@ export default function InsuranceModal({

{selected && (
<div className="mb-4 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm space-y-2">
<p>
<span className="text-gray-600 dark:text-gray-400">Coverage: </span>
<span className="font-medium text-gray-900 dark:text-white">
{selected.insurance_coverage_ ?? 0}%
</span>
</p>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Coverage (%)
</label>
<div className="relative flex items-center">
<input
type="number"
min={0}
max={100}
value={userCoverage}
onChange={(e) => {
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"
/>
<span className="absolute right-3 text-gray-500 dark:text-gray-400 pointer-events-none">
%
</span>
</div>
</div>
{selected.mode_of_payment && (
<p>
<span className="text-gray-600 dark:text-gray-400">Mode of payment: </span>
Expand Down
121 changes: 67 additions & 54 deletions klik_spa/src/components/PaymentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ export default function PaymentDialog({
const [invoiceData, setInvoiceData] = useState<any>(null);
const [roundOffInput, setRoundOffInput] = useState(roundOffAmount.toFixed(3));
const [isAutoPrinting, setIsAutoPrinting] = useState(false);
const [insuranceCoveragePercent, setInsuranceCoveragePercent] = useState<number>(0);
const [sharingMode, setSharingMode] = useState<string | null>(
initialSharingMode
); // 'email', 'sms', 'whatsapp'
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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);
Expand Down
75 changes: 64 additions & 11 deletions klik_spa/src/pages/ClosingShiftPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
MonitorX,
X,
RotateCcw,
AlertCircle
AlertCircle,
Download,
} from "lucide-react";

import InvoiceViewModal from "../components/InvoiceViewModal";
Expand All @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -547,11 +550,36 @@ export default function ClosingShiftPage() {

{/* Invoices Table */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="px-4 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
All Invoices ({filteredInvoices.length})
</h3>
</div>
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
All Invoices ({filteredInvoices.length})
</h3>
<div className="relative">
<button
onClick={() => setShowExportMenu(prev => !prev)}
className="flex items-center space-x-2 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm"
>
<Download className="w-4 h-4" />
<span>Download</span>
</button>
{showExportMenu && (
<div className="absolute right-0 mt-1 w-36 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg z-10">
<button
onClick={() => { exportToCSV(filteredInvoices); setShowExportMenu(false); }}
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-t-lg"
>
Export Excel (CSV)
</button>
<button
onClick={() => { exportToPDF(filteredInvoices, posDetails?.currency, posDetails?.cost_center); setShowExportMenu(false); }}
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-b-lg"
>
Export PDF
</button>
</div>
)}
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
Expand Down Expand Up @@ -891,11 +919,36 @@ export default function ClosingShiftPage() {

{/* Invoices Table */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
All Invoices ({filteredInvoices.length})
</h3>
</div>
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
All Invoices ({filteredInvoices.length})
</h3>
<div className="relative">
<button
onClick={() => setShowExportMenu(prev => !prev)}
className="flex items-center space-x-2 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm"
>
<Download className="w-4 h-4" />
<span>Download</span>
</button>
{showExportMenu && (
<div className="absolute right-0 mt-1 w-36 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg z-10">
<button
onClick={() => { exportToCSV(filteredInvoices); setShowExportMenu(false); }}
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-t-lg"
>
Export Excel (CSV)
</button>
<button
onClick={() => { exportToPDF(filteredInvoices, posDetails?.currency, posDetails?.cost_center); setShowExportMenu(false); }}
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-b-lg"
>
Export PDF
</button>
</div>
)}
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
Expand Down
Loading
Loading