Add units of measure and fractional quantities to rate lines

Quantities can now be time, count, distance, area, weight or volume, or a custom unit, instead of only
second/minute/hour/session/unit.

- Registry in src/lib/units.ts (mirrored in src-tauri/src/units.rs): grouped built-in units plus a custom unit of 1-12
  characters. A rate line's unit is validated when an invoice or a preset is saved; migration M12 drops the old unit
  allow-list from item_presets and keeps every row.
- Time is typed as h:mm: "4:30" hours counts as 4.5, "4:20" as 4.333... (kept exact, so rate x quantity rounds once, the
  same in TypeScript and Rust). Other units take plain decimals up to 3 places. A bad quantity blocks issuing.
- The PDF quantity column shows the unit ("4.5 hr", "12.75 km"); the rate keeps "per hour" / "/hr". RenderItem carries
  priceText, perText and rateShort, so no template parses the rate text with a regex over five hard-coded units.
- Quantity columns are sized from the widest word once a quantity with its unit is wider than 72 pt, so one long
  custom unit cannot squeeze the description (the serenity and citrus-split tables overflowed in the new fixture).
  Classic's quantity column is now content-sized.
- ERPNext: the default UOM map comes from the registry, with fractional-capable UOMs for time, distance, area, weight
  and volume. A custom unit is sent as Nos. A quantity that is not exact to 3 decimals (4:20 hours) is still refused for
  push, as before.
- Goldens and template thumbnails regenerated; the template harness has a "units" fixture with the longest texts.

Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
This commit is contained in:
2026-10-06 09:35:20 +05:30
parent cd067ad584
commit 3aa0d33a01
52 changed files with 910 additions and 148 deletions
+4
View File
@@ -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 - **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`. `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 - **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. 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 - **Client details.** The name, GSTIN, printed address and place of supply are frozen on the invoice. The structured
Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

+49 -1
View File
@@ -7,6 +7,7 @@ use super::payments::{derive_summary, summary_for, today_ist};
use crate::models::{ use crate::models::{
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, Settings, BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, Settings,
}; };
use crate::units;
use crate::AppState; use crate::AppState;
use chrono::NaiveDate; use chrono::NaiveDate;
use rusqlite::{named_params, params, Connection, OptionalExtension, Row}; 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. /// overflow integer paise arithmetic.
const MAX_LINE_RUPEES: f64 = 1e11; 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> { fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
if items.is_empty() { if items.is_empty() {
return Err("Add at least one line item".into()); 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 { if item.quantity > MAX_LINE_RUPEES {
return Err(format!("Line {n}: quantity is too large")); 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); let amount = gst::line_amount_paise(item);
if amount <= 0 { if amount <= 0 {
return Err(format!("Line {n}: amount must be greater than zero")); return Err(format!("Line {n}: amount must be greater than zero"));
@@ -427,7 +438,7 @@ pub fn issue_invoice_impl(
item.description, item.description,
item.mode, item.mode,
item.rate, item.rate,
item.unit, stored_unit(item),
item.quantity, item.quantity,
paise_to_rupees(gst::line_amount_paise(item)), paise_to_rupees(gst::line_amount_paise(item)),
i as i64, i as i64,
@@ -854,6 +865,43 @@ mod tests {
assert_eq!(inv.items[1].hsn_sac, ""); 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<f64> = 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] #[test]
fn tax_type_mismatch_is_rejected_and_the_counter_stays() { fn tax_type_mismatch_is_rejected_and_the_counter_stays() {
let mut conn = registered(); let mut conn = registered();
+21 -8
View File
@@ -1,10 +1,10 @@
use crate::models::ItemPreset; use crate::models::ItemPreset;
use crate::units;
use crate::AppState; use crate::AppState;
use rusqlite::{params, Connection, Row}; use rusqlite::{params, Connection, Row};
use tauri::State; use tauri::State;
const MODES: &[&str] = &["fixed", "rate"]; 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. /// Upper bound on a preset amount (paise); keeps later integer-paise arithmetic far from overflow.
const MAX_RATE_PAISE: i64 = 100_000_000_000; const MAX_RATE_PAISE: i64 = 100_000_000_000;
@@ -45,9 +45,8 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
if !MODES.contains(&preset.mode.as_str()) { if !MODES.contains(&preset.mode.as_str()) {
return Err(format!("Unknown line type \"{}\"", preset.mode)); return Err(format!("Unknown line type \"{}\"", preset.mode));
} }
if !UNITS.contains(&preset.unit.as_str()) { units::validate(&preset.unit)?;
return Err(format!("Unknown unit \"{}\"", preset.unit)); let unit = units::normalize(&preset.unit);
}
if !(0..=MAX_RATE_PAISE).contains(&preset.rate_paise) { if !(0..=MAX_RATE_PAISE).contains(&preset.rate_paise) {
return Err("Amount is out of range".into()); return Err("Amount is out of range".into());
} }
@@ -58,7 +57,7 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
.execute( .execute(
"UPDATE item_presets SET description=?1, hsn_sac=?2, mode=?3, unit=?4, rate_paise=?5, sort_order=?6 "UPDATE item_presets SET description=?1, hsn_sac=?2, mode=?3, unit=?4, rate_paise=?5, sort_order=?6
WHERE id=?7", WHERE id=?7",
params![description, hsn, preset.mode, preset.unit, preset.rate_paise, preset.sort_order, id], params![description, hsn, preset.mode, unit, preset.rate_paise, preset.sort_order, id],
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if n == 0 { if n == 0 {
@@ -74,7 +73,7 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
description, description,
hsn, hsn,
preset.mode, preset.mode,
preset.unit, unit,
preset.rate_paise, preset.rate_paise,
preset.sort_order, preset.sort_order,
chrono::Utc::now().to_rfc3339() chrono::Utc::now().to_rfc3339()
@@ -156,6 +155,18 @@ mod tests {
delete_preset_impl(&conn, 12345).unwrap(); delete_preset_impl(&conn, 12345).unwrap();
} }
#[test]
fn units_beyond_the_original_five_are_stored_as_given() {
let conn = open_in_memory().unwrap();
for (typed, stored) in [("km", "km"), ("SQFT", "sqft"), (" bundle ", "bundle"), ("sq.ft/day", "sq.ft/day")] {
let mut p = preset(typed);
p.mode = "rate".into();
p.unit = typed.into();
p.rate_paise = 5_000;
assert_eq!(save_preset_impl(&conn, p).unwrap().unit, stored, "{typed}");
}
}
#[test] #[test]
fn validation_rejects_bad_presets() { fn validation_rejects_bad_presets() {
let conn = open_in_memory().unwrap(); let conn = open_in_memory().unwrap();
@@ -166,9 +177,11 @@ mod tests {
let mut p = preset("X"); let mut p = preset("X");
p.mode = "weird".into(); p.mode = "weird".into();
assert!(save_preset_impl(&conn, p).is_err()); assert!(save_preset_impl(&conn, p).is_err());
for bad in ["", " ", "far too long a unit", "bad;unit", "₹"] {
let mut p = preset("X"); let mut p = preset("X");
p.unit = "decade".into(); p.unit = bad.into();
assert!(save_preset_impl(&conn, p).is_err()); assert!(save_preset_impl(&conn, p).is_err(), "{bad:?}");
}
let mut ghost = preset("Ghost"); let mut ghost = preset("Ghost");
ghost.id = Some(77); ghost.id = Some(77);
assert!(save_preset_impl(&conn, ghost).is_err()); assert!(save_preset_impl(&conn, ghost).is_err());
+67 -2
View File
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
const BACKUP_PREFIX: &str = "voiced-pre-v"; const BACKUP_PREFIX: &str = "voiced-pre-v";
/// Highest schema version, i.e. the number of entries in `migrations()`. /// Highest schema version, i.e. the number of entries in `migrations()`.
pub(crate) const LATEST_VERSION: i64 = 11; pub(crate) const LATEST_VERSION: i64 = 12;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum DbError { pub enum DbError {
@@ -345,6 +345,27 @@ DROP TABLE erpnext_sync;
ALTER TABLE erpnext_sync_new RENAME TO erpnext_sync; ALTER TABLE erpnext_sync_new RENAME TO erpnext_sync;
"#; "#;
/// Version 12: `item_presets.unit` no longer lists five units. Units of measure now cover time, distance, area, weight and
/// volume, and a preset may carry a custom unit, so the allow-list becomes a length check and src/units.rs does the rest.
/// SQLite cannot alter a CHECK constraint, so the table is rebuilt; every M7/M10 row is copied over with its id.
const M12: &str = r#"
CREATE TABLE item_presets_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
description TEXT NOT NULL,
hsn_sac TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'fixed' CHECK (mode IN ('fixed', 'rate')),
unit TEXT NOT NULL DEFAULT 'unit' CHECK (length(trim(unit)) BETWEEN 1 AND 12),
rate_paise INTEGER NOT NULL DEFAULT 0 CHECK (rate_paise >= 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> { fn migrations() -> Migrations<'static> {
Migrations::new(vec![ Migrations::new(vec![
M::up(SCHEMA), M::up(SCHEMA),
@@ -358,6 +379,7 @@ fn migrations() -> Migrations<'static> {
M::up(M9), M::up(M9),
M::up(M10), M::up(M10),
M::up(M11), 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()); assert!(conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).is_err());
migrations().to_latest(&mut conn).unwrap(); 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<String>) = conn let row: (String, i64, String, String, String, Option<String>) = conn
.query_row( .query_row(
"SELECT remote_name, remote_docstatus, status, payload_hash, attachment_sha256, synced_at FROM erpnext_sync WHERE invoice_id = 1", "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()); 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<String>)> = 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::<Result<_, _>>()
.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) { fn build_v1_database(path: &Path) {
let conn = Connection::open(path).unwrap(); let conn = Connection::open(path).unwrap();
conn.execute_batch(V1_SCHEMA).unwrap(); conn.execute_batch(V1_SCHEMA).unwrap();
+2 -11
View File
@@ -61,18 +61,9 @@ impl NamingMode {
} }
/// Voiced line unit to ERPNext UOM. Whole-number UOMs such as "Nos" reject fractional quantities, /// 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<String, String> { pub fn default_uom_map() -> BTreeMap<String, String> {
[ crate::units::KNOWN.iter().map(|(id, uom)| (id.to_string(), uom.to_string())).collect()
("second", "Second"),
("minute", "Minute"),
("hour", "Hour"),
("session", "Nos"),
("unit", "Nos"),
]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
} }
/// Everything stored for the integration, secret included. Backend-only. /// Everything stored for the integration, secret included. Backend-only.
+1
View File
@@ -5,6 +5,7 @@ mod integrations;
mod logo; mod logo;
mod models; mod models;
mod selftest; mod selftest;
mod units;
use rusqlite::Connection; use rusqlite::Connection;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
+120
View File
@@ -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<String> {
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": <problem>`.
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}");
}
}
}
+3 -2
View File
@@ -35,6 +35,7 @@ import {
type FormProblems, type FormProblems,
} from "../lib/erpnextUi"; } from "../lib/erpnextUi";
import { useToast } from "./ToastProvider"; import { useToast } from "./ToastProvider";
import { unitLabel } from "../lib/units";
const SEVERITY_TITLE = { error: "Needs fixing", warning: "Warning", info: "Note" } as const; const SEVERITY_TITLE = { error: "Needs fixing", warning: "Warning", info: "Note" } as const;
@@ -496,12 +497,12 @@ export default function ErpnextSettingsPanel({ active }: { active: boolean }) {
/> />
<fieldset className="voiced-erp__uoms"> <fieldset className="voiced-erp__uoms">
<legend>Units of measure</legend> <legend>Units of measure</legend>
<p className="voiced-muted">The ERPNext unit used for each Voiced unit. Whole-number units such as Nos reject fractional quantities.</p> <p className="voiced-muted">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.</p>
{UOM_UNITS.map((unit) => ( {UOM_UNITS.map((unit) => (
<PickField <PickField
key={unit} key={unit}
id={`erp-uom-${unit}`} id={`erp-uom-${unit}`}
label={`Voiced ${unit}`} label={`Voiced ${unitLabel(unit).toLowerCase()}`}
value={form.uomMap[unit] ?? ""} value={form.uomMap[unit] ?? ""}
onChange={(v) => setForm((f) => (f ? { ...f, uomMap: { ...f.uomMap, [unit]: v } } : f))} onChange={(v) => setForm((f) => (f ? { ...f, uomMap: { ...f.uomMap, [unit]: v } } : f))}
items={opts.uoms} items={opts.uoms}
+9 -13
View File
@@ -18,7 +18,9 @@ import {
} from "@carbon/react"; } from "@carbon/react";
import { Add, Edit, TrashCan } from "@carbon/icons-react"; import { Add, Edit, TrashCan } from "@carbon/icons-react";
import { api } from "../lib/api"; 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 { paiseToRupees, rupeesToPaise } from "../lib/presets";
import { formatAmount } from "../lib/format"; import { formatAmount } from "../lib/format";
import { useToast } from "./ToastProvider"; import { useToast } from "./ToastProvider";
@@ -61,9 +63,12 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
if (!editing) return; if (!editing) return;
setTouched(true); setTouched(true);
if (!editing.description.trim()) return; if (!editing.description.trim()) return;
if (editing.mode === "rate" && !isValidUnit(editing.unit)) return;
setBusy(true); setBusy(true);
try { 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()); toast.success(editing.id === null ? "Preset added" : "Preset updated", editing.description.trim());
setEditing(null); setEditing(null);
await load(); await load();
@@ -123,7 +128,7 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
<TableRow key={p.id ?? p.description}> <TableRow key={p.id ?? p.description}>
<TableCell>{p.description}</TableCell> <TableCell>{p.description}</TableCell>
<TableCell>{p.hsnSac || "—"}</TableCell> <TableCell>{p.hsnSac || "—"}</TableCell>
<TableCell>{p.mode === "rate" ? UNIT_LABELS[p.unit] : "Fixed amount"}</TableCell> <TableCell>{p.mode === "rate" ? ratePerText(p.unit) : "Fixed amount"}</TableCell>
<TableCell>₹{formatAmount(paiseToRupees(p.ratePaise))}</TableCell> <TableCell>₹{formatAmount(paiseToRupees(p.ratePaise))}</TableCell>
<TableCell> <TableCell>
<div className="voiced-header-action"> <div className="voiced-header-action">
@@ -199,16 +204,7 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
</Column> </Column>
{editing.mode === "rate" ? ( {editing.mode === "rate" ? (
<Column sm={4} md={4} lg={8}> <Column sm={4} md={4} lg={8}>
<Select <UnitPicker id="ip-unit" value={editing.unit} onChange={(unit) => set("unit", unit)} />
id="ip-unit"
labelText="Unit"
value={editing.unit}
onChange={(e) => set("unit", e.target.value as LineUnit)}
>
{Object.entries(UNIT_LABELS).map(([value, text]) => (
<SelectItem key={value} value={value} text={text} />
))}
</Select>
</Column> </Column>
) : null} ) : null}
<Column sm={4} md={4} lg={8}> <Column sm={4} md={4} lg={8}>
+70
View File
@@ -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 (
<TextInput
id={id}
disabled={disabled}
labelText="Quantity"
inputMode="decimal"
autoComplete="off"
placeholder={clock === "hours" ? "e.g. 4:30 or 4.5" : undefined}
value={text}
invalid={!parsed.ok}
invalidText={parsed.ok ? "" : parsed.error}
helperText={helper}
onChange={(e) => {
const next = e.target.value;
setText(next);
const r = parseQuantity(next, unit);
if (r.ok) {
reported.current = { value: r.value, unit };
onChange(r.value);
}
}}
/>
);
}
+77
View File
@@ -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 (
<>
<Select
id={id}
disabled={disabled}
labelText={labelText}
value={custom ? OTHER : value}
onChange={(e) => {
if (e.target.value === OTHER) {
setOther(true);
emit("");
} else {
setOther(false);
emit(e.target.value);
}
}}
>
{UNIT_GROUPS.map((group) => (
<SelectItemGroup key={group} label={group}>
{UNITS.filter((u) => u.group === group).map((u) => (
<SelectItem key={u.id} value={u.id} text={u.label} />
))}
</SelectItemGroup>
))}
<SelectItem value={OTHER} text="Other…" />
</Select>
{custom ? (
<TextInput
id={`${id}-custom`}
disabled={disabled}
labelText="Custom unit"
hideLabel
placeholder="e.g. bundle"
maxLength={MAX_CUSTOM_UNIT_LENGTH}
value={value}
invalid={Boolean(problem)}
invalidText={problem ?? ""}
onChange={(e) => emit(e.target.value)}
/>
) : null}
</>
);
}
+1 -1
View File
@@ -23,7 +23,7 @@ export interface ErpnextConfig {
paymentBankAccount: string; paymentBankAccount: string;
tdsAccount: string; tdsAccount: string;
defaultItemCode: 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<string, string>; uomMap: Record<string, string>;
customerGroup: string; customerGroup: string;
territory: string; territory: string;
+1 -1
View File
@@ -232,7 +232,7 @@ describe("settings form", () => {
expect(input).not.toHaveProperty("apiSecretSet"); expect(input).not.toHaveProperty("apiSecretSet");
expect(input).not.toHaveProperty("lastDetectResult"); expect(input).not.toHaveProperty("lastDetectResult");
expect(input.apiSecret).toBe(""); 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"); expect(withDefaultUoms({ hour: " " }).hour).toBe("Hour");
}); });
+3 -8
View File
@@ -1,4 +1,5 @@
/** Pure helpers behind the ERPNext screens (Settings, History, invoice detail, auto-push). No UI, no Tauri. */ /** Pure helpers behind the ERPNext screens (Settings, History, invoice detail, auto-push). No UI, no Tauri. */
import { UNITS } from "./units";
import type { import type {
ErpnextConfig, ErpnextConfig,
ErpnextConfigInput, ErpnextConfigInput,
@@ -259,14 +260,8 @@ export function versionsLine(test: ErpnextConnectionTest): string {
// ---- settings form ---- // ---- settings form ----
export const UOM_UNITS = ["second", "minute", "hour", "session", "unit"] as const; export const UOM_UNITS: readonly string[] = UNITS.map((u) => u.id);
export const DEFAULT_UOM_MAP: Record<string, string> = { export const DEFAULT_UOM_MAP: Record<string, string> = Object.fromEntries(UNITS.map((u) => [u.id, u.uom]));
second: "Second",
minute: "Minute",
hour: "Hour",
session: "Nos",
unit: "Nos",
};
export function withDefaultUoms(map: Record<string, string> | undefined): Record<string, string> { export function withDefaultUoms(map: Record<string, string> | undefined): Record<string, string> {
const out: Record<string, string> = { ...DEFAULT_UOM_MAP }; const out: Record<string, string> = { ...DEFAULT_UOM_MAP };
+14
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { computeLineAmount, computeTotals } from "./invoice"; import { computeLineAmount, computeTotals } from "./invoice";
import { EMPTY_ITEM, type InvoiceItem } from "./types"; 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 // These cases mirror the unit tests in src-tauri/src/gst.rs, so the on-screen preview
// and the stored totals stay identical. // and the stored totals stay identical.
@@ -40,6 +41,19 @@ describe("computeTotals", () => {
expect(computeLineAmount(rate(99.99, 2.5))).toBe(249.98); 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", () => { it("clamps the discount to the subtotal", () => {
const items = [fixed(100), fixed(50.5)]; const items = [fixed(100), fixed(50.5)];
const over = computeTotals(items, 999.99, "igst", 18); const over = computeTotals(items, 999.99, "igst", 18);
+2 -8
View File
@@ -1,6 +1,7 @@
export type TaxType = "none" | "cgst_sgst" | "igst"; export type TaxType = "none" | "cgst_sgst" | "igst";
export type LineMode = "fixed" | "rate"; 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 { export interface Settings {
vendorName: string; vendorName: string;
@@ -302,10 +303,3 @@ export const INDIAN_STATES: { code: string; name: string }[] = [
{ code: "97", name: "Other Territory" }, { code: "97", name: "Other Territory" },
]; ];
export const UNIT_LABELS: Record<LineUnit, string> = {
second: "per second",
minute: "per minute",
hour: "per hour",
session: "per session",
unit: "per unit",
};
+116
View File
@@ -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("");
});
});
+162
View File
@@ -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;
}
+17
View File
@@ -181,6 +181,23 @@ export function getMeasurer(): Measurer {
/** The text as it will be drawn: caps applied. */ /** The text as it will be drawn: caps applied. */
export const drawnText = (token: TypeToken, text: string) => (token.caps ? text.toUpperCase() : text); 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. */ /** Width of `text` set in a role/token at an optional size override, tracking and caps included. */
export function measureToken( export function measureToken(
m: Measurer, m: Measurer,
+16 -1
View File
@@ -133,7 +133,10 @@ describe("unregistered invoice", () => {
descriptionLines: ["Voice-over", "session A"], descriptionLines: ["Voice-over", "session A"],
hsnSac: "998397", hsnSac: "998397",
rateText: "₹1,200.00 per hour", 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", amountText: "₹3,000.00",
}); });
expect(m.items[1]).toEqual({ expect(m.items[1]).toEqual({
@@ -141,10 +144,22 @@ describe("unregistered invoice", () => {
descriptionLines: ["—"], descriptionLines: ["—"],
hsnSac: null, hsnSac: null,
rateText: null, rateText: null,
priceText: null,
perText: null,
rateShort: null,
qtyText: "1", qtyText: "1",
amountText: "₹2,500.50", 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", () => { it("labels the bank lines and defaults the signature to the vendor name", () => {
expect(m.bank).toEqual({ expect(m.bank).toEqual({
accountName: "Test Vendor", accountName: "Test Vendor",
+20 -3
View File
@@ -1,7 +1,7 @@
import { computeLineAmount } from "../../lib/invoice"; import { computeLineAmount } from "../../lib/invoice";
import { quantityText, ratePerText, unitShort, knownUnit } from "../../lib/units";
import { import {
INDIAN_STATES, INDIAN_STATES,
UNIT_LABELS,
type BankAccount, type BankAccount,
type Invoice, type Invoice,
type Settings, type Settings,
@@ -48,7 +48,15 @@ export interface RenderItem {
sno: number; sno: number;
descriptionLines: string[]; descriptionLines: string[];
hsnSac: string | null; hsnSac: string | null;
/** "₹1,200.00 per hour"; null for a fixed-amount line. */
rateText: string | null; 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; qtyText: string;
amountText: 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. */ /** Pure: turns the stored invoice into pre-formatted, sanitised data. Totals are the stored values. */
function rateFields(item: Invoice["items"][number]): Pick<RenderItem, "rateText" | "priceText" | "perText" | "rateShort"> {
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 { export function buildRenderModel(input: BuildInput): RenderModel {
const { invoice, logo, signatureDataUri, appVersion } = input; const { invoice, logo, signatureDataUri, appVersion } = input;
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, input.vendor); const vendor = vendorFromSnapshot(invoice.vendorSnapshot, input.vendor);
@@ -164,8 +181,8 @@ export function buildRenderModel(input: BuildInput): RenderModel {
return lines.length ? lines : ["—"]; return lines.length ? lines : ["—"];
})(), })(),
hsnSac: orNull(item.hsnSac), hsnSac: orNull(item.hsnSac),
rateText: item.mode === "rate" ? `${formatRupees(item.rate)} ${UNIT_LABELS[item.unit]}` : null, ...rateFields(item),
qtyText: item.mode === "rate" ? String(item.quantity) : "1", qtyText: item.mode === "rate" ? quantityText(item.quantity, item.unit) : "1",
amountText: formatRupees(computeLineAmount(item)), amountText: formatRupees(computeLineAmount(item)),
})); }));
+5 -5
View File
@@ -1,6 +1,6 @@
import { type ColSpec } from "../../engine/columns"; import { type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; 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 { textHeight } from "../../engine/textplan";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots"; 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 ------------------------------------------------------------ // --- table: description | qty | price | total ------------------------------------------------------------
const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t))); 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 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 = { const cw = {
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "QTY"), 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.rateText)), "PRICE"), rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "PRICE"),
amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "TOTAL"), 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 columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }];
const headerCells = ["SERVICE", "QTY", "PRICE", "TOTAL"]; const headerCells = ["SERVICE", "QTY", "PRICE", "TOTAL"];
const align: BandPlan["align"] = ["left", "center", "right", "right"]; const align: BandPlan["align"] = ["left", "center", "right", "right"];
const rows: TableCellValue[][] = model.items.map((item, i) => { 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 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; 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]; return [desc, item.qtyText, rateParts, item.amountText];
+15 -2
View File
@@ -1,6 +1,6 @@
import { cols, type ColSpec } from "../../engine/columns"; import { cols, type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; import type { PageFrame } from "../../engine/geometry";
import { getMeasurer } from "../../engine/measure"; import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight } from "../../engine/textplan"; import { textHeight } from "../../engine/textplan";
import { roleToken } from "../../fonts/roles"; import { roleToken } from "../../fonts/roles";
import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
@@ -82,6 +82,9 @@ export interface ClassicPlan {
closingH: number; closingH: number;
} }
const QTY_MIN = 40;
const QTY_MAX = 84;
const leading = (role: Parameters<typeof roleToken>[0]) => roleToken(role).leading as number; const leading = (role: Parameters<typeof roleToken>[0]) => roleToken(role).leading as number;
/** /**
@@ -92,12 +95,22 @@ const leading = (role: Parameters<typeof roleToken>[0]) => roleToken(role).leadi
export function planClassic(model: RenderModel, frame: PageFrame): ClassicPlan { export function planClassic(model: RenderModel, frame: PageFrame): ClassicPlan {
const gaps = elasticGaps(frame); const gaps = elasticGaps(frame);
const hsn = Slot.HsnSac(model); 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[] = [ const columns: ColSpec[] = [
{ fixed: 28 }, { fixed: 28 },
{ fill: true, min: 160 }, { fill: true, min: 160 },
...(hsn ? [{ fixed: 62 } as ColSpec] : []), ...(hsn ? [{ fixed: 62 } as ColSpec] : []),
{ fixed: 92 }, { fixed: 92 },
{ fixed: 40 }, { fixed: Math.min(qtyRoom, Math.max(QTY_MIN, qtyNeed + 2)) },
{ fixed: 88 }, { fixed: 88 },
]; ];
const header = ["S.No", "Description", ...(hsn ? [hsn.header] : []), "Unit price", "Qty", "Amount"]; const header = ["S.No", "Description", ...(hsn ? [hsn.header] : []), "Unit price", "Qty", "Amount"];
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns"; import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; 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 { textHeight } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText"; import { fitSize } from "../../blocks/FitText";
import { innerRulePadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; 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; const MIN_ADDRESS_W = 120;
/** Space between a mark and the tagline under a wordmark logo. */ /** Space between a mark and the tagline under a wordmark logo. */
const WORDMARK_TAG_GAP = 3; 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% :". */ /** 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} :`); 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 r = v.roles;
const hsn = Slot.HsnSac(model); const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => { 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 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; 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]; 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 t = v.table;
const rule = t.separator; const rule = t.separator;
const widest = (role: TypeToken, texts: string[]) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, role, x)))); 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 amountTexts = model.items.map((i) => i.amountText);
const no = Math.max(t.cols.no, widest(r.tableBody, [String(model.items.length)]) + 12); 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 priceNeed = Math.max(widest(r.tableBody, priced), widest(r.tableHead, ["Price"])) + 6 + rule + 6;
const price = Math.max(t.cols.price, priceNeed); const price = Math.max(t.cols.price, priceNeed);
const priceInset = Math.max(6, Math.min(t.priceInset, price - rule - widest(r.tableBody, priced) - 4)); const priceInset = Math.max(6, Math.min(t.priceInset, price - rule - widest(r.tableBody, priced) - 4));
+13
View File
@@ -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" }, { 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", name: "long-notes",
items: 30, items: 30,
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns"; import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; 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 { textHeight, textLines } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText"; import { fitSize } from "../../blocks/FitText";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; 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 r = v.roles;
const hsn = Slot.HsnSac(model); const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => { 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 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; 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]; return [desc, item.qtyText, rateParts, item.amountText];
@@ -158,14 +158,14 @@ export function planHighlighter(model: RenderModel, frame: PageFrame, v: Highlig
// --- table ----------------------------------------------------------------------------------------------------------------- // --- table -----------------------------------------------------------------------------------------------------------------
const t = v.table; const t = v.table;
const innerW = W - 2 * fr; 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 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 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 : "—"; return typeof cell === "string" ? cell : "—";
}); });
const cw = { 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))), 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), amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad) - v.hair),
}; };
+5 -5
View File
@@ -1,6 +1,6 @@
import { colsDetailed, type ColSpec } from "../../engine/columns"; import { colsDetailed, type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; 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 { textHeight, textLines } from "../../engine/textplan";
import { gridPadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; import { gridPadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots"; 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 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) => 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); 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 nCols = hsnCol ? 5 : 4;
const idx = (base: number) => (hsnCol ? base + 1 : base); const idx = (base: number) => (hsnCol ? base + 1 : base);
const c0 = { const c0 = {
hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnCol.header, 1) : 0, 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)), rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "Unit Price", idx(1)),
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty", idx(2)), 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)), 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 }]; 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 align: LedgerPlan["align"] = ["left", ...(hsnCol ? (["left"] as const) : []), "right", "right", "right"];
const rateInner = c0.rate - 2 * v.padX - ruleCols(idx(1)); const rateInner = c0.rate - 2 * v.padX - ruleCols(idx(1));
const tableRows: TableCellValue[][] = model.items.map((item, i) => { 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[]); 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]; return [item.descriptionLines, ...(hsnCol ? [hsnCol.values[i]] : []), rateParts, item.qtyText, item.amountText];
}); });
+1 -1
View File
@@ -282,7 +282,7 @@ describe("table", () => {
expect(r0.h).toBeCloseTo(32, 0); expect(r0.h).toBeCloseTo(32, 0);
expect(r1.y - r0.y).toBeCloseTo(32, 0); expect(r1.y - r0.y).toBeCloseTo(32, 0);
expect(r0.y - (head.y + head.h)).toBeCloseTo(V.table.headGap, 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((qty.x + qty.right) / 2).toBeCloseTo(355.5, 0);
expect(r0.x).toBeCloseTo(31.5, 0); expect(r0.x).toBeCloseTo(31.5, 0);
}); });
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns"; import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; 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 { textHeight } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText"; import { fitSize } from "../../blocks/FitText";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; 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 r = v.roles;
const hsn = Slot.HsnSac(model); const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => { 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 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; 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]; 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 t = v.table;
const need = (texts: string[], head: string, pad: number, headStep: number) => 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; 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". // 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 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 : "—"; return typeof cell === "string" ? cell : "—";
}); });
const cw = { 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))), 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)), amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad, 0)),
}; };
+1 -1
View File
@@ -193,7 +193,7 @@ describe("table, totals and footer", () => {
expect(amount.text, `row ${i}`).toMatch(/^₹[\d,]+\.\d\d$/); expect(amount.text, `row ${i}`).toMatch(/^₹[\d,]+\.\d\d$/);
expect(amount.right, `row ${i} amount edge`).toBeCloseTo(right - V.table.numPad, 0); 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 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; 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); expect(qty.x + (qty.right - qty.x) / 2).toBeCloseTo(left + plan.cw.qty / 2, 0);
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns"; import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; 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 { textHeight } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText"; import { fitSize } from "../../blocks/FitText";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; 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; export const REVERSE_GAP = 4;
/** Space between the label of a words / notes block and its text. */ /** Space between the label of a words / notes block and its text. */
export const LABEL_GAP = 4; 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". */ /** "Tax" rows read "CGST @ 9%" like the design's "Tax". */
export const atRate = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate}` : label); 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 r = v.roles;
const hsn = Slot.HsnSac(model); const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => { 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 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; 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]; return [desc, rateParts, item.qtyText, item.amountText];
@@ -165,11 +165,11 @@ export function planMonolith(model: RenderModel, frame: PageFrame, v: MonolithVa
// --- table --------------------------------------------------------------------------------------------------------- // --- table ---------------------------------------------------------------------------------------------------------
const t = v.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 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 = { const cw = {
price: Math.max(t.cols.price, need(priced, "Price", t.numPad)), 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)), 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 }]; const columns: ColSpec[] = [{ fill: true }, { fixed: cw.price }, { fixed: cw.qty }, { fixed: cw.amount }];
+6 -3
View File
@@ -131,9 +131,12 @@ describe("helpers", () => {
it("rateCell keeps '₹1,200.00/hr' on one line when it fits and splits the unit otherwise", () => { it("rateCell keeps '₹1,200.00/hr' on one line when it fits and splits the unit otherwise", () => {
const body = linea.roles.tableBody; const body = linea.roles.tableBody;
expect(rateCell("₹1,200.00 per hour", 90, body)).toBe("₹1,200.00/hr"); const rate = (price: string, per: string, short: string) => ({ priceText: price, perText: per, rateShort: `${price}/${short}` });
expect(rateCell("₹1,200.00 per session", 60, body)).toEqual(["₹1,200.00", "per session"]); expect(rateCell(rate("₹1,200.00", "per hour", "hr"), 90, body)).toBe("₹1,200.00/hr");
expect(rateCell(null, 90, body)).toBe("—"); 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(); expect(getMeasurer()).toBeTruthy();
}); });
}); });
+13 -14
View File
@@ -1,11 +1,11 @@
import { cols, type ColSpec } from "../../engine/columns"; import { cols, type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; 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 { textHeight, textLines } from "../../engine/textplan";
import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots"; import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
import { guardPresence } from "../../blocks/TableGuard"; 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 { PlacedLogo } from "../contract";
import type { GapRange, NeutralVariant } from "./variant"; import type { GapRange, NeutralVariant } from "./variant";
@@ -103,18 +103,17 @@ export function metaRows(model: RenderModel): MetaRowSpec[] {
return rows; return rows;
} }
const SHORT_UNIT: Record<string, string> = { 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 * 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. * ("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 { export function rateCell(
if (!rateText) return "—"; item: Pick<RenderItem, "priceText" | "perText" | "rateShort">,
const m = /^(.*) per (second|minute|hour|session|unit)$/.exec(rateText); width: number,
if (!m) return rateText; role: NeutralVariant["roles"]["tableBody"],
const short = `${m[1]}${SHORT_UNIT[m[2]]}`; ): TableCellValue {
return measureToken(getMeasurer(), role, short) <= width ? short : [m[1], `per ${m[2]}`]; if (!item.priceText || !item.perText || !item.rateShort) return "—";
return measureToken(getMeasurer(), role, item.rateShort) <= width ? item.rateShort : [item.priceText, item.perText];
} }
export interface NeutralPlan { 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 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) => 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); 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 hsnHead = hsnCol?.header ?? "";
const c = { const c = {
hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnHead) : 0, hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnHead) : 0,
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "Unit price"), rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "Unit price"),
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty"), 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"), amount: colW(v.cols.amount, [...model.items.map((i) => i.amountText), "Total"], "Total"),
}; };
const columns: ColSpec[] = [ const columns: ColSpec[] = [
@@ -246,7 +245,7 @@ export function planNeutral(model: RenderModel, frame: PageFrame, v: NeutralVari
const tableRows: TableCellValue[][] = model.items.map((item, i) => [ 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, hsn && !hsnCol && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.fine }] : item.descriptionLines,
...(hsnCol ? [hsnCol.values[i]] : []), ...(hsnCol ? [hsnCol.values[i]] : []),
rateCell(item.rateText, rateW, r.tableBody), rateCell(item, rateW, r.tableBody),
item.qtyText, item.qtyText,
item.amountText, item.amountText,
]); ]);
+7 -7
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns"; import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; 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 { textHeight, textLines } from "../../engine/textplan";
import { DOME_BOX } from "../../decor/Asterisk"; import { DOME_BOX } from "../../decor/Asterisk";
import { fitSize } from "../../blocks/FitText"; 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. */ /** 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 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. */ /** 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[][] { export function tableRows(model: RenderModel, rateW: number, body: TypeToken, sub: TypeToken): TableCellValue[][] {
const hsn = Slot.HsnSac(model); const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => { 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 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; 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]; 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 align: PurplePlan["align"] = ["left", "center", "right", "right"];
const padY = (t.rowH - leading(r.tableBody)) / 2; const padY = (t.rowH - leading(r.tableBody)) / 2;
const headPadY = (t.headH - leading(r.tableHead)) / 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 build = (left: number, base: { qty: number; rate: number; amount: number }) => {
const tableW = round2(W - left - t.right); const tableW = round2(W - left - t.right);
const inner = tableW - 2 * t.inset; const inner = tableW - 2 * t.inset;
const cw = { 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"), 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"), 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 ---------------------------------------------------------------------------------------------------- // --- table ----------------------------------------------------------------------------------------------------
const t = v.table; const t = v.table;
const tableW = round2(W - t.left); 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 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 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 amount = Math.max(t.cols.amount, amountNeed);
const rate = Math.max(t.cols.rate, rateNeed); const rate = Math.max(t.cols.rate, rateNeed);
const qty = Math.max(Math.min(36, panelW), round2(panelW - rate - amount)); const qty = Math.max(Math.min(36, panelW), round2(panelW - rate - amount));
+10 -10
View File
@@ -1,26 +1,26 @@
{ {
"classic-a4-tax-3": { "classic-a4-tax-3": {
"pages": 1, "pages": 1,
"fingerprint": "87b12a5c1b1a9b45ac6a8fd5862c8e7b5de8d203731fe4e485872dea5bbb07cd", "fingerprint": "3bc384c321777db5a513f85f2b24f378523e3ddf5f3fadb08ae97e68198ed4e2",
"invoiceNumber": "ST/2026-001", "invoiceNumber": "ST/2026-001",
"totalText": "₹8,625.80", "totalText": "₹22,077.80",
"pageLabel": "Page 1 of 1", "pageLabel": "Page 1 of 1",
"textHashes": [ "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": { "classic-letter-unreg-60": {
"pages": 3, "pages": 3,
"fingerprint": "6999be0875f78e2c87aee1fd0ffa1dfe651faa7a7c0a9b4c2f78c1bdff53c7d4", "fingerprint": "6d5ee73ce95af3f8871a4634e50a082ca9f34865ba4a5ffcb541dbae8d132719",
"invoiceNumber": "ST/2026-002", "invoiceNumber": "ST/2026-002",
"totalText": "₹1,56,000.00", "totalText": "₹17,04,000.00",
"pageLabel": "Page 1 of 3", "pageLabel": "Page 1 of 3",
"textHashes": [ "textHashes": [
"4a0cf8a7c1565037edd4d9941251e030fd9e6fca25845b971465e5cf9d916936", "314a9e6ff0e6736435b56bb96014c2d7e8b2398ab7f7904c176a90628015154c",
"870e9a70c0546550d002e5eb5728f342b1d235094c6b645ddda68a6306b33f7b", "fee554a8b908951fc5021a1adfc47b245da8a622c35e64770bfd8f38b9b6db70",
"fca30345bc73289cdcad200f888f917ff9a5386cdd64e41ea7dccc64f2b7e5e0" "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"
} }
} }
+9 -2
View File
@@ -58,14 +58,21 @@ const BANK: BankAccount = {
isDefault: true, 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[] => const items = (n: number, hsn: boolean): InvoiceItem[] =>
Array.from({ length: n }, (_, i) => ({ Array.from({ length: n }, (_, i) => ({
id: i + 1, id: i + 1,
description: `Voice-over session ${i + 1} - narration for module ${i + 1}`, description: `Voice-over session ${i + 1} - narration for module ${i + 1}`,
mode: i % 2 === 0 ? "rate" : "fixed", mode: i % 2 === 0 ? "rate" : "fixed",
rate: 1200, rate: 1200,
unit: "hour", unit: UNIT_CYCLE[(i >> 1) % UNIT_CYCLE.length].unit,
quantity: 1 + (i % 3), quantity: UNIT_CYCLE[(i >> 1) % UNIT_CYCLE.length].quantity(i),
amount: 2500 + i * 10, amount: 2500 + i * 10,
sortOrder: i, sortOrder: i,
...(hsn ? { hsnSac: "998397" } : {}), ...(hsn ? { hsnSac: "998397" } : {}),
+2 -1
View File
@@ -50,6 +50,7 @@ import PdfPreview from "../components/PdfPreview";
import RecordPaymentModal from "../components/RecordPaymentModal"; import RecordPaymentModal from "../components/RecordPaymentModal";
import { useToast } from "../components/ToastProvider"; import { useToast } from "../components/ToastProvider";
import { INDIAN_STATES, type Invoice, type Settings } from "../lib/types"; import { INDIAN_STATES, type Invoice, type Settings } from "../lib/types";
import { quantityText } from "../lib/units";
interface Props { interface Props {
invoiceId: number; invoiceId: number;
@@ -456,7 +457,7 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
<span className="voiced-detail__pre">{item.description}</span> <span className="voiced-detail__pre">{item.description}</span>
{item.mode === "rate" ? ( {item.mode === "rate" ? (
<div className="voiced-muted"> <div className="voiced-muted">
{item.quantity} {item.unit} × ₹{formatAmount(item.rate)} {quantityText(item.quantity, item.unit)} × ₹{formatAmount(item.rate)}
</div> </div>
) : null} ) : null}
</TableCell> </TableCell>
+27 -17
View File
@@ -34,7 +34,8 @@ import {
} from "@carbon/icons-react"; } from "@carbon/icons-react";
import { confirm } from "@tauri-apps/plugin-dialog"; import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api"; 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 { import type {
BankAccount, BankAccount,
Client, Client,
@@ -44,7 +45,6 @@ import type {
InvoiceInput, InvoiceInput,
InvoiceItem, InvoiceItem,
LineMode, LineMode,
LineUnit,
Settings, Settings,
TaxType, TaxType,
} from "../lib/types"; } from "../lib/types";
@@ -57,6 +57,8 @@ import { formToPreviewInvoice } from "../lib/previewInvoice";
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft"; import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts"; import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
import PageSetupControls from "../components/PageSetupControls"; import PageSetupControls from "../components/PageSetupControls";
import QuantityInput from "../components/QuantityInput";
import UnitPicker from "../components/UnitPicker";
import { useInvoicePdf } from "../hooks/useInvoicePdf"; import { useInvoicePdf } from "../hooks/useInvoicePdf";
import { useInvoiceExport } from "../hooks/useInvoiceExport"; import { useInvoiceExport } from "../hooks/useInvoiceExport";
import { autoPushIssued } from "../lib/erpnextUi"; import { autoPushIssued } from "../lib/erpnextUi";
@@ -257,6 +259,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
? "cgst_sgst" ? "cgst_sgst"
: "igst"; : "igst";
const clientGstinError = gstinFullError(clientGstin); 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<string>());
const markQuantity = (id: string, ok: boolean) => {
if (ok) badQuantities.current.delete(id);
else badQuantities.current.add(id);
};
const totals = useMemo( const totals = useMemo(
() => computeTotals(items, discount, effectiveTaxType, taxRate), () => computeTotals(items, discount, effectiveTaxType, taxRate),
[items, discount, effectiveTaxType, taxRate], [items, discount, effectiveTaxType, taxRate],
@@ -614,6 +622,14 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
setError(`Client GSTIN: ${clientGstinError}`); setError(`Client GSTIN: ${clientGstinError}`);
return; 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); setSaving(true);
setError(null); setError(null);
exp.dismiss(); exp.dismiss();
@@ -997,28 +1013,22 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
onChange={(_e, { value }) => updateItem(index, { rate: Number(value) || 0 })} onChange={(_e, { value }) => updateItem(index, { rate: Number(value) || 0 })}
/> />
</div> </div>
<div style={{ flex: "0 1 140px" }}> <div style={{ flex: "0 1 160px" }}>
<Select <UnitPicker
id={`unit-${index}`} id={`unit-${index}`}
disabled={locked} disabled={locked}
labelText="Unit"
value={item.unit} value={item.unit}
onChange={(e) => updateItem(index, { unit: e.target.value as LineUnit })} onChange={(unit) => updateItem(index, { unit })}
> />
{Object.entries(UNIT_LABELS).map(([value, text]) => (
<SelectItem key={value} value={value} text={text} />
))}
</Select>
</div> </div>
<div style={{ flex: "0 1 110px" }}> <div style={{ flex: "0 1 130px" }}>
<NumberInput <QuantityInput
id={`qty-${index}`} id={`qty-${index}`}
disabled={locked} disabled={locked}
label="Quantity"
min={0}
step={1}
value={item.quantity} value={item.quantity}
onChange={(_e, { value }) => updateItem(index, { quantity: Number(value) || 0 })} unit={item.unit}
onChange={(quantity) => updateItem(index, { quantity })}
onValidity={(ok) => markQuantity(`qty-${index}`, ok)}
/> />
</div> </div>
</> </>