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
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
@@ -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();
|
||||||
|
|||||||
@@ -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());
|
||||||
let mut p = preset("X");
|
for bad in ["", " ", "far too long a unit", "bad;unit", "₹"] {
|
||||||
p.unit = "decade".into();
|
let mut p = preset("X");
|
||||||
assert!(save_preset_impl(&conn, p).is_err());
|
p.unit = bad.into();
|
||||||
|
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());
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 }];
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" } : {}),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||