diff --git a/docs/voiced-invoice-v1.md b/docs/voiced-invoice-v1.md index 80c7e09..5b8ac8f 100644 --- a/docs/voiced-invoice-v1.md +++ b/docs/voiced-invoice-v1.md @@ -33,6 +33,10 @@ Notes on the fields: - **Items.** `taxable_value` of a line is the line amount before the invoice-level discount. `totals.taxable_value` is `subtotal - discount`, the base the tax is charged on. A fixed-amount line has `quantity "1"` and `unit null`. +- **Units.** On a rate line `unit` is a built-in id (`unit`, `piece`, `set`, `session`, `second`, `minute`, `hour`, `day`, + `week`, `month`, `km`, `m`, `sqft`, `sqm`, `kg`, `litre`) or the custom text typed on the invoice (at most 12 characters: + letters, digits, spaces and `. / -`). `quantity` may be fractional; a time typed as `4:20` hours is stored as the exact + repeating decimal. - **UTGST.** When the supplier is in a union territory without a legislature (state codes 04, 26, 31, 35, 38) the second head is reported under `utgst` and `sgst` is zero. - **Client details.** The name, GSTIN, printed address and place of supply are frozen on the invoice. The structured diff --git a/public/templates/citrus-split.png b/public/templates/citrus-split.png index 09942f8..ca82fe1 100644 Binary files a/public/templates/citrus-split.png and b/public/templates/citrus-split.png differ diff --git a/public/templates/classic.png b/public/templates/classic.png index 2cea5db..bb7d14f 100644 Binary files a/public/templates/classic.png and b/public/templates/classic.png differ diff --git a/public/templates/cobalt-stripe.png b/public/templates/cobalt-stripe.png index 33889d6..55f8426 100644 Binary files a/public/templates/cobalt-stripe.png and b/public/templates/cobalt-stripe.png differ diff --git a/public/templates/crimson-grid.png b/public/templates/crimson-grid.png index 0c136dc..8e464a2 100644 Binary files a/public/templates/crimson-grid.png and b/public/templates/crimson-grid.png differ diff --git a/public/templates/highlighter.png b/public/templates/highlighter.png index 94a66a9..81a3720 100644 Binary files a/public/templates/highlighter.png and b/public/templates/highlighter.png differ diff --git a/public/templates/linea.png b/public/templates/linea.png index 4b70a67..40fc266 100644 Binary files a/public/templates/linea.png and b/public/templates/linea.png differ diff --git a/public/templates/marble.png b/public/templates/marble.png index 46bee38..03f495c 100644 Binary files a/public/templates/marble.png and b/public/templates/marble.png differ diff --git a/public/templates/monogram.png b/public/templates/monogram.png index 7e18da8..92784f3 100644 Binary files a/public/templates/monogram.png and b/public/templates/monogram.png differ diff --git a/public/templates/monolith.png b/public/templates/monolith.png index bf46291..cc5150c 100644 Binary files a/public/templates/monolith.png and b/public/templates/monolith.png differ diff --git a/public/templates/purple-pop.png b/public/templates/purple-pop.png index d9b9778..0e15f1e 100644 Binary files a/public/templates/purple-pop.png and b/public/templates/purple-pop.png differ diff --git a/public/templates/serenity.png b/public/templates/serenity.png index cd87d84..0845ce9 100644 Binary files a/public/templates/serenity.png and b/public/templates/serenity.png differ diff --git a/public/templates/slate-band.png b/public/templates/slate-band.png index d8c529b..bc933f2 100644 Binary files a/public/templates/slate-band.png and b/public/templates/slate-band.png differ diff --git a/public/templates/tangerine-ledger.png b/public/templates/tangerine-ledger.png index 1b8808c..241f62c 100644 Binary files a/public/templates/tangerine-ledger.png and b/public/templates/tangerine-ledger.png differ diff --git a/public/templates/teal-swoosh.png b/public/templates/teal-swoosh.png index eeb91c1..9477724 100644 Binary files a/public/templates/teal-swoosh.png and b/public/templates/teal-swoosh.png differ diff --git a/src-tauri/src/commands/invoice.rs b/src-tauri/src/commands/invoice.rs index 1d655d2..0f70aa4 100644 --- a/src-tauri/src/commands/invoice.rs +++ b/src-tauri/src/commands/invoice.rs @@ -7,6 +7,7 @@ use super::payments::{derive_summary, summary_for, today_ist}; use crate::models::{ BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, Settings, }; +use crate::units; use crate::AppState; use chrono::NaiveDate; use rusqlite::{named_params, params, Connection, OptionalExtension, Row}; @@ -130,6 +131,15 @@ fn parse_date(label: &str, value: &str) -> Result<(), String> { /// overflow integer paise arithmetic. const MAX_LINE_RUPEES: f64 = 1e11; +/// The unit written to the database. A fixed-amount line has no unit of its own, so whatever the form held is not kept. +fn stored_unit(item: &InvoiceItem) -> String { + if item.mode == "rate" { + units::normalize(&item.unit) + } else { + "unit".to_string() + } +} + fn validate_items(items: &[InvoiceItem]) -> Result<(), String> { if items.is_empty() { return Err("Add at least one line item".into()); @@ -145,6 +155,7 @@ fn validate_items(items: &[InvoiceItem]) -> Result<(), String> { if item.quantity > MAX_LINE_RUPEES { return Err(format!("Line {n}: quantity is too large")); } + units::validate(&item.unit).map_err(|e| format!("Line {n}: {e}"))?; let amount = gst::line_amount_paise(item); if amount <= 0 { return Err(format!("Line {n}: amount must be greater than zero")); @@ -427,7 +438,7 @@ pub fn issue_invoice_impl( item.description, item.mode, item.rate, - item.unit, + stored_unit(item), item.quantity, paise_to_rupees(gst::line_amount_paise(item)), i as i64, @@ -854,6 +865,43 @@ mod tests { assert_eq!(inv.items[1].hsn_sac, ""); } + #[test] + fn lines_can_use_any_unit_and_fractional_quantities() { + let mut conn = registered(); + let inv = issue( + &mut conn, + input(json!({ + "items": [ + {"description": "Travel", "mode": "rate", "rate": 18.0, "quantity": 12.75, "unit": "km"}, + {"description": "Studio", "mode": "rate", "rate": 1200.0, "quantity": 4.0 + 1.0 / 3.0, "unit": "HOUR"}, + {"description": "Parcels", "mode": "rate", "rate": 99.5, "quantity": 3.0, "unit": " bundle "}, + {"description": "Fee", "mode": "fixed", "amount": 10.0, "unit": "not a unit;"}, + ], + })), + ) + .unwrap(); + let units: Vec<&str> = inv.items.iter().map(|i| i.unit.as_str()).collect(); + assert_eq!(units, ["km", "hour", "bundle", "unit"]); + let amounts: Vec = inv.items.iter().map(|i| i.amount).collect(); + // 18 x 12.75 = 229.50; 1200 x 4h20m = 5200.00 (one rounding, on the exact quantity); 99.50 x 3 = 298.50. + assert_eq!(amounts, [229.5, 5200.0, 298.5, 10.0]); + } + + #[test] + fn a_rate_line_with_a_bad_unit_is_refused_and_the_counter_stays() { + let mut conn = registered(); + for bad in ["", " ", "thirteen char", "x;y", "\u{20b9}"] { + let err = issue( + &mut conn, + input(json!({"items": [{"description": "Work", "mode": "rate", "rate": 10.0, "quantity": 1.0, "unit": bad}]})), + ) + .unwrap_err(); + assert!(err.starts_with("Line 1: "), "{bad:?}: {err}"); + } + assert_eq!(next_number(&conn), 1); + assert_eq!(invoice_count(&conn), 0); + } + #[test] fn tax_type_mismatch_is_rejected_and_the_counter_stays() { let mut conn = registered(); diff --git a/src-tauri/src/commands/presets.rs b/src-tauri/src/commands/presets.rs index d1dcd1e..17b7451 100644 --- a/src-tauri/src/commands/presets.rs +++ b/src-tauri/src/commands/presets.rs @@ -1,10 +1,10 @@ use crate::models::ItemPreset; +use crate::units; use crate::AppState; use rusqlite::{params, Connection, Row}; use tauri::State; const MODES: &[&str] = &["fixed", "rate"]; -const UNITS: &[&str] = &["second", "minute", "hour", "session", "unit"]; /// Upper bound on a preset amount (paise); keeps later integer-paise arithmetic far from overflow. const MAX_RATE_PAISE: i64 = 100_000_000_000; @@ -45,9 +45,8 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result Result Result= 0), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + erpnext_item_code TEXT +); +INSERT INTO item_presets_new (id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code) + SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code FROM item_presets; +DROP TABLE item_presets; +ALTER TABLE item_presets_new RENAME TO item_presets; +"#; + fn migrations() -> Migrations<'static> { Migrations::new(vec![ M::up(SCHEMA), @@ -358,6 +379,7 @@ fn migrations() -> Migrations<'static> { M::up(M9), M::up(M10), M::up(M11), + M::up(M12), ]) } @@ -950,7 +972,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); assert!(conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).is_err()); migrations().to_latest(&mut conn).unwrap(); - assert_eq!(user_version(&conn), 11); + assert_eq!(user_version(&conn), LATEST_VERSION); let row: (String, i64, String, String, String, Option) = conn .query_row( "SELECT remote_name, remote_docstatus, status, payload_hash, attachment_sha256, synced_at FROM erpnext_sync WHERE invoice_id = 1", @@ -968,6 +990,49 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')", []).is_err()); } + #[test] + fn m12_rebuilds_item_presets_keeping_rows_and_allowing_new_units() { + let mut conn = Connection::open_in_memory().unwrap(); + let v11 = vec![ + M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9), M::up(M10), M::up(M11), + ]; + Migrations::new(v11).to_latest(&mut conn).unwrap(); + assert_eq!(user_version(&conn), 11); + conn.execute( + "INSERT INTO item_presets (description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code) + VALUES ('Studio', '998397', 'rate', 'hour', 150050, 3, 'then', 'ITEM-1'), ('Fee', '', 'fixed', 'unit', 500000, 0, 'then', NULL)", + [], + ) + .unwrap(); + assert!(conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('X', 'km', 'now')", []).is_err()); + + migrations().to_latest(&mut conn).unwrap(); + assert_eq!(user_version(&conn), 12); + let rows: Vec<(i64, String, String, String, String, i64, i64, String, Option)> = conn + .prepare("SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code FROM item_presets ORDER BY id") + .unwrap() + .query_map([], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?, r.get(8)?)) + }) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!( + rows, + vec![ + (1, "Studio".into(), "998397".into(), "rate".into(), "hour".into(), 150050, 3, "then".into(), Some("ITEM-1".into())), + (2, "Fee".into(), "".into(), "fixed".into(), "unit".into(), 500000, 0, "then".into(), None), + ] + ); + // New units and custom text are allowed; an empty or over-long unit is not; ids keep counting up. + conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('Travel', 'km', 'now')", []).unwrap(); + conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('Parcel', 'bundle', 'now')", []).unwrap(); + assert!(conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('X', '', 'now')", []).is_err()); + assert!(conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('X', 'thirteen char', 'now')", []).is_err()); + let last: i64 = conn.query_row("SELECT MAX(id) FROM item_presets", [], |r| r.get(0)).unwrap(); + assert_eq!(last, 4); + } + fn build_v1_database(path: &Path) { let conn = Connection::open(path).unwrap(); conn.execute_batch(V1_SCHEMA).unwrap(); diff --git a/src-tauri/src/integrations/erpnext/config.rs b/src-tauri/src/integrations/erpnext/config.rs index c6957d2..1430812 100644 --- a/src-tauri/src/integrations/erpnext/config.rs +++ b/src-tauri/src/integrations/erpnext/config.rs @@ -61,18 +61,9 @@ impl NamingMode { } /// Voiced line unit to ERPNext UOM. Whole-number UOMs such as "Nos" reject fractional quantities, -/// which is why hours and minutes map to their own UOMs. +/// which is why hours, minutes, kilometres and the like map to their own UOMs (see `units::KNOWN`). pub fn default_uom_map() -> BTreeMap { - [ - ("second", "Second"), - ("minute", "Minute"), - ("hour", "Hour"), - ("session", "Nos"), - ("unit", "Nos"), - ] - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() + crate::units::KNOWN.iter().map(|(id, uom)| (id.to_string(), uom.to_string())).collect() } /// Everything stored for the integration, secret included. Backend-only. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9f357f9..cb5fcee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod integrations; mod logo; mod models; mod selftest; +mod units; use rusqlite::Connection; use std::path::{Path, PathBuf}; diff --git a/src-tauri/src/units.rs b/src-tauri/src/units.rs new file mode 100644 index 0000000..f19e942 --- /dev/null +++ b/src-tauri/src/units.rs @@ -0,0 +1,120 @@ +//! Units of measure for rate × quantity lines. Mirrors src/lib/units.ts: the ids below are stored in +//! invoices and presets, and any other text that passes `validate` is a custom unit stored as typed. + +/// Built-in unit ids with the ERPNext UOM each one maps to by default. Whole-number UOMs ("Nos", "Set") +/// reject fractional quantities, so units that are usually fractional map to UOMs that allow them. +pub const KNOWN: &[(&str, &str)] = &[ + ("unit", "Nos"), + ("piece", "Nos"), + ("set", "Set"), + ("session", "Nos"), + ("second", "Second"), + ("minute", "Minute"), + ("hour", "Hour"), + ("day", "Day"), + ("week", "Week"), + ("month", "Month"), + ("km", "Kilometer"), + ("m", "Meter"), + ("sqft", "Square Foot"), + ("sqm", "Square Meter"), + ("kg", "Kg"), + ("litre", "Litre"), +]; + +/// Longest custom unit; it lands in a measured PDF column. +pub const MAX_CUSTOM_LEN: usize = 12; + +pub fn is_known(unit: &str) -> bool { + KNOWN.iter().any(|(id, _)| *id == unit) +} + +/// The ERPNext UOM that suits a built-in unit. +pub fn default_uom(unit: &str) -> Option<&'static str> { + KNOWN.iter().find(|(id, _)| *id == unit).map(|(_, uom)| *uom) +} + +fn custom_problem(text: &str) -> Option { + let t = text.trim(); + if t.is_empty() { + return Some("a unit is required".into()); + } + if t.chars().count() > MAX_CUSTOM_LEN { + return Some(format!("a custom unit has at most {MAX_CUSTOM_LEN} characters")); + } + let mut chars = t.chars(); + let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphanumeric()); + if !first_ok || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '/' | '-')) { + return Some("a unit may use letters, digits, spaces, . / and - only".into()); + } + None +} + +/// The unit as it is stored: trimmed, and a built-in id in lower case. An empty value is the generic unit. +pub fn normalize(unit: &str) -> String { + let t = unit.trim(); + if t.is_empty() { + return "unit".to_string(); + } + let lower = t.to_ascii_lowercase(); + if is_known(&lower) { + lower + } else { + t.to_string() + } +} + +/// Checks a unit that is about to be stored. Errors read as `Unit "x": `. +pub fn validate(unit: &str) -> Result<(), String> { + if unit.trim().is_empty() { + return Err("A unit is required".to_string()); + } + if is_known(&normalize(unit)) { + return Ok(()); + } + match custom_problem(unit) { + None => Ok(()), + Some(p) => Err(format!("Unit \"{}\": {p}", unit.trim())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_units_are_accepted_in_any_case() { + for (id, _) in KNOWN { + assert!(validate(id).is_ok(), "{id}"); + } + assert_eq!(normalize(" HOUR "), "hour"); + assert_eq!(normalize(""), "unit"); + } + + #[test] + fn custom_units_follow_the_same_rules_as_the_ui() { + assert!(validate("bundle").is_ok()); + assert!(validate("sq.ft/day").is_ok()); + assert_eq!(normalize(" bundle "), "bundle"); + assert!(validate("a".repeat(13).as_str()).is_err()); + assert!(validate("₹").is_err()); + assert!(validate("-x").is_err()); + assert!(validate("").is_err()); + assert!(validate(" ").is_err()); + assert!(validate("bad;drop").is_err()); + } + + #[test] + fn the_original_five_ids_are_unchanged() { + for id in ["second", "minute", "hour", "session", "unit"] { + assert!(is_known(id), "{id}"); + } + } + + #[test] + fn fractional_units_do_not_map_to_whole_number_uoms() { + for id in ["second", "minute", "hour", "day", "week", "month", "km", "m", "sqft", "sqm", "kg", "litre"] { + assert_ne!(default_uom(id), Some("Nos"), "{id}"); + } + } +} diff --git a/src/components/ErpnextSettingsPanel.tsx b/src/components/ErpnextSettingsPanel.tsx index 5038a55..af83f85 100644 --- a/src/components/ErpnextSettingsPanel.tsx +++ b/src/components/ErpnextSettingsPanel.tsx @@ -35,6 +35,7 @@ import { type FormProblems, } from "../lib/erpnextUi"; import { useToast } from "./ToastProvider"; +import { unitLabel } from "../lib/units"; const SEVERITY_TITLE = { error: "Needs fixing", warning: "Warning", info: "Note" } as const; @@ -496,12 +497,12 @@ export default function ErpnextSettingsPanel({ active }: { active: boolean }) { />
Units of measure -

The ERPNext unit used for each Voiced unit. Whole-number units such as Nos reject fractional quantities.

+

The ERPNext unit used for each Voiced unit. Whole-number units such as Nos reject fractional quantities. A custom unit typed on an invoice is sent as Nos. Month is not a stock ERPNext unit: create it there or pick another.

{UOM_UNITS.map((unit) => ( setForm((f) => (f ? { ...f, uomMap: { ...f.uomMap, [unit]: v } } : f))} items={opts.uoms} diff --git a/src/components/ItemPresetsPanel.tsx b/src/components/ItemPresetsPanel.tsx index 2f8e0a5..a62f0bb 100644 --- a/src/components/ItemPresetsPanel.tsx +++ b/src/components/ItemPresetsPanel.tsx @@ -18,7 +18,9 @@ import { } from "@carbon/react"; import { Add, Edit, TrashCan } from "@carbon/icons-react"; import { api } from "../lib/api"; -import { UNIT_LABELS, blankPreset, type ItemPreset, type LineMode, type LineUnit } from "../lib/types"; +import { blankPreset, type ItemPreset, type LineMode } from "../lib/types"; +import { isValidUnit, ratePerText } from "../lib/units"; +import UnitPicker from "./UnitPicker"; import { paiseToRupees, rupeesToPaise } from "../lib/presets"; import { formatAmount } from "../lib/format"; import { useToast } from "./ToastProvider"; @@ -61,9 +63,12 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) { if (!editing) return; setTouched(true); if (!editing.description.trim()) return; + if (editing.mode === "rate" && !isValidUnit(editing.unit)) return; setBusy(true); try { - await api.saveItemPreset({ ...editing, description: editing.description.trim() }); + // A fixed-amount preset has no unit of its own; whatever the picker last held must not block the save. + const unit = isValidUnit(editing.unit) ? editing.unit : "unit"; + await api.saveItemPreset({ ...editing, unit, description: editing.description.trim() }); toast.success(editing.id === null ? "Preset added" : "Preset updated", editing.description.trim()); setEditing(null); await load(); @@ -123,7 +128,7 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) { {p.description} {p.hsnSac || "—"} - {p.mode === "rate" ? UNIT_LABELS[p.unit] : "Fixed amount"} + {p.mode === "rate" ? ratePerText(p.unit) : "Fixed amount"} ₹{formatAmount(paiseToRupees(p.ratePaise))}
@@ -199,16 +204,7 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) { {editing.mode === "rate" ? ( - + set("unit", unit)} /> ) : null} diff --git a/src/components/QuantityInput.tsx b/src/components/QuantityInput.tsx new file mode 100644 index 0000000..24951a5 --- /dev/null +++ b/src/components/QuantityInput.tsx @@ -0,0 +1,70 @@ +import { useEffect, useRef, useState } from "react"; +import { TextInput } from "@carbon/react"; +import { formatQuantity, knownUnit, parseQuantity } from "../lib/units"; + +export interface QuantityInputProps { + id: string; + value: number; + unit: string; + onChange: (quantity: number) => void; + disabled?: boolean; + /** Reports whether the text is a quantity, so the form can refuse to issue with a bad one. */ + onValidity?: (valid: boolean) => void; +} + +/** + * Quantity of a rate line. Takes decimals for any unit, and for time units also `4:30` (4 h 30 min), + * showing the decimal it counts as. The text is the user's own while they type. + */ +export default function QuantityInput({ id, value, unit, onChange, disabled, onValidity }: QuantityInputProps) { + const [text, setText] = useState(() => formatQuantity(value, unit)); + // What the box last reported, so a change that came from outside (a preset, a draft) can tell itself apart. + const reported = useRef({ value, unit }); + + useEffect(() => { + if (reported.current.value === value && reported.current.unit === unit) return; + reported.current = { value, unit }; + const next = formatQuantity(value, unit); + setText(next); + // What is stored must be what the box says: a time kept as 4:20 has no exact decimal form in another unit. + const again = parseQuantity(next, unit); + if (again.ok && Math.abs(again.value - value) > 1e-9) { + reported.current = { value: again.value, unit }; + onChange(again.value); + } + // onChange is the parent's setter; only a new value or unit should re-sync the text. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value, unit]); + + const parsed = parseQuantity(text, unit); + const clock = knownUnit(unit)?.clock; + const validity = useRef(onValidity); + validity.current = onValidity; + useEffect(() => validity.current?.(parsed.ok), [parsed.ok]); + + const helper = parsed.ok && text.includes(":") ? `= ${formatQuantity(parsed.value, "unit")} ${knownUnit(unit)?.short ?? ""}`.trim() : undefined; + + return ( + { + const next = e.target.value; + setText(next); + const r = parseQuantity(next, unit); + if (r.ok) { + reported.current = { value: r.value, unit }; + onChange(r.value); + } + }} + /> + ); +} diff --git a/src/components/UnitPicker.tsx b/src/components/UnitPicker.tsx new file mode 100644 index 0000000..961ae32 --- /dev/null +++ b/src/components/UnitPicker.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef, useState } from "react"; +import { Select, SelectItem, SelectItemGroup, TextInput } from "@carbon/react"; +import { MAX_CUSTOM_UNIT_LENGTH, UNITS, UNIT_GROUPS, isKnownUnit, validateCustomUnit } from "../lib/units"; + +const OTHER = "__other__"; + +export interface UnitPickerProps { + id: string; + value: string; + onChange: (unit: string) => void; + disabled?: boolean; + labelText?: string; +} + +/** The unit of a rate line: a grouped list of built-in units plus "Other…", which takes free text. */ +export default function UnitPicker({ id, value, onChange, disabled, labelText = "Unit" }: UnitPickerProps) { + // "Other…" can be chosen before any text is typed, so the mode is kept apart from the stored value. + const [other, setOther] = useState(() => !isKnownUnit(value)); + const custom = other || !isKnownUnit(value); + const problem = custom ? validateCustomUnit(value) : null; + + // A unit that arrives from outside (another preset or draft in the same row) decides the mode again; one this picker + // just reported does not, so typing "m" on the way to "minute" does not flip the box back to the list. + const reported = useRef(value); + useEffect(() => { + if (value === reported.current) return; + reported.current = value; + setOther(!isKnownUnit(value)); + }, [value]); + const emit = (unit: string) => { + reported.current = unit; + onChange(unit); + }; + + return ( + <> + + {custom ? ( + emit(e.target.value)} + /> + ) : null} + + ); +} diff --git a/src/lib/erpnext.ts b/src/lib/erpnext.ts index 3821c66..cefe8a9 100644 --- a/src/lib/erpnext.ts +++ b/src/lib/erpnext.ts @@ -23,7 +23,7 @@ export interface ErpnextConfig { paymentBankAccount: string; tdsAccount: string; defaultItemCode: string; - /** Voiced unit (second, minute, hour, session, unit) to ERPNext UOM. */ + /** Voiced unit id (see lib/units.ts) to ERPNext UOM. */ uomMap: Record; customerGroup: string; territory: string; diff --git a/src/lib/erpnextUi.test.ts b/src/lib/erpnextUi.test.ts index bc14f55..3783bca 100644 --- a/src/lib/erpnextUi.test.ts +++ b/src/lib/erpnextUi.test.ts @@ -232,7 +232,7 @@ describe("settings form", () => { expect(input).not.toHaveProperty("apiSecretSet"); expect(input).not.toHaveProperty("lastDetectResult"); expect(input.apiSecret).toBe(""); - expect(input.uomMap).toEqual({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos" }); + expect(input.uomMap).toMatchObject({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos", km: "Kilometer", sqft: "Square Foot" }); expect(withDefaultUoms({ hour: " " }).hour).toBe("Hour"); }); diff --git a/src/lib/erpnextUi.ts b/src/lib/erpnextUi.ts index 3f4677e..017e512 100644 --- a/src/lib/erpnextUi.ts +++ b/src/lib/erpnextUi.ts @@ -1,4 +1,5 @@ /** Pure helpers behind the ERPNext screens (Settings, History, invoice detail, auto-push). No UI, no Tauri. */ +import { UNITS } from "./units"; import type { ErpnextConfig, ErpnextConfigInput, @@ -259,14 +260,8 @@ export function versionsLine(test: ErpnextConnectionTest): string { // ---- settings form ---- -export const UOM_UNITS = ["second", "minute", "hour", "session", "unit"] as const; -export const DEFAULT_UOM_MAP: Record = { - second: "Second", - minute: "Minute", - hour: "Hour", - session: "Nos", - unit: "Nos", -}; +export const UOM_UNITS: readonly string[] = UNITS.map((u) => u.id); +export const DEFAULT_UOM_MAP: Record = Object.fromEntries(UNITS.map((u) => [u.id, u.uom])); export function withDefaultUoms(map: Record | undefined): Record { const out: Record = { ...DEFAULT_UOM_MAP }; diff --git a/src/lib/invoice.test.ts b/src/lib/invoice.test.ts index 414f5f9..bb30213 100644 --- a/src/lib/invoice.test.ts +++ b/src/lib/invoice.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { computeLineAmount, computeTotals } from "./invoice"; import { EMPTY_ITEM, type InvoiceItem } from "./types"; +import { parseQuantity } from "./units"; // These cases mirror the unit tests in src-tauri/src/gst.rs, so the on-screen preview // and the stored totals stay identical. @@ -40,6 +41,19 @@ describe("computeTotals", () => { expect(computeLineAmount(rate(99.99, 2.5))).toBe(249.98); }); + it("counts a typed time as its decimal and rounds once, like Rust (units test: lines_can_use_any_unit...)", () => { + const q = (text: string, unit: string) => { + const r = parseQuantity(text, unit); + if (!r.ok) throw new Error(r.error); + return r.value; + }; + expect(computeLineAmount(rate(1200, q("4:30", "hour")))).toBe(5400); + // 4 h 20 min is 4.333...; 1200 x that is 5200.00 only if the exact value is kept, not 4.33. + expect(computeLineAmount(rate(1200, q("4:20", "hour")))).toBe(5200); + expect(computeLineAmount({ ...rate(18, q("12.75", "km")), unit: "km" })).toBe(229.5); + expect(computeLineAmount(rate(99.5, 3))).toBe(298.5); + }); + it("clamps the discount to the subtotal", () => { const items = [fixed(100), fixed(50.5)]; const over = computeTotals(items, 999.99, "igst", 18); diff --git a/src/lib/types.ts b/src/lib/types.ts index fafdfe2..fa24305 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,7 @@ export type TaxType = "none" | "cgst_sgst" | "igst"; export type LineMode = "fixed" | "rate"; -export type LineUnit = "second" | "minute" | "hour" | "session" | "unit"; +/** A built-in unit id (see lib/units.ts) or, for a custom unit, the text the vendor typed. */ +export type LineUnit = string; export interface Settings { vendorName: string; @@ -302,10 +303,3 @@ export const INDIAN_STATES: { code: string; name: string }[] = [ { code: "97", name: "Other Territory" }, ]; -export const UNIT_LABELS: Record = { - second: "per second", - minute: "per minute", - hour: "per hour", - session: "per session", - unit: "per unit", -}; diff --git a/src/lib/units.test.ts b/src/lib/units.test.ts new file mode 100644 index 0000000..b85ad15 --- /dev/null +++ b/src/lib/units.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + UNITS, + formatQuantity, + normalizeUnit, + parseQuantity, + quantityText, + ratePerText, + unitShort, + validateCustomUnit, +} from "./units"; + +const value = (text: string, unit: string) => { + const r = parseQuantity(text, unit); + if (!r.ok) throw new Error(r.error); + return r.value; +}; + +describe("parseQuantity", () => { + it("reads h:mm as hours for hour units", () => { + expect(value("4:30", "hour")).toBe(4.5); + expect(value("0:15", "hour")).toBe(0.25); + expect(value("4:20", "hour")).toBeCloseTo(4 + 1 / 3, 12); + expect(value("1:00:30", "hour")).toBeCloseTo(1 + 30 / 3600, 12); + }); + + it("reads m:ss as minutes for minute units", () => { + expect(value("4:30", "minute")).toBe(4.5); + expect(parseQuantity("1:00:30", "minute").ok).toBe(false); + }); + + it("refuses a clock quantity for other units and out-of-range parts", () => { + expect(parseQuantity("4:30", "km").ok).toBe(false); + expect(parseQuantity("4:75", "hour").ok).toBe(false); + expect(parseQuantity("4:", "hour").ok).toBe(false); + }); + + it("reads plain decimals for every unit", () => { + expect(value("12.75", "km")).toBe(12.75); + expect(value("120.5", "sqft")).toBe(120.5); + expect(value("3", "unit")).toBe(3); + expect(value(".5", "hour")).toBe(0.5); + expect(value("2.", "kg")).toBe(2); + expect(value("", "hour")).toBe(0); + }); + + it("rejects text, negatives, more than 3 decimals and huge values", () => { + for (const bad of ["abc", "-1", "1,5", "1.2345", ".", "1e3", "99999999"]) { + expect(parseQuantity(bad, "hour").ok, bad).toBe(false); + } + }); +}); + +describe("formatQuantity", () => { + it("trims zeros", () => { + expect(formatQuantity(2, "unit")).toBe("2"); + expect(formatQuantity(1.5, "km")).toBe("1.5"); + expect(formatQuantity(12.75, "km")).toBe("12.75"); + expect(formatQuantity(0.125, "kg")).toBe("0.125"); + }); + + it("writes a repeating time as h:mm", () => { + expect(formatQuantity(value("4:20", "hour"), "hour")).toBe("4:20"); + expect(formatQuantity(value("0:20", "hour"), "hour")).toBe("0:20"); + expect(formatQuantity(value("1:00:30", "hour"), "hour")).toBe("1:00:30"); + expect(formatQuantity(value("4:30", "hour"), "hour")).toBe("4.5"); + }); + + it("round-trips whatever parseQuantity accepts", () => { + for (const [text, unit] of [["4:20", "hour"], ["7:45", "hour"], ["12.75", "km"], ["0:05", "hour"], ["3:10", "minute"]] as const) { + const v = value(text, unit); + expect(value(formatQuantity(v, unit), unit)).toBeCloseTo(v, 9); + } + }); +}); + +describe("unit text", () => { + it("keeps the old rate wording for the original five units", () => { + expect(["second", "minute", "hour", "session", "unit"].map(ratePerText)).toEqual([ + "per second", + "per minute", + "per hour", + "per session", + "per unit", + ]); + }); + + it("shows the unit beside the quantity, but not for the generic unit", () => { + expect(quantityText(4.5, "hour")).toBe("4.5 hr"); + expect(quantityText(12.75, "km")).toBe("12.75 km"); + expect(quantityText(3, "unit")).toBe("3"); + expect(quantityText(2, "bundle")).toBe("2 bundle"); + expect(unitShort("sqft")).toBe("sq ft"); + expect(ratePerText("bundle")).toBe("per bundle"); + }); + + it("validates custom units", () => { + expect(validateCustomUnit("bundle")).toBeNull(); + expect(validateCustomUnit("sq.ft/day")).toBeNull(); + expect(validateCustomUnit("")).not.toBeNull(); + expect(validateCustomUnit("a".repeat(13))).not.toBeNull(); + expect(validateCustomUnit("₹")).not.toBeNull(); + }); + + it("normalises stored values", () => { + expect(normalizeUnit("HOUR")).toBe("hour"); + expect(normalizeUnit(" bundle ")).toBe("bundle"); + expect(normalizeUnit("")).toBe("unit"); + expect(normalizeUnit(undefined)).toBe("unit"); + }); + + it("has unique ids and a short for every unit except the generic one", () => { + expect(new Set(UNITS.map((u) => u.id)).size).toBe(UNITS.length); + for (const u of UNITS) if (u.id !== "unit") expect(u.short).not.toBe(""); + }); +}); diff --git a/src/lib/units.ts b/src/lib/units.ts new file mode 100644 index 0000000..fd70bd8 --- /dev/null +++ b/src/lib/units.ts @@ -0,0 +1,162 @@ +/** + * Units of measure for rate × quantity lines. The ids of the first five are stored in saved invoices + * and must never change. Any other text is a custom unit and is stored as typed (see `validateCustomUnit`). + * The Rust side keeps the same rules in src-tauri/src/units.rs. + */ + +export type UnitGroup = "Count" | "Time" | "Distance" | "Area" | "Weight" | "Volume"; + +/** How `h:mm` style quantities are read for a unit: "4:30" is 4 h 30 min for hours, 4 min 30 s for minutes. */ +export type ClockKind = "hours" | "minutes"; + +export interface UnitDef { + id: string; + /** Shown in pickers: "Hour". */ + label: string; + /** Lower-case word used after "per": "per hour". */ + noun: string; + /** Short form beside a quantity: "4.5 hr". Empty for the generic "unit", which stays a bare number. */ + short: string; + group: UnitGroup; + clock?: ClockKind; + /** ERPNext UOM used when the integration has no mapping of its own for this unit. */ + uom: string; +} + +export const UNITS: readonly UnitDef[] = [ + { id: "unit", label: "Unit", noun: "unit", short: "", group: "Count", uom: "Nos" }, + { id: "piece", label: "Piece", noun: "piece", short: "pc", group: "Count", uom: "Nos" }, + { id: "set", label: "Set", noun: "set", short: "set", group: "Count", uom: "Set" }, + { id: "session", label: "Session", noun: "session", short: "session", group: "Time", uom: "Nos" }, + { id: "second", label: "Second", noun: "second", short: "sec", group: "Time", uom: "Second" }, + { id: "minute", label: "Minute", noun: "minute", short: "min", group: "Time", clock: "minutes", uom: "Minute" }, + { id: "hour", label: "Hour", noun: "hour", short: "hr", group: "Time", clock: "hours", uom: "Hour" }, + { id: "day", label: "Day", noun: "day", short: "day", group: "Time", uom: "Day" }, + { id: "week", label: "Week", noun: "week", short: "wk", group: "Time", uom: "Week" }, + { id: "month", label: "Month", noun: "month", short: "mo", group: "Time", uom: "Month" }, + { id: "km", label: "Kilometre", noun: "km", short: "km", group: "Distance", uom: "Kilometer" }, + { id: "m", label: "Metre", noun: "m", short: "m", group: "Distance", uom: "Meter" }, + { id: "sqft", label: "Square foot", noun: "sq ft", short: "sq ft", group: "Area", uom: "Square Foot" }, + { id: "sqm", label: "Square metre", noun: "sq m", short: "sq m", group: "Area", uom: "Square Meter" }, + { id: "kg", label: "Kilogram", noun: "kg", short: "kg", group: "Weight", uom: "Kg" }, + { id: "litre", label: "Litre", noun: "litre", short: "L", group: "Volume", uom: "Litre" }, +]; + +export const UNIT_GROUPS: readonly UnitGroup[] = ["Count", "Time", "Distance", "Area", "Weight", "Volume"]; + +const BY_ID = new Map(UNITS.map((u) => [u.id, u])); + +export const DEFAULT_UNIT = "unit"; +export const MAX_CUSTOM_UNIT_LENGTH = 12; + +/** The built-in unit with this id, or undefined for a custom unit. */ +export const knownUnit = (unit: string): UnitDef | undefined => BY_ID.get(unit.trim().toLowerCase()); + +export const isKnownUnit = (unit: string): boolean => BY_ID.has(unit.trim().toLowerCase()); + +/** Problem with a custom unit text, or null when it is fine. */ +export function validateCustomUnit(text: string): string | null { + const t = text.trim(); + if (!t) return "Enter a unit"; + if (t.length > MAX_CUSTOM_UNIT_LENGTH) return `Use at most ${MAX_CUSTOM_UNIT_LENGTH} characters`; + if (!/^[A-Za-z0-9][A-Za-z0-9 ./-]*$/.test(t)) return "Use letters, digits, spaces, . / or -"; + return null; +} + +/** Whether a stored unit can be issued: a built-in id or a valid custom text. */ +export const isValidUnit = (unit: string): boolean => isKnownUnit(unit) || validateCustomUnit(unit) === null; + +/** A unit value as stored: the id for a built-in unit, the trimmed text otherwise. Falls back to the default. */ +export function normalizeUnit(unit: string | null | undefined): string { + const t = (unit ?? "").trim(); + if (!t) return DEFAULT_UNIT; + const lower = t.toLowerCase(); + if (BY_ID.has(lower)) return lower; + return t; +} + +export const unitLabel = (unit: string): string => knownUnit(unit)?.label ?? unit.trim(); + +/** "hr" for hours; the text itself for a custom unit; "" for the generic unit. */ +export const unitShort = (unit: string): string => { + const def = knownUnit(unit); + return def ? def.short : unit.trim(); +}; + +/** "per hour", "per sq ft", "per bundle". */ +export const ratePerText = (unit: string): string => `per ${knownUnit(unit)?.noun ?? unit.trim()}`; + +/** The ERPNext UOM that suits a built-in unit; undefined for a custom unit. */ +export const defaultUom = (unit: string): string | undefined => knownUnit(unit)?.uom; + +export type QuantityParse = { ok: true; value: number } | { ok: false; error: string }; + +const MAX_QUANTITY = 10_000_000; + +/** + * Reads what the user typed into a quantity box. Plain decimals (up to 3 places) work for every unit. + * Hour units also take `h:mm` and `h:mm:ss`, minute units take `m:ss`, so "4:30" hours is 4.5. + * An empty box is 0, the same as before this existed. + */ +export function parseQuantity(text: string, unit: string): QuantityParse { + const t = text.trim(); + if (!t) return { ok: true, value: 0 }; + const clock = knownUnit(unit)?.clock; + + if (t.includes(":")) { + if (!clock) return { ok: false, error: "Only time units take h:mm" }; + const m = /^(\d{1,7}):(\d{1,2})(?::(\d{1,2}))?$/.exec(t); + if (!m) return { ok: false, error: clock === "hours" ? "Use h:mm, for example 4:30" : "Use m:ss, for example 4:30" }; + const first = Number(m[1]); + const second = Number(m[2]); + const third = m[3] === undefined ? null : Number(m[3]); + if (second > 59 || (third !== null && third > 59)) return { ok: false, error: "Minutes and seconds go up to 59" }; + if (clock === "minutes" && third !== null) return { ok: false, error: "Use m:ss, for example 4:30" }; + const value = clock === "hours" ? first + second / 60 + (third ?? 0) / 3600 : first + second / 60; + return value > MAX_QUANTITY ? { ok: false, error: "That quantity is too large" } : { ok: true, value }; + } + + const m = /^(\d{0,8})(?:\.(\d{0,3}))?$/.exec(t); + if (!m || (m[1] === "" && !m[2])) { + return { ok: false, error: /^\d*\.\d{4,}$/.test(t) ? "Use at most 3 decimal places" : "Enter a number" }; + } + const value = Number(t); + return value > MAX_QUANTITY ? { ok: false, error: "That quantity is too large" } : { ok: true, value }; +} + +const EPS = 1e-9; + +function trimmed(value: number, places: number): string { + return value.toFixed(places).replace(/\.?0+$/, ""); +} + +/** + * A quantity as text, without the unit. Exact for anything `parseQuantity` accepts: a time that is not a + * whole number of hundredths (4 h 20 min) is written as h:mm so that quantity × rate can be checked by eye. + */ +export function formatQuantity(value: number, unit: string): string { + if (!Number.isFinite(value)) return "0"; + const clock = knownUnit(unit)?.clock; + if (!clock) return trimmed(value, 3); + if (Math.abs(value * 100 - Math.round(value * 100)) < EPS * 100) return trimmed(value, 2); + const whole = Math.floor(value + EPS); + const rest = (value - whole) * 60; + const min = Math.floor(rest + EPS); + const sec = Math.round((rest - min) * 60); + const head = clock === "hours" ? `${whole}:${String(min).padStart(2, "0")}` : null; + if (clock === "hours" && sec < 60 && Math.abs(whole + min / 60 + sec / 3600 - value) < EPS) { + return sec === 0 ? head! : `${head}:${String(sec).padStart(2, "0")}`; + } + if (clock === "minutes") { + const s = Math.round((value - whole) * 60); + if (s < 60 && Math.abs(whole + s / 60 - value) < EPS) return `${whole}:${String(s).padStart(2, "0")}`; + } + return trimmed(value, 4); +} + +/** The quantity as printed on an invoice: "4:20 hr", "12.75 km", or a bare "3" for the generic unit. */ +export function quantityText(value: number, unit: string): string { + const q = formatQuantity(value, unit); + const short = unitShort(unit); + return short ? `${q} ${short}` : q; +} diff --git a/src/pdf/engine/measure.ts b/src/pdf/engine/measure.ts index 05438ec..149e0d5 100644 --- a/src/pdf/engine/measure.ts +++ b/src/pdf/engine/measure.ts @@ -181,6 +181,23 @@ export function getMeasurer(): Measurer { /** The text as it will be drawn: caps applied. */ export const drawnText = (token: TypeToken, text: string) => (token.caps ? text.toUpperCase() : text); +/** A quantity with its unit ("1250.5 sq ft") is allowed this much width before the cell wraps at its spaces. */ +export const QTY_WRAP_CAP = 72; + +/** + * The texts a quantity column is sized from. A quantity cell can wrap between its words ("120 / wooden crate") + * but never inside one, so a text wider than `cap` counts as its widest word; the column then stays modest + * and the description keeps its room, instead of one long custom unit squeezing the table. + */ +export function wrapAwareTexts(texts: string[], role: RoleName | TypeToken, cap = QTY_WRAP_CAP): string[] { + const m = getMeasurer(); + return texts.map((text) => { + if (measureToken(m, role, text) <= cap) return text; + const words = text.split(/\s+/).filter(Boolean); + return words.reduce((widest, w) => (measureToken(m, role, w) > measureToken(m, role, widest) ? w : widest), words[0] ?? text); + }); +} + /** Width of `text` set in a role/token at an optional size override, tracking and caps included. */ export function measureToken( m: Measurer, diff --git a/src/pdf/model/build.test.ts b/src/pdf/model/build.test.ts index e6506ef..838aa77 100644 --- a/src/pdf/model/build.test.ts +++ b/src/pdf/model/build.test.ts @@ -133,7 +133,10 @@ describe("unregistered invoice", () => { descriptionLines: ["Voice-over", "session A"], hsnSac: "998397", rateText: "₹1,200.00 per hour", - qtyText: "2.5", + priceText: "₹1,200.00", + perText: "per hour", + rateShort: "₹1,200.00/hr", + qtyText: "2.5 hr", amountText: "₹3,000.00", }); expect(m.items[1]).toEqual({ @@ -141,10 +144,22 @@ describe("unregistered invoice", () => { descriptionLines: ["—"], hsnSac: null, rateText: null, + priceText: null, + perText: null, + rateShort: null, qtyText: "1", amountText: "₹2,500.50", }); }); + it("words the unit and the quantity for every kind of unit", () => { + const line = (unit: string, quantity: number, rate = 18.5) => + make({ items: [{ ...base.items[0], mode: "rate", unit, quantity, rate }] }).items[0]; + expect(line("km", 12.75)).toMatchObject({ qtyText: "12.75 km", rateText: "₹18.50 per km", rateShort: "₹18.50/km", perText: "per km" }); + expect(line("sqft", 1250.5)).toMatchObject({ qtyText: "1250.5 sq ft", rateShort: "₹18.50/sq ft" }); + expect(line("hour", 4 + 1 / 3)).toMatchObject({ qtyText: "4:20 hr" }); + expect(line("unit", 3)).toMatchObject({ qtyText: "3", rateText: "₹18.50 per unit", rateShort: "₹18.50/unit" }); + expect(line(" bundle ", 2)).toMatchObject({ qtyText: "2 bundle", rateText: "₹18.50 per bundle", rateShort: "₹18.50/bundle" }); + }); it("labels the bank lines and defaults the signature to the vendor name", () => { expect(m.bank).toEqual({ accountName: "Test Vendor", diff --git a/src/pdf/model/build.ts b/src/pdf/model/build.ts index 9deaaff..673f565 100644 --- a/src/pdf/model/build.ts +++ b/src/pdf/model/build.ts @@ -1,7 +1,7 @@ import { computeLineAmount } from "../../lib/invoice"; +import { quantityText, ratePerText, unitShort, knownUnit } from "../../lib/units"; import { INDIAN_STATES, - UNIT_LABELS, type BankAccount, type Invoice, type Settings, @@ -48,7 +48,15 @@ export interface RenderItem { sno: number; descriptionLines: string[]; hsnSac: string | null; + /** "₹1,200.00 per hour"; null for a fixed-amount line. */ rateText: string | null; + /** The amount alone: "₹1,200.00". */ + priceText: string | null; + /** The unit wording alone: "per hour". */ + perText: string | null; + /** The one-line form: "₹1,200.00/hr". */ + rateShort: string | null; + /** "4.5 hr", "12.75 km", or a bare "3" for the generic unit; "1" for a fixed-amount line. */ qtyText: string; amountText: string; } @@ -140,6 +148,15 @@ function taxRowsFor(invoice: Invoice, supplierStateCode: string): TaxRow[] { } /** Pure: turns the stored invoice into pre-formatted, sanitised data. Totals are the stored values. */ +function rateFields(item: Invoice["items"][number]): Pick { + if (item.mode !== "rate") return { rateText: null, priceText: null, perText: null, rateShort: null }; + const priceText = formatRupees(item.rate); + const perText = ratePerText(item.unit); + // The generic "unit" has no short form beside a quantity, but its rate still reads "/unit". + const suffix = unitShort(item.unit) || knownUnit(item.unit)?.noun || item.unit.trim(); + return { rateText: `${priceText} ${perText}`, priceText, perText, rateShort: `${priceText}/${suffix}` }; +} + export function buildRenderModel(input: BuildInput): RenderModel { const { invoice, logo, signatureDataUri, appVersion } = input; const vendor = vendorFromSnapshot(invoice.vendorSnapshot, input.vendor); @@ -164,8 +181,8 @@ export function buildRenderModel(input: BuildInput): RenderModel { return lines.length ? lines : ["—"]; })(), hsnSac: orNull(item.hsnSac), - rateText: item.mode === "rate" ? `${formatRupees(item.rate)} ${UNIT_LABELS[item.unit]}` : null, - qtyText: item.mode === "rate" ? String(item.quantity) : "1", + ...rateFields(item), + qtyText: item.mode === "rate" ? quantityText(item.quantity, item.unit) : "1", amountText: formatRupees(computeLineAmount(item)), })); diff --git a/src/pdf/templates/band/plan.ts b/src/pdf/templates/band/plan.ts index 6354a06..844a95c 100644 --- a/src/pdf/templates/band/plan.ts +++ b/src/pdf/templates/band/plan.ts @@ -1,6 +1,6 @@ import { type ColSpec } from "../../engine/columns"; import type { PageFrame } from "../../engine/geometry"; -import { getMeasurer, measureToken } from "../../engine/measure"; +import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure"; import { textHeight } from "../../engine/textplan"; import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots"; @@ -150,17 +150,17 @@ export function planBand(model: RenderModel, frame: PageFrame, v: BandVariant, l // --- table: description | qty | price | total ------------------------------------------------------------ const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t))); const colW = (base: number, texts: string[], headText: string) => Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, [headText]))) + 4 + 1); - const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—"); + const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—"; const cw = { - qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "QTY"), - rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "PRICE"), + qty: colW(v.cols.qty, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "QTY"), + rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "PRICE"), amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "TOTAL"), }; const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }]; const headerCells = ["SERVICE", "QTY", "PRICE", "TOTAL"]; const align: BandPlan["align"] = ["left", "center", "right", "right"]; const rows: TableCellValue[][] = model.items.map((item, i) => { - const rate = rateCell(item.rateText, cw.rate - 4, r.tableBody); + const rate = rateCell(item, cw.rate - 4, r.tableBody); const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]); const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines; return [desc, item.qtyText, rateParts, item.amountText]; diff --git a/src/pdf/templates/classic/plan.ts b/src/pdf/templates/classic/plan.ts index 84423ed..789a913 100644 --- a/src/pdf/templates/classic/plan.ts +++ b/src/pdf/templates/classic/plan.ts @@ -1,6 +1,6 @@ import { cols, type ColSpec } from "../../engine/columns"; import type { PageFrame } from "../../engine/geometry"; -import { getMeasurer } from "../../engine/measure"; +import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure"; import { textHeight } from "../../engine/textplan"; import { roleToken } from "../../fonts/roles"; import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; @@ -82,6 +82,9 @@ export interface ClassicPlan { closingH: number; } +const QTY_MIN = 40; +const QTY_MAX = 84; + const leading = (role: Parameters[0]) => roleToken(role).leading as number; /** @@ -92,12 +95,22 @@ const leading = (role: Parameters[0]) => roleToken(role).leadi export function planClassic(model: RenderModel, frame: PageFrame): ClassicPlan { const gaps = elasticGaps(frame); const hsn = Slot.HsnSac(model); + // The quantity column grows to hold "12.75 km" on one line, but the description keeps its 160 pt: a text wider than + // that room wraps at its spaces. + const fixedBesideQty = 28 + (hsn ? 62 : 0) + 92 + 88 + TABLE_GAP * (hsn ? 5 : 4); + const qtyRoom = Math.max(QTY_MIN, Math.min(QTY_MAX, Math.floor(frame.contentWidth - fixedBesideQty - 160))); + const qtyNeed = Math.ceil( + Math.max( + measureToken(getMeasurer(), "tableHead", "Qty"), + ...wrapAwareTexts(model.items.map((i) => i.qtyText), "tableBody", qtyRoom).map((t) => measureToken(getMeasurer(), "tableBody", t)), + ), + ); const columns: ColSpec[] = [ { fixed: 28 }, { fill: true, min: 160 }, ...(hsn ? [{ fixed: 62 } as ColSpec] : []), { fixed: 92 }, - { fixed: 40 }, + { fixed: Math.min(qtyRoom, Math.max(QTY_MIN, qtyNeed + 2)) }, { fixed: 88 }, ]; const header = ["S.No", "Description", ...(hsn ? [hsn.header] : []), "Unit price", "Qty", "Amount"]; diff --git a/src/pdf/templates/cobalt/plan.ts b/src/pdf/templates/cobalt/plan.ts index b9e7a22..dd55a00 100644 --- a/src/pdf/templates/cobalt/plan.ts +++ b/src/pdf/templates/cobalt/plan.ts @@ -1,6 +1,6 @@ import type { ColSpec } from "../../engine/columns"; import type { PageFrame } from "../../engine/geometry"; -import { getMeasurer, measureToken } from "../../engine/measure"; +import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure"; import { textHeight } from "../../engine/textplan"; import { fitSize } from "../../blocks/FitText"; import { innerRulePadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; @@ -19,7 +19,7 @@ const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5"; const MIN_ADDRESS_W = 120; /** Space between a mark and the tagline under a wordmark logo. */ const WORDMARK_TAG_GAP = 3; -const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—"); +const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—"; /** The totals' labels read "Sub Total :", "CGST 9% :" like the design's "Tax 15% :". */ export const labelWithColon = (label: string, rate?: string) => (rate ? `${label} ${rate} :` : `${label} :`); @@ -65,7 +65,7 @@ export function tableRows(model: RenderModel, v: CobaltVariant, rateW: number): const r = v.roles; const hsn = Slot.HsnSac(model); return model.items.map((item, i) => { - const rate = rateCell(item.rateText, rateW, r.tableBody); + const rate = rateCell(item, rateW, r.tableBody); const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]); const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines; return [String(i + 1), desc, item.qtyText, rateParts, item.amountText]; @@ -245,10 +245,10 @@ export function planCobalt(model: RenderModel, frame: PageFrame, v: CobaltVarian const t = v.table; const rule = t.separator; const widest = (role: TypeToken, texts: string[]) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, role, x)))); - const priced = model.items.map((i) => priceOf(i.rateText)); + const priced = model.items.map((i) => priceOf(i)); const amountTexts = model.items.map((i) => i.amountText); const no = Math.max(t.cols.no, widest(r.tableBody, [String(model.items.length)]) + 12); - const qty = Math.max(t.cols.qty, widest(r.tableBody, model.items.map((i) => i.qtyText)) + 12 + rule); + const qty = Math.max(t.cols.qty, widest(r.tableBody, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody)) + 12 + rule); const priceNeed = Math.max(widest(r.tableBody, priced), widest(r.tableHead, ["Price"])) + 6 + rule + 6; const price = Math.max(t.cols.price, priceNeed); const priceInset = Math.max(6, Math.min(t.priceInset, price - rule - widest(r.tableBody, priced) - 4)); diff --git a/src/pdf/templates/harness.ts b/src/pdf/templates/harness.ts index c610116..86fc53b 100644 --- a/src/pdf/templates/harness.ts +++ b/src/pdf/templates/harness.ts @@ -297,6 +297,19 @@ export const FIXTURES: FixtureSpec[] = [ { description: "Audio editing and mastering of all deliverables, including noise reduction, levelling, and export to the three formats requested by the client in the brief of August", mode: "rate", rate: 400, unit: "minute", quantity: 25, amount: 0, sortOrder: 2, hsnSac: "998397" }, ], }, + { + // Units of measure: a decimal distance, an area, a time that is not a whole number of hundredths (4 h 20 min), + // a 12-character custom unit and a month, so the quantity and rate columns see the longest texts they can. + name: "units", + items: [ + { description: "Travel to the client's studio", mode: "rate", rate: 18.5, unit: "km", quantity: 12.75, amount: 0, sortOrder: 0, hsnSac: "996601" }, + { description: "Acoustic treatment, floor area", mode: "rate", rate: 85, unit: "sqft", quantity: 1250.5, amount: 0, sortOrder: 1, hsnSac: "995411" }, + { description: "Evening studio session", mode: "rate", rate: 1500, unit: "hour", quantity: 4 + 1 / 3, amount: 0, sortOrder: 2, hsnSac: "998397" }, + { description: "Packed equipment cases", mode: "rate", rate: 99.5, unit: "wooden crate", quantity: 120, amount: 0, sortOrder: 3, hsnSac: "998397" }, + { description: "Retainer", mode: "rate", rate: 25000, unit: "month", quantity: 1, amount: 0, sortOrder: 4, hsnSac: "998397" }, + { description: "Setup fee", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 8000, sortOrder: 5, hsnSac: "997212" }, + ], + }, { name: "long-notes", items: 30, diff --git a/src/pdf/templates/highlighter/plan.ts b/src/pdf/templates/highlighter/plan.ts index 5ed7a1b..073ffac 100644 --- a/src/pdf/templates/highlighter/plan.ts +++ b/src/pdf/templates/highlighter/plan.ts @@ -1,6 +1,6 @@ import type { ColSpec } from "../../engine/columns"; import type { PageFrame } from "../../engine/geometry"; -import { drawnText, getMeasurer, measureToken } from "../../engine/measure"; +import { drawnText, getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure"; import { textHeight, textLines } from "../../engine/textplan"; import { fitSize } from "../../blocks/FitText"; import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; @@ -37,7 +37,7 @@ export function tableRows(model: RenderModel, v: HighlighterVariant, rateW: numb const r = v.roles; const hsn = Slot.HsnSac(model); return model.items.map((item, i) => { - const rate = rateCell(item.rateText, rateW, r.tableBody); + const rate = rateCell(item, rateW, r.tableBody); const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]); const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines; return [desc, item.qtyText, rateParts, item.amountText]; @@ -158,14 +158,14 @@ export function planHighlighter(model: RenderModel, frame: PageFrame, v: Highlig // --- table ----------------------------------------------------------------------------------------------------------------- const t = v.table; const innerW = W - 2 * fr; - const rateOnly = model.items.map((i) => (i.rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(i.rateText)?.[1] ?? i.rateText) : "—")); + const rateOnly = model.items.map((i) => (i.priceText ?? "—")); const need = (texts: string[], head: string, pad: number) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head))) + pad + v.hair + 6; const shortRate = model.items.map((i) => { - const cell = rateCell(i.rateText, 10000, r.tableBody); + const cell = rateCell(i, 10000, r.tableBody); return typeof cell === "string" ? cell : "—"; }); const cw = { - qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0)), + qty: Math.max(t.cols.qty, need(wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", 0)), rate: Math.max(t.cols.rate, need(rateOnly, "Price", t.numPad), Math.min(t.cols.rate + 24, need(shortRate, "Price", t.numPad))), amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad) - v.hair), }; diff --git a/src/pdf/templates/ledger/plan.ts b/src/pdf/templates/ledger/plan.ts index 69af149..0cfd2f2 100644 --- a/src/pdf/templates/ledger/plan.ts +++ b/src/pdf/templates/ledger/plan.ts @@ -1,6 +1,6 @@ import { colsDetailed, type ColSpec } from "../../engine/columns"; import type { PageFrame } from "../../engine/geometry"; -import { getMeasurer, measureToken } from "../../engine/measure"; +import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure"; import { textHeight, textLines } from "../../engine/textplan"; import { gridPadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots"; @@ -140,13 +140,13 @@ export function planLedger(model: RenderModel, frame: PageFrame, v: LedgerVarian const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t))); const colW = (base: number, texts: string[], headText: string, idx: number) => Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, headText.split(" ")))) + 2 * v.padX + ruleCols(idx) + 1); - const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—"); + const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—"; const nCols = hsnCol ? 5 : 4; const idx = (base: number) => (hsnCol ? base + 1 : base); const c0 = { hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnCol.header, 1) : 0, - rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "Unit Price", idx(1)), - qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty", idx(2)), + rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "Unit Price", idx(1)), + qty: colW(v.cols.qty, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", idx(2)), amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "Amount", idx(3)), }; const columns: ColSpec[] = [{ fill: true }, ...(hsnCol ? [{ fixed: c0.hsn } as ColSpec] : []), { fixed: c0.rate }, { fixed: c0.qty }, { fixed: c0.amount }]; @@ -154,7 +154,7 @@ export function planLedger(model: RenderModel, frame: PageFrame, v: LedgerVarian const align: LedgerPlan["align"] = ["left", ...(hsnCol ? (["left"] as const) : []), "right", "right", "right"]; const rateInner = c0.rate - 2 * v.padX - ruleCols(idx(1)); const tableRows: TableCellValue[][] = model.items.map((item, i) => { - const rate = rateCell(item.rateText, rateInner, r.tableBody); + const rate = rateCell(item, rateInner, r.tableBody); const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]); return [item.descriptionLines, ...(hsnCol ? [hsnCol.values[i]] : []), rateParts, item.qtyText, item.amountText]; }); diff --git a/src/pdf/templates/marble/marble.test.tsx b/src/pdf/templates/marble/marble.test.tsx index 00c42df..488d879 100644 --- a/src/pdf/templates/marble/marble.test.tsx +++ b/src/pdf/templates/marble/marble.test.tsx @@ -282,7 +282,7 @@ describe("table", () => { expect(r0.h).toBeCloseTo(32, 0); expect(r1.y - r0.y).toBeCloseTo(32, 0); expect(r0.y - (head.y + head.h)).toBeCloseTo(V.table.headGap, 0); - const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1")!; + const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1 hr")!; expect((qty.x + qty.right) / 2).toBeCloseTo(355.5, 0); expect(r0.x).toBeCloseTo(31.5, 0); }); diff --git a/src/pdf/templates/marble/plan.ts b/src/pdf/templates/marble/plan.ts index 631023c..3f8a95f 100644 --- a/src/pdf/templates/marble/plan.ts +++ b/src/pdf/templates/marble/plan.ts @@ -1,6 +1,6 @@ import type { ColSpec } from "../../engine/columns"; import type { PageFrame } from "../../engine/geometry"; -import { getMeasurer, measureToken } from "../../engine/measure"; +import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure"; import { textHeight } from "../../engine/textplan"; import { fitSize } from "../../blocks/FitText"; import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; @@ -62,7 +62,7 @@ export function tableRows(model: RenderModel, v: MarbleVariant, rateW: number): const r = v.roles; const hsn = Slot.HsnSac(model); return model.items.map((item, i) => { - const rate = rateCell(item.rateText, rateW, r.tableBody); + const rate = rateCell(item, rateW, r.tableBody); const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]); const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines; return [desc, item.qtyText, rateParts, item.amountText]; @@ -165,14 +165,14 @@ export function planMarble(model: RenderModel, frame: PageFrame, v: MarbleVarian const t = v.table; const need = (texts: string[], head: string, pad: number, headStep: number) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head) - headStep)) + pad + 6; - const rateOnly = model.items.map((i) => (i.rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(i.rateText)?.[1] ?? i.rateText) : "—")); + const rateOnly = model.items.map((i) => (i.priceText ?? "—")); // The rate shows as "₹1,200.00/hr" when the column can take it (up to RATE_MAX wide), else as the rate over "per hour". const shortRate = model.items.map((i) => { - const cell = rateCell(i.rateText, 10000, r.tableBody); + const cell = rateCell(i, 10000, r.tableBody); return typeof cell === "string" ? cell : "—"; }); const cw = { - qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0, 0)), + qty: Math.max(t.cols.qty, need(wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", 0, 0)), rate: Math.max(t.cols.rate, need(rateOnly, "Rate", t.ratePad, 0), Math.min(RATE_MAX, need(shortRate, "Rate", t.ratePad, 0))), amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad, 0)), }; diff --git a/src/pdf/templates/monolith/monolith.test.tsx b/src/pdf/templates/monolith/monolith.test.tsx index 6466007..1490bff 100644 --- a/src/pdf/templates/monolith/monolith.test.tsx +++ b/src/pdf/templates/monolith/monolith.test.tsx @@ -193,7 +193,7 @@ describe("table, totals and footer", () => { expect(amount.text, `row ${i}`).toMatch(/^₹[\d,]+\.\d\d$/); expect(amount.right, `row ${i} amount edge`).toBeCloseTo(right - V.table.numPad, 0); } - const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1")!; + const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1 hr")!; const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false); const left = frameForPrefs(r.prefs).pad.left + plan.W - plan.cw.amount - plan.cw.qty; expect(qty.x + (qty.right - qty.x) / 2).toBeCloseTo(left + plan.cw.qty / 2, 0); diff --git a/src/pdf/templates/monolith/plan.ts b/src/pdf/templates/monolith/plan.ts index 9c4e497..724bd23 100644 --- a/src/pdf/templates/monolith/plan.ts +++ b/src/pdf/templates/monolith/plan.ts @@ -1,6 +1,6 @@ import type { ColSpec } from "../../engine/columns"; import type { PageFrame } from "../../engine/geometry"; -import { getMeasurer, measureToken } from "../../engine/measure"; +import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure"; import { textHeight } from "../../engine/textplan"; import { fitSize } from "../../blocks/FitText"; import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; @@ -18,7 +18,7 @@ const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5"; export const REVERSE_GAP = 4; /** Space between the label of a words / notes block and its text. */ export const LABEL_GAP = 4; -const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—"); +const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—"; /** "Tax" rows read "CGST @ 9%" like the design's "Tax". */ export const atRate = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate}` : label); @@ -58,7 +58,7 @@ export function tableRows(model: RenderModel, v: MonolithVariant, priceW: number const r = v.roles; const hsn = Slot.HsnSac(model); return model.items.map((item, i) => { - const rate = rateCell(item.rateText, priceW, r.tableBody); + const rate = rateCell(item, priceW, r.tableBody); const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]); const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines; return [desc, rateParts, item.qtyText, item.amountText]; @@ -165,11 +165,11 @@ export function planMonolith(model: RenderModel, frame: PageFrame, v: MonolithVa // --- table --------------------------------------------------------------------------------------------------------- const t = v.table; - const priced = model.items.map((i) => priceOf(i.rateText)); + const priced = model.items.map((i) => priceOf(i)); const need = (texts: string[], head: string, pad: number) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head))) + pad + 6; const cw = { price: Math.max(t.cols.price, need(priced, "Price", t.numPad)), - qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0)), + qty: Math.max(t.cols.qty, need(wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", 0)), amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad)), }; const columns: ColSpec[] = [{ fill: true }, { fixed: cw.price }, { fixed: cw.qty }, { fixed: cw.amount }]; diff --git a/src/pdf/templates/neutral/neutral.test.tsx b/src/pdf/templates/neutral/neutral.test.tsx index 40f1682..62422f4 100644 --- a/src/pdf/templates/neutral/neutral.test.tsx +++ b/src/pdf/templates/neutral/neutral.test.tsx @@ -131,9 +131,12 @@ describe("helpers", () => { it("rateCell keeps '₹1,200.00/hr' on one line when it fits and splits the unit otherwise", () => { const body = linea.roles.tableBody; - expect(rateCell("₹1,200.00 per hour", 90, body)).toBe("₹1,200.00/hr"); - expect(rateCell("₹1,200.00 per session", 60, body)).toEqual(["₹1,200.00", "per session"]); - expect(rateCell(null, 90, body)).toBe("—"); + const rate = (price: string, per: string, short: string) => ({ priceText: price, perText: per, rateShort: `${price}/${short}` }); + expect(rateCell(rate("₹1,200.00", "per hour", "hr"), 90, body)).toBe("₹1,200.00/hr"); + expect(rateCell(rate("₹1,200.00", "per session", "session"), 60, body)).toEqual(["₹1,200.00", "per session"]); + expect(rateCell(rate("₹18.50", "per sq ft", "sq ft"), 200, body)).toBe("₹18.50/sq ft"); + expect(rateCell(rate("₹18.50", "per bundle", "bundle"), 20, body)).toEqual(["₹18.50", "per bundle"]); + expect(rateCell({ priceText: null, perText: null, rateShort: null }, 90, body)).toBe("—"); expect(getMeasurer()).toBeTruthy(); }); }); diff --git a/src/pdf/templates/neutral/plan.ts b/src/pdf/templates/neutral/plan.ts index bcd7c26..8d922a5 100644 --- a/src/pdf/templates/neutral/plan.ts +++ b/src/pdf/templates/neutral/plan.ts @@ -1,11 +1,11 @@ import { cols, type ColSpec } from "../../engine/columns"; import type { PageFrame } from "../../engine/geometry"; -import { getMeasurer, measureToken } from "../../engine/measure"; +import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure"; import { textHeight, textLines } from "../../engine/textplan"; import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots"; import { guardPresence } from "../../blocks/TableGuard"; -import type { RenderModel } from "../../model/build"; +import type { RenderItem, RenderModel } from "../../model/build"; import type { PlacedLogo } from "../contract"; import type { GapRange, NeutralVariant } from "./variant"; @@ -103,18 +103,17 @@ export function metaRows(model: RenderModel): MetaRowSpec[] { return rows; } -const SHORT_UNIT: Record = { second: "/sec", minute: "/min", hour: "/hr", session: "/session", unit: "/unit" }; - /** * The rate cell: "₹1,200.00/hr" on one line when it fits the column, else the amount with its unit * ("per hour") on a second line, which keeps one-line rows one line tall. */ -export function rateCell(rateText: string | null, width: number, role: NeutralVariant["roles"]["tableBody"]): TableCellValue { - if (!rateText) return "—"; - const m = /^(.*) per (second|minute|hour|session|unit)$/.exec(rateText); - if (!m) return rateText; - const short = `${m[1]}${SHORT_UNIT[m[2]]}`; - return measureToken(getMeasurer(), role, short) <= width ? short : [m[1], `per ${m[2]}`]; +export function rateCell( + item: Pick, + width: number, + role: NeutralVariant["roles"]["tableBody"], +): TableCellValue { + if (!item.priceText || !item.perText || !item.rateShort) return "—"; + return measureToken(getMeasurer(), role, item.rateShort) <= width ? item.rateShort : [item.priceText, item.perText]; } export interface NeutralPlan { @@ -224,12 +223,12 @@ export function planNeutral(model: RenderModel, frame: PageFrame, v: NeutralVari const widest = (role: NeutralVariant["roles"]["tableBody"], texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t))); const colW = (base: number, texts: string[], headText: string) => Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, headText.split(" ")))) + 2 * v.padX + 1); - const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—"); + const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—"; const hsnHead = hsnCol?.header ?? ""; const c = { hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnHead) : 0, - rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "Unit price"), - qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty"), + rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "Unit price"), + qty: colW(v.cols.qty, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty"), amount: colW(v.cols.amount, [...model.items.map((i) => i.amountText), "Total"], "Total"), }; const columns: ColSpec[] = [ @@ -246,7 +245,7 @@ export function planNeutral(model: RenderModel, frame: PageFrame, v: NeutralVari const tableRows: TableCellValue[][] = model.items.map((item, i) => [ hsn && !hsnCol && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.fine }] : item.descriptionLines, ...(hsnCol ? [hsnCol.values[i]] : []), - rateCell(item.rateText, rateW, r.tableBody), + rateCell(item, rateW, r.tableBody), item.qtyText, item.amountText, ]); diff --git a/src/pdf/templates/northline/plan.ts b/src/pdf/templates/northline/plan.ts index b1ac594..de4c7f3 100644 --- a/src/pdf/templates/northline/plan.ts +++ b/src/pdf/templates/northline/plan.ts @@ -1,6 +1,6 @@ import type { ColSpec } from "../../engine/columns"; import type { PageFrame } from "../../engine/geometry"; -import { getMeasurer, measureToken } from "../../engine/measure"; +import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure"; import { textHeight, textLines } from "../../engine/textplan"; import { DOME_BOX } from "../../decor/Asterisk"; import { fitSize } from "../../blocks/FitText"; @@ -68,13 +68,13 @@ export function idLines(role: TypeToken, text: string, width: number): number { /** Plain shrink-wrapped width of a one-line text. */ export const widthOf = (role: TypeToken, text: string, size?: number) => Math.ceil(measureToken(getMeasurer(), role, text, size ? { size } : {})) + 1; -export const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—"); +export const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—"; /** The table's description + HSN/SAC sub-line, qty, rate (amount over its unit) and amount cells. */ export function tableRows(model: RenderModel, rateW: number, body: TypeToken, sub: TypeToken): TableCellValue[][] { const hsn = Slot.HsnSac(model); return model.items.map((item, i) => { - const rate = rateCell(item.rateText, rateW, body); + const rate = rateCell(item, rateW, body); const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: sub }] as CellPart[]); const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: sub }] : item.descriptionLines; return [desc, item.qtyText, rateParts, item.amountText]; @@ -193,12 +193,12 @@ export function planPurple(model: RenderModel, frame: PageFrame, v: PurpleVarian const align: PurplePlan["align"] = ["left", "center", "right", "right"]; const padY = (t.rowH - leading(r.tableBody)) / 2; const headPadY = (t.headH - leading(r.tableHead)) / 2; - const priced = model.items.map((i) => priceOf(i.rateText)); + const priced = model.items.map((i) => priceOf(i)); const build = (left: number, base: { qty: number; rate: number; amount: number }) => { const tableW = round2(W - left - t.right); const inner = tableW - 2 * t.inset; const cw = { - qty: numericColW(base.qty, r.tableBody, r.tableHead, model.items.map((i) => i.qtyText), "QTY"), + qty: numericColW(base.qty, r.tableBody, r.tableHead, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "QTY"), rate: numericColW(base.rate, r.tableBody, r.tableHead, priced, "RATE"), amount: numericColW(base.amount, r.tableBody, r.tableHead, model.items.map((i) => i.amountText), "AMOUNT"), }; @@ -424,10 +424,10 @@ export function planCitrus(model: RenderModel, frame: PageFrame, v: CitrusVarian // --- table ---------------------------------------------------------------------------------------------------- const t = v.table; const tableW = round2(W - t.left); - const priced = model.items.map((i) => priceOf(i.rateText)); + const priced = model.items.map((i) => priceOf(i)); const amountNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.amountText)), measureToken(m, r.headNum, "AMOUNT"))) + 14 + 4; const rateNeed = Math.ceil(Math.max(0, ...priced.map((p) => measureToken(m, r.tableBody, p)), measureToken(m, r.headNum, "RATE"))) + 14 + 4; - const qtyNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.qtyText)), measureToken(m, r.headNum, "QTY"))) + 6; + const qtyNeed = Math.ceil(Math.max(0, ...wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody).map((t) => measureToken(m, r.tableBody, t)), measureToken(m, r.headNum, "QTY"))) + 6; const amount = Math.max(t.cols.amount, amountNeed); const rate = Math.max(t.cols.rate, rateNeed); const qty = Math.max(Math.min(36, panelW), round2(panelW - rate - amount)); diff --git a/src/pdf/testing/goldens.json b/src/pdf/testing/goldens.json index cc4e47d..e48074f 100644 --- a/src/pdf/testing/goldens.json +++ b/src/pdf/testing/goldens.json @@ -1,26 +1,26 @@ { "classic-a4-tax-3": { "pages": 1, - "fingerprint": "87b12a5c1b1a9b45ac6a8fd5862c8e7b5de8d203731fe4e485872dea5bbb07cd", + "fingerprint": "3bc384c321777db5a513f85f2b24f378523e3ddf5f3fadb08ae97e68198ed4e2", "invoiceNumber": "ST/2026-001", - "totalText": "₹8,625.80", + "totalText": "₹22,077.80", "pageLabel": "Page 1 of 1", "textHashes": [ - "ddda2419b84480c00d8ed76088898277711f448ef11c2383a48494ecd0e4c524" + "e5b4e97805ecb9a50e26694554f810c89cbcd43fba566b27f0f8255a498f753c" ], - "firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A GSTIN: 29AAAAA0000A1Z5 TAX INVOICE Invoice No. ST/2026-001 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 GSTIN: 29ABCDE1234F1Z5 Place of Supply: 29 – Karnataka S . N O D E S C R I P TI O N H S N /S AC U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 998397 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 998397 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 998397 ₹1,200.00 per hour 3 ₹3,600.00 Subtotal ₹7,310.00 Taxable value ₹7,310.00 CGST (9%) ₹657.90 SGST (9%) ₹657.90 Total ₹8,625.80 Amount in words: Indian Rupees Eight Thousand Six Hundred Twenty Five and Eighty Paise Only Tax is payable on reverse charge: No PAY T O Selftest Studio State Bank of India Account No.: 00000000000 Branch: 000000 IFSC: SBIN0000000 Selftest Studio Proprietor Computer-generated invoice; no signature is required. Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 1" + "firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A GSTIN: 29AAAAA0000A1Z5 TAX INVOICE Invoice No. ST/2026-001 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 GSTIN: 29ABCDE1234F1Z5 Place of Supply: 29 – Karnataka S . N O D E S C R I P TI O N H S N /S AC U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 998397 ₹1,200.00 per hour 1 hr ₹1,200.00 2 Voice-over session 2 - narration for module 2 998397 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 998397 ₹1,200.00 per km 12.5 km ₹15,000.00 Subtotal ₹18,710.00 Taxable value ₹18,710.00 CGST (9%) ₹1,683.90 SGST (9%) ₹1,683.90 Total ₹22,077.80 Amount in words: Indian Rupees Twenty Two Thousand Seventy Seven and Eighty Paise Only Tax is payable on reverse charge: No PAY T O Selftest Studio State Bank of India Account No.: 00000000000 Branch: 000000 IFSC: SBIN0000000 Selftest Studio Proprietor Computer-generated invoice; no signature is required. Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 1" }, "classic-letter-unreg-60": { "pages": 3, - "fingerprint": "6999be0875f78e2c87aee1fd0ffa1dfe651faa7a7c0a9b4c2f78c1bdff53c7d4", + "fingerprint": "6d5ee73ce95af3f8871a4634e50a082ca9f34865ba4a5ffcb541dbae8d132719", "invoiceNumber": "ST/2026-002", - "totalText": "₹1,56,000.00", + "totalText": "₹17,04,000.00", "pageLabel": "Page 1 of 3", "textHashes": [ - "4a0cf8a7c1565037edd4d9941251e030fd9e6fca25845b971465e5cf9d916936", - "870e9a70c0546550d002e5eb5728f342b1d235094c6b645ddda68a6306b33f7b", - "fca30345bc73289cdcad200f888f917ff9a5386cdd64e41ea7dccc64f2b7e5e0" + "314a9e6ff0e6736435b56bb96014c2d7e8b2398ab7f7904c176a90628015154c", + "fee554a8b908951fc5021a1adfc47b245da8a622c35e64770bfd8f38b9b6db70", + "3c4fc1cbba3081b4dcef564b31e3c4cac0aff951923c747ec63a4ce0fd4811e9" ], - "firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A INVOICE Invoice No. ST/2026-002 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 S . N O D E S C R I P TI O N U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 ₹1,200.00 per hour 3 ₹3,600.00 4 Voice-over session 4 - narration for module 4 — 1 ₹2,530.00 5 Voice-over session 5 - narration for module 5 ₹1,200.00 per hour 2 ₹2,400.00 6 Voice-over session 6 - narration for module 6 — 1 ₹2,550.00 7 Voice-over session 7 - narration for module 7 ₹1,200.00 per hour 1 ₹1,200.00 8 Voice-over session 8 - narration for module 8 — 1 ₹2,570.00 9 Voice-over session 9 - narration for module 9 ₹1,200.00 per hour 3 ₹3,600.00 10 Voice-over session 10 - narration for module 10 — 1 ₹2,590.00 11 Voice-over session 11 - narration for module 11 ₹1,200.00 per hour 2 ₹2,400.00 12 Voice-over session 12 - narration for module 12 — 1 ₹2,610.00 13 Voice-over session 13 - narration for module 13 ₹1,200.00 per hour 1 ₹1,200.00 14 Voice-over session 14 - narration for module 14 — 1 ₹2,630.00 15 Voice-over session 15 - narration for module 15 ₹1,200.00 per hour 3 ₹3,600.00 16 Voice-over session 16 - narration for module 16 — 1 ₹2,650.00 17 Voice-over session 17 - narration for module 17 ₹1,200.00 per hour 2 ₹2,400.00 18 Voice-over session 18 - narration for module 18 — 1 ₹2,670.00 19 Voice-over session 19 - narration for module 19 ₹1,200.00 per hour 1 ₹1,200.00 Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 3" + "firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A INVOICE Invoice No. ST/2026-002 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 S . N O D E S C R I P TI O N U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 ₹1,200.00 per hour 1 hr ₹1,200.00 2 Voice-over session 2 - narration for module 2 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 ₹1,200.00 per km 12.5 km ₹15,000.00 4 Voice-over session 4 - narration for module 4 — 1 ₹2,530.00 5 Voice-over session 5 - narration for module 5 ₹1,200.00 per sq ft 121.5 sq ft ₹1,45,800.00 6 Voice-over session 6 - narration for module 6 — 1 ₹2,550.00 7 Voice-over session 7 - narration for module 7 ₹1,200.00 per hour 1 hr ₹1,200.00 8 Voice-over session 8 - narration for module 8 — 1 ₹2,570.00 9 Voice-over session 9 - narration for module 9 ₹1,200.00 per km 12.5 km ₹15,000.00 10 Voice-over session 10 - narration for module 10 — 1 ₹2,590.00 11 Voice-over session 11 - narration for module 11 ₹1,200.00 per sq ft 121.5 sq ft ₹1,45,800.00 12 Voice-over session 12 - narration for module 12 — 1 ₹2,610.00 13 Voice-over session 13 - narration for module 13 ₹1,200.00 per hour 1 hr ₹1,200.00 14 Voice-over session 14 - narration for module 14 — 1 ₹2,630.00 15 Voice-over session 15 - narration for module 15 ₹1,200.00 per km 12.5 km ₹15,000.00 16 Voice-over session 16 - narration for module 16 — 1 ₹2,650.00 17 Voice-over session 17 - narration for module 17 ₹1,200.00 per sq ft 121.5 sq ft ₹1,45,800.00 18 Voice-over session 18 - narration for module 18 — 1 ₹2,670.00 19 Voice-over session 19 - narration for module 19 ₹1,200.00 per hour 1 hr ₹1,200.00 Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 3" } } diff --git a/src/pdf/testing/selfTestFixtures.ts b/src/pdf/testing/selfTestFixtures.ts index 9397212..4e501c7 100644 --- a/src/pdf/testing/selfTestFixtures.ts +++ b/src/pdf/testing/selfTestFixtures.ts @@ -58,14 +58,21 @@ const BANK: BankAccount = { isDefault: true, }; +// Rate lines cycle through units so the webview self-test also measures "hr", "km" and "sq ft" text. +const UNIT_CYCLE: Array<{ unit: string; quantity: (i: number) => number }> = [ + { unit: "hour", quantity: (i) => 1 + (i % 3) }, + { unit: "km", quantity: (i) => 10.5 + (i % 3) }, + { unit: "sqft", quantity: (i) => 120.5 + (i % 3) }, +]; + const items = (n: number, hsn: boolean): InvoiceItem[] => Array.from({ length: n }, (_, i) => ({ id: i + 1, description: `Voice-over session ${i + 1} - narration for module ${i + 1}`, mode: i % 2 === 0 ? "rate" : "fixed", rate: 1200, - unit: "hour", - quantity: 1 + (i % 3), + unit: UNIT_CYCLE[(i >> 1) % UNIT_CYCLE.length].unit, + quantity: UNIT_CYCLE[(i >> 1) % UNIT_CYCLE.length].quantity(i), amount: 2500 + i * 10, sortOrder: i, ...(hsn ? { hsnSac: "998397" } : {}), diff --git a/src/views/InvoiceDetail.tsx b/src/views/InvoiceDetail.tsx index e8ddc2c..e9432a7 100644 --- a/src/views/InvoiceDetail.tsx +++ b/src/views/InvoiceDetail.tsx @@ -50,6 +50,7 @@ import PdfPreview from "../components/PdfPreview"; import RecordPaymentModal from "../components/RecordPaymentModal"; import { useToast } from "../components/ToastProvider"; import { INDIAN_STATES, type Invoice, type Settings } from "../lib/types"; +import { quantityText } from "../lib/units"; interface Props { invoiceId: number; @@ -456,7 +457,7 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate {item.description} {item.mode === "rate" ? (
- {item.quantity} {item.unit} × ₹{formatAmount(item.rate)} + {quantityText(item.quantity, item.unit)} × ₹{formatAmount(item.rate)}
) : null} diff --git a/src/views/NewInvoice.tsx b/src/views/NewInvoice.tsx index 2689271..6b32598 100644 --- a/src/views/NewInvoice.tsx +++ b/src/views/NewInvoice.tsx @@ -34,7 +34,8 @@ import { } from "@carbon/icons-react"; import { confirm } from "@tauri-apps/plugin-dialog"; import { api } from "../lib/api"; -import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types"; +import { EMPTY_ITEM, INDIAN_STATES } from "../lib/types"; +import { isValidUnit } from "../lib/units"; import type { BankAccount, Client, @@ -44,7 +45,6 @@ import type { InvoiceInput, InvoiceItem, LineMode, - LineUnit, Settings, TaxType, } from "../lib/types"; @@ -57,6 +57,8 @@ import { formToPreviewInvoice } from "../lib/previewInvoice"; import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft"; import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts"; import PageSetupControls from "../components/PageSetupControls"; +import QuantityInput from "../components/QuantityInput"; +import UnitPicker from "../components/UnitPicker"; import { useInvoicePdf } from "../hooks/useInvoicePdf"; import { useInvoiceExport } from "../hooks/useInvoiceExport"; import { autoPushIssued } from "../lib/erpnextUi"; @@ -257,6 +259,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv ? "cgst_sgst" : "igst"; const clientGstinError = gstinFullError(clientGstin); + // Quantity boxes that hold text which is not a quantity; they keep the last good value, so issuing must wait. + const badQuantities = useRef(new Set()); + const markQuantity = (id: string, ok: boolean) => { + if (ok) badQuantities.current.delete(id); + else badQuantities.current.add(id); + }; const totals = useMemo( () => computeTotals(items, discount, effectiveTaxType, taxRate), [items, discount, effectiveTaxType, taxRate], @@ -614,6 +622,14 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv setError(`Client GSTIN: ${clientGstinError}`); return; } + if (badQuantities.current.size > 0) { + setError("A quantity is not a number. Fix the line items marked in red."); + return; + } + if (items.some((i) => i.mode === "rate" && !isValidUnit(i.unit))) { + setError("A line item has no valid unit. Pick one, or type a custom unit."); + return; + } setSaving(true); setError(null); exp.dismiss(); @@ -997,28 +1013,22 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv onChange={(_e, { value }) => updateItem(index, { rate: Number(value) || 0 })} />
-
- + onChange={(unit) => updateItem(index, { unit })} + />
-
- + updateItem(index, { quantity: Number(value) || 0 })} + unit={item.unit} + onChange={(quantity) => updateItem(index, { quantity })} + onValidity={(ok) => markQuantity(`qty-${index}`, ok)} />