diff --git a/klik_pos/api/sales_order.py b/klik_pos/api/sales_order.py index 6e795ad..521e339 100644 --- a/klik_pos/api/sales_order.py +++ b/klik_pos/api/sales_order.py @@ -42,6 +42,15 @@ def _normalize_medication_orders(data): return [o for o in orders if isinstance(o, str) and o.strip()] +def _normalize_batch_nos(batch_nos): + if isinstance(batch_nos, str): + batch_nos = [b.strip() for b in batch_nos.split(",") if b and b.strip()] + elif not isinstance(batch_nos, list): + batch_nos = [batch_nos] + + return [b for b in batch_nos if isinstance(b, str) and b.strip()] + + def _mark_medication_orders_completed(order_names): if not order_names: return @@ -112,6 +121,48 @@ def _resolve_patient_for_hospital_order(data, medication_orders): return None +@frappe.whitelist() +def get_batch_label_details(batch_nos): + try: + normalized_batch_nos = _normalize_batch_nos(batch_nos) + if not normalized_batch_nos: + return {} + + batches = frappe.get_all( + "Batch", + filters={"name": ["in", normalized_batch_nos]}, + fields=["name", "batch_id", "expiry_date"], + ) + + if len(batches) < len(normalized_batch_nos): + existing_names = {b.get("name") for b in batches} + missing = [b for b in normalized_batch_nos if b not in existing_names] + if missing: + batches.extend( + frappe.get_all( + "Batch", + filters={"batch_id": ["in", missing]}, + fields=["name", "batch_id", "expiry_date"], + ) + ) + + result = {} + for batch in batches: + entry = { + "batch_no": batch.get("batch_id") or batch.get("name"), + "expiry_date": str(batch.get("expiry_date")) if batch.get("expiry_date") else None, + } + if batch.get("name"): + result[batch.get("name")] = entry + if batch.get("batch_id"): + result[batch.get("batch_id")] = entry + + return result + except Exception: + frappe.log_error(frappe.get_traceback(), "Get batch label details error") + return {} + + def _create_and_submit_delivery_note_from_sales_order(sales_order_name, pos_profile): """Create submitted Delivery Note from Sales Order so stock updates immediately (not long SO reservation).""" try: diff --git a/klik_spa/src/components/OrderSummary.tsx b/klik_spa/src/components/OrderSummary.tsx index 7b102cf..8f8d3b7 100644 --- a/klik_spa/src/components/OrderSummary.tsx +++ b/klik_spa/src/components/OrderSummary.tsx @@ -36,7 +36,7 @@ import { getPrescriptionFrequencies, type PrescriptionFrequency } from "../servi import { searchPatients, getPendingInpatientMedicationOrders, getPatientMedicationOrderHistory, createPatientVisit, type Patient, type InpatientMedicationOrder } from "../services/patientService"; import { getItemPriceForCustomer } from "../services/dynamicPricing"; import { getItemUOMsAndPrices } from "../services/uomService"; -import { createHospitalSalesOrder } from "../services/salesOrder"; +import { createHospitalSalesOrder, getBatchLabelDetails } from "../services/salesOrder"; interface OrderSummaryProps { @@ -50,6 +50,43 @@ interface OrderSummaryProps { isMobile?: boolean; } +interface DispensedLabelItem { + itemCode: string; + itemName: string; + dosage: string; + frequency: string; + batchNo: string; + expiryDate: string; +} + +const getCartSignature = (items: CartItem[]) => + items + .map((item) => { + const line = item as CartItem & { cartLineId?: string; batch_no?: string; serial_no?: string; medicationOrder?: string; medicationOrders?: string[] }; + return [ + line.cartLineId || item.id, + item.id, + item.quantity, + line.batch_no || "", + line.serial_no || "", + line.medicationOrder || "", + (line.medicationOrders || []).join("|"), + ].join("::"); + }) + .sort() + .join("||"); + +const MEDICATION_LABEL_CSS = ` + body { font-family: Arial, sans-serif; margin: 0; padding: 0; box-sizing: border-box; } + @page { size: 2.299in 1.5in; margin: 0; } + .label-page { width: 2.299in; height: 1.5in; padding: 5px; box-sizing: border-box; page-break-after: always; } + .label-page:last-child { page-break-after: auto; } + .medication-label { width: 100%; height: 100%; border: 1px solid #000; padding: 5px; box-sizing: border-box; overflow: hidden; display: flex; flex-direction: column; justify-content: center; } + .title { font-size: 8px; font-weight: 700; text-align: center; margin-bottom: 3px; } + .item { font-size: 8px; font-weight: 700; text-align: center; margin-bottom: 4px; } + .detail-row { font-size: 7px; line-height: 1.25; margin-bottom: 1px; } +`; + // Component to handle quantity input with local state interface QuantityInputProps { item: CartItem; @@ -113,8 +150,8 @@ const QuantityInput = ({ item, onUpdateQuantity, isMobile }: QuantityInputProps) // Component to handle dosage input with local state (allows empty + decimals) interface DosageInputProps { itemId: string; - value: number | null | undefined; - onChange: (itemId: string, value: number | null) => void; + value: string | number | null | undefined; + onChange: (itemId: string, value: string) => void; isMobile?: boolean; } @@ -132,34 +169,18 @@ const DosageInput = ({ itemId, value, onChange, isMobile }: DosageInputProps) => const handleBlur = () => { setIsEditing(false); const trimmed = inputValue.trim(); - if (trimmed === "") { - onChange(itemId, null); - setInputValue(""); - return; - } - - const numValue = Number(trimmed); - if (Number.isNaN(numValue) || numValue < 0) { - // Invalid input - reset to last known good value - setInputValue(value === null || value === undefined ? "" : String(value)); - return; - } - - // Normalize to remove leading zeros etc. - onChange(itemId, numValue); - setInputValue(String(numValue)); + onChange(itemId, trimmed); + setInputValue(trimmed); }; return ( setInputValue(e.target.value)} onFocus={() => setIsEditing(true)} onBlur={handleBlur} - placeholder="e.g. 9.8" + placeholder="e.g. 1 tablet" className={`w-full ${ isMobile ? "text-sm" : "text-sm" } px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-beveren-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white`} @@ -901,6 +922,8 @@ export default function OrderSummary({ const [patientVisitCreatedSignal, setPatientVisitCreatedSignal] = useState(0); const [isDispensing, setIsDispensing] = useState(false); const [lastDispensedSalesOrder, setLastDispensedSalesOrder] = useState(null); + const [lastDispensedLabelItems, setLastDispensedLabelItems] = useState([]); + const [lastDispensedCartSignature, setLastDispensedCartSignature] = useState(null); // const couponButtonRef = useRef(null); const { customers, isLoading, refetch: refetchCustomers } = useCustomers(customerSearchQuery); // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -1055,7 +1078,7 @@ export default function OrderSummary({ serialNumber: string; availableQuantity: number; prescriptionDosage?: string; // From Prescription Frequency doctype (dropdown) - dosage?: number | null; // Actual dosage amount/quantity (float input) + dosage?: string; // Free text dosage copied from patient medication order medicationOrder?: string; // Patient Medication Order (when items added from order) } > @@ -1373,7 +1396,7 @@ export default function OrderSummary({ uomToUse ); - const prescriptionDosageValue = itemToAdd.patient_frequency || itemToAdd.dosage; + const prescriptionDosageValue = itemToAdd.patient_frequency; const currentQty = qtyByItem.get(product.id) ?? 0; const willExist = currentQty > 0; @@ -1387,8 +1410,7 @@ export default function OrderSummary({ updateItemDiscount(product.id, "prescriptionDosage", prescriptionDosageValue); } if (itemToAdd.dosage != null && itemToAdd.dosage !== "") { - const dosageVal = parseFloat(String(itemToAdd.dosage)); - updateItemDiscount(product.id, "dosage", Number.isNaN(dosageVal) ? itemToAdd.dosage : dosageVal); + updateItemDiscount(product.id, "dosage", String(itemToAdd.dosage).trim()); } if (itemToAdd.medication_order) { const s = medsByItem.get(product.id) ?? new Set(); @@ -1434,9 +1456,8 @@ export default function OrderSummary({ }, 100); } if (itemToAdd.dosage != null && itemToAdd.dosage !== "") { - const dosageVal = parseFloat(String(itemToAdd.dosage)); setTimeout(() => { - updateItemDiscount(product.id, "dosage", Number.isNaN(dosageVal) ? itemToAdd.dosage : dosageVal); + updateItemDiscount(product.id, "dosage", String(itemToAdd.dosage).trim()); }, 100); } if (itemToAdd.medication_order) { @@ -1545,7 +1566,7 @@ export default function OrderSummary({ itemToAdd.uom, uomToUse ); - const prescriptionDosageValue = itemToAdd.patient_frequency || itemToAdd.dosage; + const prescriptionDosageValue = itemToAdd.patient_frequency; const currentQty = qtyByItem.get(product.id) ?? 0; const willExist = currentQty > 0; @@ -1559,8 +1580,7 @@ export default function OrderSummary({ updateItemDiscount(product.id, "prescriptionDosage", prescriptionDosageValue); } if (itemToAdd.dosage != null && itemToAdd.dosage !== "") { - const dosageVal = parseFloat(String(itemToAdd.dosage)); - updateItemDiscount(product.id, "dosage", Number.isNaN(dosageVal) ? itemToAdd.dosage : dosageVal); + updateItemDiscount(product.id, "dosage", String(itemToAdd.dosage).trim()); } if (itemToAdd.medication_order) { const s = medsByItem.get(product.id) ?? new Set(); @@ -1605,9 +1625,8 @@ export default function OrderSummary({ }, 100); } if (itemToAdd.dosage != null && itemToAdd.dosage !== "") { - const dosageVal = parseFloat(String(itemToAdd.dosage)); setTimeout(() => { - updateItemDiscount(product.id, "dosage", Number.isNaN(dosageVal) ? itemToAdd.dosage : dosageVal); + updateItemDiscount(product.id, "dosage", String(itemToAdd.dosage).trim()); }, 100); } if (itemToAdd.medication_order) { @@ -1718,6 +1737,12 @@ export default function OrderSummary({ const newQty = currentQty + cartQuantity; await onUpdateQuantity(product.id, newQty); qtyByItem.set(product.id, newQty); + if (itemToAdd.patient_frequency) { + updateItemDiscount(product.id, "prescriptionDosage", itemToAdd.patient_frequency); + } + if (itemToAdd.dosage != null && itemToAdd.dosage !== "") { + updateItemDiscount(product.id, "dosage", String(itemToAdd.dosage).trim()); + } } else { await addToCartWithQuantity( { @@ -1734,6 +1759,16 @@ export default function OrderSummary({ cartQuantity ); qtyByItem.set(product.id, cartQuantity); + if (itemToAdd.patient_frequency) { + setTimeout(() => { + updateItemDiscount(product.id, "prescriptionDosage", itemToAdd.patient_frequency!); + }, 100); + } + if (itemToAdd.dosage != null && itemToAdd.dosage !== "") { + setTimeout(() => { + updateItemDiscount(product.id, "dosage", String(itemToAdd.dosage).trim()); + }, 100); + } } addedCount++; } @@ -1908,6 +1943,8 @@ export default function OrderSummary({ const handleStartNewOrder = async () => { handleClearCart(); setLastDispensedSalesOrder(null); + setLastDispensedLabelItems([]); + setLastDispensedCartSignature(null); setCreatedVisitRef(null); try { await refreshStockOnly(); @@ -1932,6 +1969,50 @@ export default function OrderSummary({ window.open(`${base}/printview?${params.toString()}`, "_blank", "noopener,noreferrer"); }; + const printMedicationLabels = (labels: DispensedLabelItem[]) => { + if (!labels.length) { + toast.error("No dispensed medicines found for label printing."); + return; + } + +const pages = labels.map((label) => ` +
+
+
Medication Label
+
${label.itemCode} - ${label.itemName}
+
Dosage: ${label.dosage}
+
Frequency: ${label.frequency}
+
Batch No: ${label.batchNo}
+
Expiry Date: ${label.expiryDate}
+
+
+`); + + const printWindow = window.open("", "_blank"); + if (!printWindow) { + toast.error("Unable to open print window. Please allow popups and try again."); + return; + } + + printWindow.document.write( + "" + + pages.join("") + + '' + ); + printWindow.document.close(); + }; + + const showPostDispenseActions = + isHospitalPharmacy && + !!lastDispensedSalesOrder && + !!lastDispensedCartSignature && + lastDispensedCartSignature === getCartSignature(cartItems); + const handleDispense = async () => { if (!validateCustomer()) return; if (!selectedCustomer) return; @@ -1939,6 +2020,26 @@ export default function OrderSummary({ setShowPaymentDialog(true); return; } + const missingMedicationDetails = cartItems + .map((item) => { + const lineKey = getLineKey(item); + const lineDiscount = ((itemDiscounts[item.id] || itemDiscounts[lineKey]) || {}) as { dosage?: string; prescriptionDosage?: string }; + const dosageRaw = lineDiscount.dosage; + const frequencyRaw = lineDiscount.prescriptionDosage; + const hasDosage = dosageRaw !== null && dosageRaw !== undefined && String(dosageRaw).trim() !== ""; + const hasFrequency = typeof frequencyRaw === "string" && frequencyRaw.trim() !== ""; + if (hasDosage && hasFrequency) return null; + return item.name || item.item_code || item.id; + }) + .filter(Boolean) as string[]; + + if (missingMedicationDetails.length > 0) { + const uniqueItems = Array.from(new Set(missingMedicationDetails)); + toast.error( + `Missing dosage or prescription frequency for: ${uniqueItems.join(", ")}. Kindly update Patient Medication Order items before dispensing.` + ); + return; + } try { setIsDispensing(true); const allMedicationOrders = Array.from(new Set( @@ -1992,7 +2093,58 @@ export default function OrderSummary({ toast.error("Dispense failed. Sales Order was not created."); return; } + const dispensedCartSignature = getCartSignature(cartItems); + + const labelsFromCart: DispensedLabelItem[] = cartItems.map((item) => { + const lineKey = getLineKey(item); + const lineDiscount = ((itemDiscounts[item.id] || itemDiscounts[lineKey]) || {}) as { + dosage?: string; + prescriptionDosage?: string; + batchNumber?: string; + }; + const dosage = lineDiscount.dosage; + const frequency = lineDiscount.prescriptionDosage; + const batchNo = + lineDiscount.batchNumber || + (item as CartItem & { batch_no?: string }).batch_no || + "N/A"; + + return { + itemCode: item.item_code || item.id, + itemName: item.name, + dosage: dosage === null || dosage === undefined || String(dosage).trim() === "" ? "N/A" : String(dosage), + frequency: typeof frequency === "string" && frequency.trim() ? frequency : "N/A", + batchNo, + expiryDate: "N/A", + }; + }); + + const batchNumbers = Array.from( + new Set(labelsFromCart.map((label) => label.batchNo).filter((batchNo) => batchNo && batchNo !== "N/A")) + ); + + if (batchNumbers.length > 0) { + try { + const batchDetails = await getBatchLabelDetails(batchNumbers); + const labelsWithExpiry = labelsFromCart.map((label) => { + const matchedBatch = batchDetails[label.batchNo]; + return { + ...label, + expiryDate: matchedBatch?.expiry_date || "N/A", + batchNo: matchedBatch?.batch_no || label.batchNo, + }; + }); + setLastDispensedLabelItems(labelsWithExpiry); + } catch (batchError) { + console.error("Failed to fetch batch expiry details for labels:", batchError); + setLastDispensedLabelItems(labelsFromCart); + } + } else { + setLastDispensedLabelItems(labelsFromCart); + } + setLastDispensedSalesOrder(soName); + setLastDispensedCartSignature(dispensedCartSignature); toast.success(`Dispensed successfully. Sales Order: ${soName}`); } catch (error) { toast.error(extractErrorFromException(error, "Failed to dispense items")); @@ -2878,7 +3030,8 @@ const handleSetSerial = (event: CustomEvent) => { serialNumber: "", availableQuantity: 150, prescriptionDosage: "", - dosage: 0, + dosage: "", + ...(itemDiscounts[item.id] || {}), ...(itemDiscounts[lineKey] || {}), // Persisted batch survives refresh ...(cartItemBatch !== undefined && cartItemBatch !== "" ? { batchNumber: cartItemBatch } : {}), @@ -3338,12 +3491,14 @@ const handleSetSerial = (event: CustomEvent) => { Hold )} - + {!showPostDispenseActions && ( + + )} {isAllowAdditionalAmounts && ( - {isHospitalPharmacy && lastDispensedSalesOrder && ( -
+ {showPostDispenseActions && ( +
)} diff --git a/klik_spa/src/services/salesOrder.ts b/klik_spa/src/services/salesOrder.ts index ff63182..d9374d7 100644 --- a/klik_spa/src/services/salesOrder.ts +++ b/klik_spa/src/services/salesOrder.ts @@ -19,3 +19,27 @@ export async function createHospitalSalesOrder(data: any) { } return result.message; } + +export async function getBatchLabelDetails(batchNumbers: string[]) { + if (!Array.isArray(batchNumbers) || batchNumbers.length === 0) { + return {}; + } + + const csrfToken = window.csrf_token; + const response = await fetch('/api/method/klik_pos.api.sales_order.get_batch_label_details', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Frappe-CSRF-Token': csrfToken + }, + body: JSON.stringify({ batch_nos: batchNumbers }), + credentials: 'include' + }); + + const result = await response.json(); + if (!response.ok || !result?.message) { + throw new Error(extractErrorMessage(result, 'Failed to fetch batch label details')); + } + + return result.message as Record; +}