Per-Procedure Line Items
Business view
Before this PR, a quotation had one discount % and one tax % that applied to the whole estimate. If a patient needed a filling and a crown, and only the crown was insured, staff had no way to discount or tax those two procedures differently — the whole quotation had to use the same numbers.
Now every procedure line in a quotation is its own mini line-item with:
- Its own price and quantity.
- Its own discount amount.
- Its own insurance-covered amount (with a checkbox to mark whether insurance applies to that specific procedure at all).
- Its own tax toggle — a procedure can be marked taxable or not, independent of the rest of the quotation.
- (Saudi Arabia branches only) an "allow max limit" checkbox, tied to the patient's insurance policy class daily/annual limit.
- A free-text note and a manual order number, so procedures can be reordered on the printed estimate independent of the order they were added.
The quotation-level discount/insurance/tax fields at the bottom still exist — typing into them now redistributes that value back down across all the line items, so staff who just want "10% off everything" don't have to touch each row individually. But per-procedure control is now available when the estimate needs it.
Technical view
Data shape
Both editors keep a client-side array of line items, one object per procedure, with fields: subtotal, discount, discountPercent, insurance, insurancePercent, tax, taxPercent, taxApplied, total, insured, allowMaxLimit, order, notes.
- Web (
QuotationDrawer.js): a Form-managed array watched viaForm.useWatch('lineItems', form), rendered throughForm.List name="lineItems"(QuotationDrawer.js:1441). - Mobile (
NewQuotationScreen.js): auseFieldArray-backed array built bybuildLineItemsFromOps(NewQuotationScreen.js:187-342).
On save, the whole per-procedure breakdown is serialized into the operationInsurance JSON string that's stored on the Quotation row (operationInsurance: String field, unchanged shape, but now populated with per-op priceDiscount, insurance, taxApplied, taxPercent instead of just a flat discount amount). The server's recalculation logic (quotationUtils.js:16-24) reads this JSON back out via storedByOp, so the per-line breakdown survives round-trips through the backend, not just the UI session.
Web UI (QuotationDrawer.js)
The per-procedure Table, columns defined at QuotationDrawer.js:979-1181:
| Column | Handler | Lines |
|---|---|---|
Discount button → OperationDiscountModal | handleSelectOperation / handleUpdateOperationDiscount | 1019-1028, 850-892 |
| Tax toggle (✓ / ✕ icon) | handleToggleTax | 1029-1052, 894-925 |
| Insurance checkbox + amount input | handleToggleOperationInsured / handleChangeInsuranceDiscountAmount | 1094-1130, 759-797, 810-843 |
| "Allow max limit" checkbox (Saudi Arabia only, requires a policy class max limit) | handleToggleOperationMaxLimit | 1131-1151 |
| Notes textarea | — | 1152-1170 |
| Order / sequence input | — | 1074-1093 |
The bottom summary table (QuotationDrawer.js:1453-1732) still shows aggregate discount%/discount, tax%/tax, and insurance%/insurance inputs (handleUpdateDiscountPercent/handleUpdateDiscount, handleUpdateTaxPercent/handleUpdateTax, handleUpdateInsurancePercent/handleUpdateInsurance) that redistribute proportionally into the line items — plus a note showing patientVatPercent/insuranceVatPercent side by side when they diverge (lines 1529-1541), and an InvoiceInsuranceDetailsPopover (line 1598) breaking down the tax split for review before saving.
Mobile UI (NewQuotationScreen.js)
The equivalent per-row controls:
- Discount amount editor — navigates to a numeric input screen (
handleEditOperationDiscount→handleUpdateOperationDiscount, lines 909-922, 828-854), rendered 1140-1150. - Tax toggle —
handleToggleTax, lines 856-877, rendered 1152-1168 (only shown whentaxPercent || insuranceTaxPercent.current !== patientTaxPercent.current, i.e. hidden when there's nothing to toggle). - Insurance checkbox + amount —
handleToggleOperationInsuranceDiscount/editInsurance, lines 765-792, 894-907, rendered 1170-1198. - "Allow max limit" (Saudi only) — lines 794-802, 1199-1223.
- Order field and notes — 1228-1260.
- Patient-vs-insurance VAT display text, shown only when the two rates diverge — lines 1437-1447.
Quotation-level discount/tax/insurance fields exist as aggregate editors the same way as web (handleUpdateDiscount, handleUpdateTax, handleUpdateInsurance, lines 602-763) and become disabled once the patient/insurance tax rates diverge (lines 1421, 1434) for the same reason described in Patient vs. Insurance Tax Split.
Legacy quotations
Quotations created before this PR don't have per-operation priceDiscount/taxApplied/patientTaxPercent in their stored JSON. buildLineItemsFromOps (mobile, lines 194-224) and its web counterpart carry explicit fallback branches for this — old data loads into the new per-line UI with reasonable defaults rather than breaking. This is a natural regression-test target: loading and re-saving a pre-PR quotation should not corrupt its numbers.
Related smaller fixes in the same area
QuotationOperations.js— a procedure row previously labeled the insurance-covered amount as "discount"; it now shows the price discount and the insurance amount as two separate, correctly labeled figures.QuotationExpendedRow.js—discountPercent/taxPercentdisplay now defensively guards with.toFixed(2)againstundefinedvalues (previously could renderNaN%or crash on quotations missing those fields).ConvertInvoiceButton.js— converting a quotation to an invoice now passes each procedure's owninitialDiscount(pulled fromoperationInsurance.operations[].discount) instead of one aggregate discount/insurance blob, so the invoice created from a quotation inherits the same per-procedure split instead of flattening it back to one number.