Author SHA1 Message Date
xavierk 93a4ece886 Run cargo fmt on the unit code and drop a quantity box's invalid mark when its line is removed
Windows build / build (push) Waiting to run
CI / security (pull_request) Successful in 58s
CI / lint (pull_request) Successful in 2m51s
CI / e2e (pull_request) Successful in 3m34s
CI / ai-review (pull_request) Failing after 10m2s
The new CI runs cargo fmt --check. QuantityInput also reported its validity only when it changed, so removing a line
whose quantity box held bad text left that box counted as invalid and blocked issuing; it now reports valid when it
unmounts, and no longer writes a ref during render.

Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
2026-10-06 09:47:43 +05:30
xavierk d1976499a2 Merge origin/main (CI quality gates) into the 1.1.0 release
# Conflicts:
#	package-lock.json
#	src/views/NewInvoice.tsx
2026-10-06 09:43:47 +05:30
xavierk 0faf3d63ee Release 1.1.0
Version bump, the README line about units, and two new manual checks in the release checklist (export then New invoice
through the real save dialog, and units).

Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
2026-10-06 09:35:26 +05:30
xavierk 3aa0d33a01 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
2026-10-06 09:35:20 +05:30
xavierk cd067ad584 Fix the blank window after an export
Every successful export left the window blank and unresponsive, so the app had to be killed. The "Saved to ..." notice
(and the export-failed notice) held buttons inside a Carbon InlineNotification. Carbon throws "component should have no
interactive child nodes" in an effect, and an uncaught throw makes React unmount the whole tree. Found by driving the
real debug binary through WebKitWebDriver: the page body was empty and the error listener showed that throw.

- The buttons now sit beside the notice, and the failed-export notice uses ActionableNotification's own Retry action.
- ErrorBoundary around the app (reload screen instead of a blank window) and around each export notice.
- A lint test that fails if a Carbon notification wraps an interactive element.
- The real-webview self-test mounts the saved and failed notices and fails on an uncaught React error, and issues and
  exports a second invoice after the first.

Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
2026-10-06 09:35:10 +05:30
xavierk 66a39b2291 Fix clipped text in the invoice form
The saved-client box showed "Timber Media Pv" for "Timber Media Pvt Ltd": the form sat on a Carbon Grid nested in a
Column, whose tracks came out much narrower than the page grid, so the combo box was about 230 px wide at the default
window size and the date, GSTIN and PO fields were just as tight. The fields now sit on a plain 12-column CSS grid
(.voiced-fields), the client fields use the full width, place of supply gets its own row, and the line type select is
wide enough for "Rate x quantity".

Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
2026-10-06 09:35:02 +05:30
xavierk 2083d2ff6a Merge pull request 'ci: add quality gates (semgrep, lint, e2e smoke, advisory PR-Agent)' (#1) from ci/quality-gates into main
CI / security (push) Successful in 37s
CI / lint (push) Successful in 2m48s
CI / e2e (push) Successful in 2m33s
CI / ai-review (push) Skipped
2026-10-05 14:27:40 +00:00
68 changed files with 1264 additions and 206 deletions
+6 -6
View File
@@ -8,7 +8,7 @@ connection. The only optional network feature is the ERPNext push, which you con
Targets: Void Linux (xbps, WebKitGTK 2.50 or newer) and Windows 10/11 (NSIS installer, WebView2).
Current release: **1.0.2**. Installers and the Void package are attached to the release on the project's Gitea
Current release: **1.1.0**. Installers and the Void package are attached to the release on the project's Gitea
page (see "Installing").
## Features
@@ -19,8 +19,8 @@ page (see "Installing").
series prefix or digits.
- **Invoices.** Automatic numbering in a persistent series (for example `INV/2026-001`) with a one-click new series,
drafts, issue and cancel. An issued invoice is immutable: it stores a snapshot of the vendor, client, GST figures
and layout. Line items are a fixed amount or rate x quantity (per second, minute, hour, session or unit), with
item presets.
and layout. Line items are a fixed amount or rate x quantity in a unit of measure (time, distance, area, weight,
volume, a count, or a custom unit; a time typed as `4:30` counts as 4.5 hours), with item presets.
- **GST.** All money and tax maths is done in integer paise. CGST+SGST, IGST or UTGST follow from the supplier's
state and the place of supply, and a mismatch is rejected. An unregistered vendor gets a plain "Invoice" with no
tax. GSTIN, PAN, IFSC, email and phone are validated; amounts are written in Indian words.
@@ -85,11 +85,11 @@ The release procedure, including the manual first-run checklist, is in `docs/REL
## Installing
Download the files for your platform from the release page of the project on `git.bongbetic.com` (Releases, then
`v1.0.2`).
`v1.1.0`).
- **Windows 10/11:** run `Voiced_1.0.2_x64-setup.exe`. It is a per-user install and is not code-signed, so Windows
- **Windows 10/11:** run `Voiced_1.1.0_x64-setup.exe`. It is a per-user install and is not code-signed, so Windows
SmartScreen may warn: choose "More info", then "Run anyway".
- **Void Linux:** install `voiced-1.0.2_1.x86_64.xbps` from a local repository (see "Installing on Void Linux").
- **Void Linux:** install `voiced-1.1.0_1.x86_64.xbps` from a local repository (see "Installing on Void Linux").
On first launch the setup wizard asks for your business details, logo, bank account and invoice numbering. Nothing is
pre-filled. Uninstalling does not delete your data (see "Where data lives"); to start from scratch use
+5
View File
@@ -71,6 +71,11 @@ Linux and Windows. Windows has never been run on real hardware, so treat it as u
- **Template picker.** Open all 14 templates; switch A4 and Letter and the margin presets; check the logo slots.
- **Export.** Issue an invoice; export searchable and flattened; change settings and re-export; "Original as issued"
must be byte-identical to the archived PDF. On Windows, check file names with `/`, `:` and reserved names.
- **Export, then New invoice.** Issue and export in both formats through the real save dialog, press "New invoice",
issue and export a second one. The window must stay usable after every export (1.1.0 fixed a blank window caused
by buttons inside a Carbon notification; the dialog itself can only be checked by hand).
- **Units.** Add a rate line in hours typed as `4:30` (amount counts 4.5), one in km and one with a custom unit;
check the quantity and rate columns on a few templates and A4/Letter.
- **Fonts.** Settings, Fonts: import a TTF for Now, Gotham or Open Sauce One (if a licensed file is at hand), confirm
a template that uses it changes, remove it again.
- **Clients.** Create a client with structured address and GST category; use it on an invoice.
+4
View File
@@ -33,6 +33,10 @@ Notes on the fields:
- **Items.** `taxable_value` of a line is the line amount before the invoice-level discount. `totals.taxable_value` is
`subtotal - discount`, the base the tax is charged on. A fixed-amount line has `quantity "1"` and `unit null`.
- **Units.** On a rate line `unit` is a built-in id (`unit`, `piece`, `set`, `session`, `second`, `minute`, `hour`, `day`,
`week`, `month`, `km`, `m`, `sqft`, `sqm`, `kg`, `litre`) or the custom text typed on the invoice (at most 12 characters:
letters, digits, spaces and `. / -`). `quantity` may be fractional; a time typed as `4:20` hours is stored as the exact
repeating decimal.
- **UTGST.** When the supplier is in a union territory without a legislature (state codes 04, 26, 31, 35, 38) the second
head is reported under `utgst` and `sgst` is zero.
- **Client details.** The name, GSTIN, printed address and place of supply are frozen on the invoice. The structured
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "voiced",
"version": "1.0.2",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "voiced",
"version": "1.0.2",
"version": "1.1.0",
"license": "Apache-2.0",
"dependencies": {
"@carbon/icons-react": "^11.89.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "voiced",
"private": true,
"version": "1.0.2",
"version": "1.1.0",
"license": "Apache-2.0",
"description": "Voiced — offline invoicing desktop app by Bongbetic",
"type": "module",
+1 -1
View File
@@ -28,7 +28,7 @@
# pure Rust and need no extra system libraries.
#
pkgname=voiced
version=1.0.2
version=1.1.0
revision=1
archs="x86_64*"
hostmakedepends="rust nodejs pkg-config"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

+1 -1
View File
@@ -4647,7 +4647,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "voiced"
version = "1.0.2"
version = "1.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "voiced"
version = "1.0.2"
version = "1.1.0"
license = "Apache-2.0"
description = "Voiced — offline invoicing desktop app by Bongbetic"
authors = ["Bongbetic"]
+49 -1
View File
@@ -8,6 +8,7 @@ use crate::models::{
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary,
Settings,
};
use crate::units;
use crate::AppState;
use chrono::NaiveDate;
use rusqlite::{named_params, params, Connection, OptionalExtension, Row};
@@ -131,6 +132,15 @@ fn parse_date(label: &str, value: &str) -> Result<(), String> {
/// overflow integer paise arithmetic.
const MAX_LINE_RUPEES: f64 = 1e11;
/// The unit written to the database. A fixed-amount line has no unit of its own, so whatever the form held is not kept.
fn stored_unit(item: &InvoiceItem) -> String {
if item.mode == "rate" {
units::normalize(&item.unit)
} else {
"unit".to_string()
}
}
fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
if items.is_empty() {
return Err("Add at least one line item".into());
@@ -148,6 +158,7 @@ fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
if item.quantity > MAX_LINE_RUPEES {
return Err(format!("Line {n}: quantity is too large"));
}
units::validate(&item.unit).map_err(|e| format!("Line {n}: {e}"))?;
let amount = gst::line_amount_paise(item);
if amount <= 0 {
return Err(format!("Line {n}: amount must be greater than zero"));
@@ -441,7 +452,7 @@ pub fn issue_invoice_impl(
item.description,
item.mode,
item.rate,
item.unit,
stored_unit(item),
item.quantity,
paise_to_rupees(gst::line_amount_paise(item)),
i as i64,
@@ -916,6 +927,43 @@ mod tests {
assert_eq!(inv.items[1].hsn_sac, "");
}
#[test]
fn lines_can_use_any_unit_and_fractional_quantities() {
let mut conn = registered();
let inv = issue(
&mut conn,
input(json!({
"items": [
{"description": "Travel", "mode": "rate", "rate": 18.0, "quantity": 12.75, "unit": "km"},
{"description": "Studio", "mode": "rate", "rate": 1200.0, "quantity": 4.0 + 1.0 / 3.0, "unit": "HOUR"},
{"description": "Parcels", "mode": "rate", "rate": 99.5, "quantity": 3.0, "unit": " bundle "},
{"description": "Fee", "mode": "fixed", "amount": 10.0, "unit": "not a unit;"},
],
})),
)
.unwrap();
let units: Vec<&str> = inv.items.iter().map(|i| i.unit.as_str()).collect();
assert_eq!(units, ["km", "hour", "bundle", "unit"]);
let amounts: Vec<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]
fn tax_type_mismatch_is_rejected_and_the_counter_stays() {
let mut conn = registered();
+26 -8
View File
@@ -1,10 +1,10 @@
use crate::models::ItemPreset;
use crate::units;
use crate::AppState;
use rusqlite::{params, Connection, Row};
use tauri::State;
const MODES: &[&str] = &["fixed", "rate"];
const UNITS: &[&str] = &["second", "minute", "hour", "session", "unit"];
/// Upper bound on a preset amount (paise); keeps later integer-paise arithmetic far from overflow.
const MAX_RATE_PAISE: i64 = 100_000_000_000;
@@ -45,9 +45,8 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
if !MODES.contains(&preset.mode.as_str()) {
return Err(format!("Unknown line type \"{}\"", preset.mode));
}
if !UNITS.contains(&preset.unit.as_str()) {
return Err(format!("Unknown unit \"{}\"", preset.unit));
}
units::validate(&preset.unit)?;
let unit = units::normalize(&preset.unit);
if !(0..=MAX_RATE_PAISE).contains(&preset.rate_paise) {
return Err("Amount is out of range".into());
}
@@ -58,7 +57,7 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
.execute(
"UPDATE item_presets SET description=?1, hsn_sac=?2, mode=?3, unit=?4, rate_paise=?5, sort_order=?6
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())?;
if n == 0 {
@@ -74,7 +73,7 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
description,
hsn,
preset.mode,
preset.unit,
unit,
preset.rate_paise,
preset.sort_order,
chrono::Utc::now().to_rfc3339()
@@ -159,6 +158,23 @@ mod tests {
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]
fn validation_rejects_bad_presets() {
let conn = open_in_memory().unwrap();
@@ -169,9 +185,11 @@ mod tests {
let mut p = preset("X");
p.mode = "weird".into();
assert!(save_preset_impl(&conn, p).is_err());
for bad in ["", " ", "far too long a unit", "bad;unit", "₹"] {
let mut p = preset("X");
p.unit = "decade".into();
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");
ghost.id = Some(77);
assert!(save_preset_impl(&conn, ghost).is_err());
+104 -2
View File
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
const BACKUP_PREFIX: &str = "voiced-pre-v";
/// 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)]
pub enum DbError {
@@ -345,6 +345,27 @@ DROP TABLE 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> {
Migrations::new(vec![
M::up(SCHEMA),
@@ -358,6 +379,7 @@ fn migrations() -> Migrations<'static> {
M::up(M9),
M::up(M10),
M::up(M11),
M::up(M12),
])
}
@@ -1049,7 +1071,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.is_err());
migrations().to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 11);
assert_eq!(user_version(&conn), LATEST_VERSION);
let row: (String, i64, String, String, String, Option<String>) = conn
.query_row(
"SELECT remote_name, remote_docstatus, status, payload_hash, attachment_sha256, synced_at FROM erpnext_sync WHERE invoice_id = 1",
@@ -1082,6 +1104,86 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.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) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(V1_SCHEMA).unwrap();
+3 -9
View File
@@ -65,17 +65,11 @@ impl NamingMode {
}
/// Voiced line unit to ERPNext UOM. Whole-number UOMs such as "Nos" reject fractional quantities,
/// which is why hours and minutes map to their own UOMs.
/// which is why hours, minutes, kilometres and the like map to their own UOMs (see `units::KNOWN`).
pub fn default_uom_map() -> BTreeMap<String, String> {
[
("second", "Second"),
("minute", "Minute"),
("hour", "Hour"),
("session", "Nos"),
("unit", "Nos"),
]
crate::units::KNOWN
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.map(|(id, uom)| (id.to_string(), uom.to_string()))
.collect()
}
+1
View File
@@ -5,6 +5,7 @@ mod integrations;
mod logo;
mod models;
mod selftest;
mod units;
use rusqlite::Connection;
use std::path::{Path, PathBuf};
+129
View File
@@ -0,0 +1,129 @@
//! 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}");
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Voiced",
"version": "1.0.2",
"version": "1.1.0",
"identifier": "com.bongbetic.voiced",
"build": {
"beforeDevCommand": "npm run dev",
+3 -2
View File
@@ -35,6 +35,7 @@ import {
type FormProblems,
} from "../lib/erpnextUi";
import { useToast } from "./ToastProvider";
import { unitLabel } from "../lib/units";
const SEVERITY_TITLE = { error: "Needs fixing", warning: "Warning", info: "Note" } as const;
@@ -496,12 +497,12 @@ export default function ErpnextSettingsPanel({ active }: { active: boolean }) {
/>
<fieldset className="voiced-erp__uoms">
<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) => (
<PickField
key={unit}
id={`erp-uom-${unit}`}
label={`Voiced ${unit}`}
label={`Voiced ${unitLabel(unit).toLowerCase()}`}
value={form.uomMap[unit] ?? ""}
onChange={(v) => setForm((f) => (f ? { ...f, uomMap: { ...f.uomMap, [unit]: v } } : f))}
items={opts.uoms}
+51
View File
@@ -0,0 +1,51 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
/** What to show instead of the children. Without it, a full-window notice with a Reload button. */
fallback?: (error: Error, reset: () => void) => ReactNode;
}
interface State {
error: Error | null;
}
/**
* React unmounts the whole tree when a component throws and nothing catches it, which leaves a blank window that
* looks like a hang. This keeps the failure to the part that threw (or, at the top, shows what happened and a way back).
* Plain elements on purpose: the component library may be what threw.
*/
export default class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Voiced UI error:", error, info.componentStack);
}
reset = () => this.setState({ error: null });
render() {
const { error } = this.state;
if (!error) return this.props.children;
if (this.props.fallback) return this.props.fallback(error, this.reset);
return (
<div
role="alert"
style={{ padding: "2rem", maxWidth: "40rem", margin: "0 auto", color: "var(--cds-text-primary, #161616)", background: "var(--cds-background, #f4f4f4)", minHeight: "100vh" }}
>
<h1 style={{ fontSize: "1.5rem", marginBottom: "0.75rem" }}>Something went wrong</h1>
<p style={{ marginBottom: "1rem" }}>
Voiced hit an unexpected error. Your invoices and drafts are saved. Reload to carry on.
</p>
<pre style={{ whiteSpace: "pre-wrap", fontSize: "0.8125rem", marginBottom: "1.5rem" }}>{error.message}</pre>
<button type="button" className="cds--btn cds--btn--primary" onClick={() => window.location.reload()}>
Reload Voiced
</button>
</div>
);
}
}
+15 -10
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Button, InlineLoading, InlineNotification } from "@carbon/react";
import { ActionableNotification, Button, InlineLoading, InlineNotification } from "@carbon/react";
import { api } from "../lib/api";
import type { InvoiceExport } from "../hooks/useInvoiceExport";
import FlattenProgressModal from "./FlattenProgressModal";
@@ -37,13 +37,18 @@ export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
{busy && !modalOpen ? <InlineLoading description={progress?.phase === "saving" ? "Waiting for the save dialog…" : "Preparing the PDF…"} /> : null}
{error && !flattenFailed ? (
<InlineNotification kind="error" lowContrast title="The PDF was not exported" subtitle={error.message} onCloseButtonClick={exp.dismiss}>
<div className="voiced-notice-actions">
<Button kind="ghost" size="sm" onClick={exp.retry}>
Retry
</Button>
</div>
</InlineNotification>
// Carbon throws if a notification has buttons inside it, and an uncaught throw blanks the whole window.
// A single action goes through ActionableNotification's own button instead.
<ActionableNotification
inline
kind="error"
lowContrast
title="The PDF was not exported"
subtitle={error.message}
actionButtonLabel="Retry"
onActionButtonClick={exp.retry}
onCloseButtonClick={exp.dismiss}
/>
) : null}
{result ? (
@@ -54,7 +59,8 @@ export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
title={`Saved to ${result.path}`}
subtitle={result.identicalToIssued === true ? "The layout is identical to the issued invoice." : undefined}
onCloseButtonClick={exp.dismiss}
>
/>
{/* Beside the notification, not inside it: Carbon throws on interactive children. */}
<div className="voiced-notice-actions">
<Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}>
Open file
@@ -63,7 +69,6 @@ export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
Show in folder
</Button>
</div>
</InlineNotification>
{result.identicalToIssued === false ? (
<InlineNotification
kind="warning"
+9 -13
View File
@@ -18,7 +18,9 @@ import {
} from "@carbon/react";
import { Add, Edit, TrashCan } from "@carbon/icons-react";
import { api } from "../lib/api";
import { UNIT_LABELS, blankPreset, type ItemPreset, type LineMode, type LineUnit } from "../lib/types";
import { blankPreset, type ItemPreset, type LineMode } from "../lib/types";
import { isValidUnit, ratePerText } from "../lib/units";
import UnitPicker from "./UnitPicker";
import { paiseToRupees, rupeesToPaise } from "../lib/presets";
import { formatAmount } from "../lib/format";
import { useToast } from "./ToastProvider";
@@ -61,9 +63,12 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
if (!editing) return;
setTouched(true);
if (!editing.description.trim()) return;
if (editing.mode === "rate" && !isValidUnit(editing.unit)) return;
setBusy(true);
try {
await api.saveItemPreset({ ...editing, description: editing.description.trim() });
// A fixed-amount preset has no unit of its own; whatever the picker last held must not block the save.
const unit = isValidUnit(editing.unit) ? editing.unit : "unit";
await api.saveItemPreset({ ...editing, unit, description: editing.description.trim() });
toast.success(editing.id === null ? "Preset added" : "Preset updated", editing.description.trim());
setEditing(null);
await load();
@@ -123,7 +128,7 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
<TableRow key={p.id ?? p.description}>
<TableCell>{p.description}</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>
<div className="voiced-header-action">
@@ -199,16 +204,7 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
</Column>
{editing.mode === "rate" ? (
<Column sm={4} md={4} lg={8}>
<Select
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>
<UnitPicker id="ip-unit" value={editing.unit} onChange={(unit) => set("unit", unit)} />
</Column>
) : null}
<Column sm={4} md={4} lg={8}>
+77
View File
@@ -0,0 +1,77 @@
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);
useEffect(() => {
validity.current = onValidity;
});
// Reported while this box exists; a box that goes away (its line was removed) stops counting as invalid.
useEffect(() => {
const report = validity.current;
report?.(parsed.ok);
return () => report?.(true);
}, [parsed.ok]);
const helper = parsed.ok && text.includes(":") ? `= ${formatQuantity(parsed.value, "unit")} ${knownUnit(unit)?.short ?? ""}`.trim() : undefined;
return (
<TextInput
id={id}
disabled={disabled}
labelText="Quantity"
inputMode="decimal"
autoComplete="off"
placeholder={clock === "hours" ? "e.g. 4:30 or 4.5" : undefined}
value={text}
invalid={!parsed.ok}
invalidText={parsed.ok ? "" : parsed.error}
helperText={helper}
onChange={(e) => {
const next = e.target.value;
setText(next);
const r = parseQuantity(next, unit);
if (r.ok) {
reported.current = { value: r.value, unit };
onChange(r.value);
}
}}
/>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useRef, useState } from "react";
import { Select, SelectItem, SelectItemGroup, TextInput } from "@carbon/react";
import { MAX_CUSTOM_UNIT_LENGTH, UNITS, UNIT_GROUPS, isKnownUnit, validateCustomUnit } from "../lib/units";
const OTHER = "__other__";
export interface UnitPickerProps {
id: string;
value: string;
onChange: (unit: string) => void;
disabled?: boolean;
labelText?: string;
}
/** The unit of a rate line: a grouped list of built-in units plus "Other…", which takes free text. */
export default function UnitPicker({ id, value, onChange, disabled, labelText = "Unit" }: UnitPickerProps) {
// "Other…" can be chosen before any text is typed, so the mode is kept apart from the stored value.
const [other, setOther] = useState(() => !isKnownUnit(value));
const custom = other || !isKnownUnit(value);
const problem = custom ? validateCustomUnit(value) : null;
// A unit that arrives from outside (another preset or draft in the same row) decides the mode again; one this picker
// just reported does not, so typing "m" on the way to "minute" does not flip the box back to the list.
const reported = useRef(value);
useEffect(() => {
if (value === reported.current) return;
reported.current = value;
setOther(!isKnownUnit(value));
}, [value]);
const emit = (unit: string) => {
reported.current = unit;
onChange(unit);
};
return (
<>
<Select
id={id}
disabled={disabled}
labelText={labelText}
value={custom ? OTHER : value}
onChange={(e) => {
if (e.target.value === OTHER) {
setOther(true);
emit("");
} else {
setOther(false);
emit(e.target.value);
}
}}
>
{UNIT_GROUPS.map((group) => (
<SelectItemGroup key={group} label={group}>
{UNITS.filter((u) => u.group === group).map((u) => (
<SelectItem key={u.id} value={u.id} text={u.label} />
))}
</SelectItemGroup>
))}
<SelectItem value={OTHER} text="Other…" />
</Select>
{custom ? (
<TextInput
id={`${id}-custom`}
disabled={disabled}
labelText="Custom unit"
hideLabel
placeholder="e.g. bundle"
maxLength={MAX_CUSTOM_UNIT_LENGTH}
value={value}
invalid={Boolean(problem)}
invalidText={problem ?? ""}
onChange={(e) => emit(e.target.value)}
/>
) : null}
</>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
/**
* Carbon's notifications throw "component should have no interactive child nodes" when a button or link sits
* inside them, and a throw in an effect unmounts the whole app: the window goes blank (this made every successful
* export look like a freeze). There is no DOM test setup here, so this reads the sources instead. Put the
* buttons beside the notification, or use ActionableNotification's own action.
*/
const SRC = path.resolve(process.cwd(), "src");
const NOTIFICATIONS = ["InlineNotification", "ActionableNotification", "ToastNotification", "Callout"];
const INTERACTIVE = /<(Button|IconButton|button|a|Link|TextInput|Select|Checkbox|Toggle|OverflowMenu|ComboBox|Dropdown)\b/;
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const full = path.join(dir, name);
if (statSync(full).isDirectory()) return name === "pdf" ? [] : sourceFiles(full);
return /\.tsx$/.test(name) && !/\.test\.tsx$/.test(name) ? [full] : [];
});
}
/** Bodies of the notifications that have children, with the line they start on. */
function notificationBodies(source: string): Array<{ tag: string; line: number; body: string }> {
const out: Array<{ tag: string; line: number; body: string }> = [];
for (const tag of NOTIFICATIONS) {
const open = new RegExp(`<${tag}\\b`, "g");
for (let m = open.exec(source); m; m = open.exec(source)) {
let i = m.index + m[0].length;
let braces = 0;
for (; i < source.length; i++) {
const c = source[i];
if (c === "{") braces++;
else if (c === "}") braces--;
else if (c === ">" && braces === 0) break;
}
if (source[i - 1] === "/") continue; // self-closing: no children
const end = source.indexOf(`</${tag}>`, i);
out.push({ tag, line: source.slice(0, m.index).split("\n").length, body: source.slice(i + 1, end < 0 ? undefined : end) });
}
}
return out;
}
describe("Carbon notifications", () => {
it("never wrap an interactive element", () => {
const offenders: string[] = [];
for (const file of sourceFiles(SRC)) {
for (const { tag, line, body } of notificationBodies(readFileSync(file, "utf8"))) {
if (INTERACTIVE.test(body)) offenders.push(`${path.relative(SRC, file)}:${line} <${tag}>`);
}
}
expect(offenders).toEqual([]);
});
it("the scan finds an offender when there is one", () => {
const bad = `<InlineNotification kind="success" title="x">\n <Button>Open</Button>\n</InlineNotification>`;
const fine = `<InlineNotification kind="success" title="x" />\n<Button>Open</Button>`;
expect(notificationBodies(bad).some((n) => INTERACTIVE.test(n.body))).toBe(true);
expect(notificationBodies(fine)).toEqual([]);
});
});
+1 -1
View File
@@ -23,7 +23,7 @@ export interface ErpnextConfig {
paymentBankAccount: string;
tdsAccount: string;
defaultItemCode: string;
/** Voiced unit (second, minute, hour, session, unit) to ERPNext UOM. */
/** Voiced unit id (see lib/units.ts) to ERPNext UOM. */
uomMap: Record<string, string>;
customerGroup: string;
territory: string;
+1 -1
View File
@@ -232,7 +232,7 @@ describe("settings form", () => {
expect(input).not.toHaveProperty("apiSecretSet");
expect(input).not.toHaveProperty("lastDetectResult");
expect(input.apiSecret).toBe("");
expect(input.uomMap).toEqual({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos" });
expect(input.uomMap).toMatchObject({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos", km: "Kilometer", sqft: "Square Foot" });
expect(withDefaultUoms({ hour: " " }).hour).toBe("Hour");
});
+3 -8
View File
@@ -1,4 +1,5 @@
/** Pure helpers behind the ERPNext screens (Settings, History, invoice detail, auto-push). No UI, no Tauri. */
import { UNITS } from "./units";
import type {
ErpnextConfig,
ErpnextConfigInput,
@@ -259,14 +260,8 @@ export function versionsLine(test: ErpnextConnectionTest): string {
// ---- settings form ----
export const UOM_UNITS = ["second", "minute", "hour", "session", "unit"] as const;
export const DEFAULT_UOM_MAP: Record<string, string> = {
second: "Second",
minute: "Minute",
hour: "Hour",
session: "Nos",
unit: "Nos",
};
export const UOM_UNITS: readonly string[] = UNITS.map((u) => u.id);
export const DEFAULT_UOM_MAP: Record<string, string> = Object.fromEntries(UNITS.map((u) => [u.id, u.uom]));
export function withDefaultUoms(map: Record<string, string> | undefined): Record<string, string> {
const out: Record<string, string> = { ...DEFAULT_UOM_MAP };
+14
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { computeLineAmount, computeTotals } from "./invoice";
import { EMPTY_ITEM, type InvoiceItem } from "./types";
import { parseQuantity } from "./units";
// These cases mirror the unit tests in src-tauri/src/gst.rs, so the on-screen preview
// and the stored totals stay identical.
@@ -40,6 +41,19 @@ describe("computeTotals", () => {
expect(computeLineAmount(rate(99.99, 2.5))).toBe(249.98);
});
it("counts a typed time as its decimal and rounds once, like Rust (units test: lines_can_use_any_unit...)", () => {
const q = (text: string, unit: string) => {
const r = parseQuantity(text, unit);
if (!r.ok) throw new Error(r.error);
return r.value;
};
expect(computeLineAmount(rate(1200, q("4:30", "hour")))).toBe(5400);
// 4 h 20 min is 4.333...; 1200 x that is 5200.00 only if the exact value is kept, not 4.33.
expect(computeLineAmount(rate(1200, q("4:20", "hour")))).toBe(5200);
expect(computeLineAmount({ ...rate(18, q("12.75", "km")), unit: "km" })).toBe(229.5);
expect(computeLineAmount(rate(99.5, 3))).toBe(298.5);
});
it("clamps the discount to the subtotal", () => {
const items = [fixed(100), fixed(50.5)];
const over = computeTotals(items, 999.99, "igst", 18);
+13
View File
@@ -124,6 +124,8 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
return { ok: r?.path === searchablePath, detail: `wrote ${r?.path}` };
});
await log.run("e2e: export notices render without crashing the window", async () => (await import("./selfTestUi")).checkExportFeedbackRenders());
await log.run("e2e: export flattened original", async () => {
const r = await exportWith("flattened", "original", flattenedPath);
return { ok: r?.path === flattenedPath, detail: `wrote ${r?.path}` };
@@ -157,6 +159,17 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
};
});
await log.run("e2e: a new invoice can be issued and exported after the first", async () => {
const first = inv.number;
const outcome = await issueAndArchive({ ...input, notes: "Self-test invoice 2" }, prefs, deps);
const next = `${outDir}/e2e-second.pdf`;
const r = await exportInvoice({ invoice: outcome.invoice, mode: "searchable", source: "original" }, { ...deps, save: async () => next });
return {
ok: outcome.archived && outcome.invoice.number !== first && r?.path === next,
detail: `${first}, then ${outcome.invoice.number} (archived=${outcome.archived}), exported ${r?.path}`,
};
});
await log.run("e2e: archiving different bytes is refused", async () => {
const other = new TextEncoder().encode("%PDF-1.4\n% a different file\n");
const before = await api.readArchive(inv.id);
+59
View File
@@ -0,0 +1,59 @@
import { createRoot } from "react-dom/client";
import { Theme } from "@carbon/react";
import ExportFeedback from "../components/ExportFeedback";
import type { InvoiceExport } from "../hooks/useInvoiceExport";
import type { Invoice } from "./types";
/**
* Part of the real-webview self-test: mounts the export notices the way the app does and fails if React reports an
* uncaught error. Carbon throws when a notification has a button inside it, and that took the whole window
* down after every successful export; nothing that runs without a real DOM can see it.
*/
const noop = () => {};
const idle: InvoiceExport = {
busy: false,
progress: null,
request: null,
error: null,
result: null,
start: async () => null,
cancel: noop,
retry: noop,
exportSearchableInstead: noop,
dismiss: noop,
};
const CASES: Array<{ name: string; expect: string; exp: InvoiceExport }> = [
{
name: "saved",
expect: "Saved to",
exp: { ...idle, result: { path: "/tmp/voiced-selftest.pdf", mode: "searchable", invoice: {} as Invoice, identicalToIssued: false, archiveError: "not archived" } },
},
{ name: "failed", expect: "was not exported", exp: { ...idle, error: { message: "disk full", flatten: false } } },
];
export async function checkExportFeedbackRenders(): Promise<{ ok: boolean; detail: string }> {
const problems: string[] = [];
for (const c of CASES) {
const host = document.createElement("div");
document.body.appendChild(host);
const errors: string[] = [];
const root = createRoot(host, {
onUncaughtError: (e) => errors.push(e instanceof Error ? e.message : String(e)),
onRecoverableError: (e) => errors.push(e instanceof Error ? e.message : String(e)),
});
root.render(
<Theme theme="g10">
<ExportFeedback exp={c.exp} />
</Theme>,
);
// Effects (where Carbon checks its children) run after the first paint.
await new Promise((r) => setTimeout(r, 400));
if (errors.length > 0) problems.push(`${c.name}: ${errors.join("; ").slice(0, 300)}`);
else if (!(host.textContent ?? "").includes(c.expect)) problems.push(`${c.name}: the notice did not render`);
root.unmount();
host.remove();
}
return { ok: problems.length === 0, detail: problems.join(" | ") || "the saved and failed notices render without throwing" };
}
+2 -8
View File
@@ -1,6 +1,7 @@
export type TaxType = "none" | "cgst_sgst" | "igst";
export type LineMode = "fixed" | "rate";
export type LineUnit = "second" | "minute" | "hour" | "session" | "unit";
/** A built-in unit id (see lib/units.ts) or, for a custom unit, the text the vendor typed. */
export type LineUnit = string;
export interface Settings {
vendorName: string;
@@ -302,10 +303,3 @@ export const INDIAN_STATES: { code: string; name: string }[] = [
{ code: "97", name: "Other Territory" },
];
export const UNIT_LABELS: Record<LineUnit, string> = {
second: "per second",
minute: "per minute",
hour: "per hour",
session: "per session",
unit: "per unit",
};
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import {
UNITS,
formatQuantity,
normalizeUnit,
parseQuantity,
quantityText,
ratePerText,
unitShort,
validateCustomUnit,
} from "./units";
const value = (text: string, unit: string) => {
const r = parseQuantity(text, unit);
if (!r.ok) throw new Error(r.error);
return r.value;
};
describe("parseQuantity", () => {
it("reads h:mm as hours for hour units", () => {
expect(value("4:30", "hour")).toBe(4.5);
expect(value("0:15", "hour")).toBe(0.25);
expect(value("4:20", "hour")).toBeCloseTo(4 + 1 / 3, 12);
expect(value("1:00:30", "hour")).toBeCloseTo(1 + 30 / 3600, 12);
});
it("reads m:ss as minutes for minute units", () => {
expect(value("4:30", "minute")).toBe(4.5);
expect(parseQuantity("1:00:30", "minute").ok).toBe(false);
});
it("refuses a clock quantity for other units and out-of-range parts", () => {
expect(parseQuantity("4:30", "km").ok).toBe(false);
expect(parseQuantity("4:75", "hour").ok).toBe(false);
expect(parseQuantity("4:", "hour").ok).toBe(false);
});
it("reads plain decimals for every unit", () => {
expect(value("12.75", "km")).toBe(12.75);
expect(value("120.5", "sqft")).toBe(120.5);
expect(value("3", "unit")).toBe(3);
expect(value(".5", "hour")).toBe(0.5);
expect(value("2.", "kg")).toBe(2);
expect(value("", "hour")).toBe(0);
});
it("rejects text, negatives, more than 3 decimals and huge values", () => {
for (const bad of ["abc", "-1", "1,5", "1.2345", ".", "1e3", "99999999"]) {
expect(parseQuantity(bad, "hour").ok, bad).toBe(false);
}
});
});
describe("formatQuantity", () => {
it("trims zeros", () => {
expect(formatQuantity(2, "unit")).toBe("2");
expect(formatQuantity(1.5, "km")).toBe("1.5");
expect(formatQuantity(12.75, "km")).toBe("12.75");
expect(formatQuantity(0.125, "kg")).toBe("0.125");
});
it("writes a repeating time as h:mm", () => {
expect(formatQuantity(value("4:20", "hour"), "hour")).toBe("4:20");
expect(formatQuantity(value("0:20", "hour"), "hour")).toBe("0:20");
expect(formatQuantity(value("1:00:30", "hour"), "hour")).toBe("1:00:30");
expect(formatQuantity(value("4:30", "hour"), "hour")).toBe("4.5");
});
it("round-trips whatever parseQuantity accepts", () => {
for (const [text, unit] of [["4:20", "hour"], ["7:45", "hour"], ["12.75", "km"], ["0:05", "hour"], ["3:10", "minute"]] as const) {
const v = value(text, unit);
expect(value(formatQuantity(v, unit), unit)).toBeCloseTo(v, 9);
}
});
});
describe("unit text", () => {
it("keeps the old rate wording for the original five units", () => {
expect(["second", "minute", "hour", "session", "unit"].map(ratePerText)).toEqual([
"per second",
"per minute",
"per hour",
"per session",
"per unit",
]);
});
it("shows the unit beside the quantity, but not for the generic unit", () => {
expect(quantityText(4.5, "hour")).toBe("4.5 hr");
expect(quantityText(12.75, "km")).toBe("12.75 km");
expect(quantityText(3, "unit")).toBe("3");
expect(quantityText(2, "bundle")).toBe("2 bundle");
expect(unitShort("sqft")).toBe("sq ft");
expect(ratePerText("bundle")).toBe("per bundle");
});
it("validates custom units", () => {
expect(validateCustomUnit("bundle")).toBeNull();
expect(validateCustomUnit("sq.ft/day")).toBeNull();
expect(validateCustomUnit("")).not.toBeNull();
expect(validateCustomUnit("a".repeat(13))).not.toBeNull();
expect(validateCustomUnit("₹")).not.toBeNull();
});
it("normalises stored values", () => {
expect(normalizeUnit("HOUR")).toBe("hour");
expect(normalizeUnit(" bundle ")).toBe("bundle");
expect(normalizeUnit("")).toBe("unit");
expect(normalizeUnit(undefined)).toBe("unit");
});
it("has unique ids and a short for every unit except the generic one", () => {
expect(new Set(UNITS.map((u) => u.id)).size).toBe(UNITS.length);
for (const u of UNITS) if (u.id !== "unit") expect(u.short).not.toBe("");
});
});
+162
View File
@@ -0,0 +1,162 @@
/**
* Units of measure for rate × quantity lines. The ids of the first five are stored in saved invoices
* and must never change. Any other text is a custom unit and is stored as typed (see `validateCustomUnit`).
* The Rust side keeps the same rules in src-tauri/src/units.rs.
*/
export type UnitGroup = "Count" | "Time" | "Distance" | "Area" | "Weight" | "Volume";
/** How `h:mm` style quantities are read for a unit: "4:30" is 4 h 30 min for hours, 4 min 30 s for minutes. */
export type ClockKind = "hours" | "minutes";
export interface UnitDef {
id: string;
/** Shown in pickers: "Hour". */
label: string;
/** Lower-case word used after "per": "per hour". */
noun: string;
/** Short form beside a quantity: "4.5 hr". Empty for the generic "unit", which stays a bare number. */
short: string;
group: UnitGroup;
clock?: ClockKind;
/** ERPNext UOM used when the integration has no mapping of its own for this unit. */
uom: string;
}
export const UNITS: readonly UnitDef[] = [
{ id: "unit", label: "Unit", noun: "unit", short: "", group: "Count", uom: "Nos" },
{ id: "piece", label: "Piece", noun: "piece", short: "pc", group: "Count", uom: "Nos" },
{ id: "set", label: "Set", noun: "set", short: "set", group: "Count", uom: "Set" },
{ id: "session", label: "Session", noun: "session", short: "session", group: "Time", uom: "Nos" },
{ id: "second", label: "Second", noun: "second", short: "sec", group: "Time", uom: "Second" },
{ id: "minute", label: "Minute", noun: "minute", short: "min", group: "Time", clock: "minutes", uom: "Minute" },
{ id: "hour", label: "Hour", noun: "hour", short: "hr", group: "Time", clock: "hours", uom: "Hour" },
{ id: "day", label: "Day", noun: "day", short: "day", group: "Time", uom: "Day" },
{ id: "week", label: "Week", noun: "week", short: "wk", group: "Time", uom: "Week" },
{ id: "month", label: "Month", noun: "month", short: "mo", group: "Time", uom: "Month" },
{ id: "km", label: "Kilometre", noun: "km", short: "km", group: "Distance", uom: "Kilometer" },
{ id: "m", label: "Metre", noun: "m", short: "m", group: "Distance", uom: "Meter" },
{ id: "sqft", label: "Square foot", noun: "sq ft", short: "sq ft", group: "Area", uom: "Square Foot" },
{ id: "sqm", label: "Square metre", noun: "sq m", short: "sq m", group: "Area", uom: "Square Meter" },
{ id: "kg", label: "Kilogram", noun: "kg", short: "kg", group: "Weight", uom: "Kg" },
{ id: "litre", label: "Litre", noun: "litre", short: "L", group: "Volume", uom: "Litre" },
];
export const UNIT_GROUPS: readonly UnitGroup[] = ["Count", "Time", "Distance", "Area", "Weight", "Volume"];
const BY_ID = new Map(UNITS.map((u) => [u.id, u]));
export const DEFAULT_UNIT = "unit";
export const MAX_CUSTOM_UNIT_LENGTH = 12;
/** The built-in unit with this id, or undefined for a custom unit. */
export const knownUnit = (unit: string): UnitDef | undefined => BY_ID.get(unit.trim().toLowerCase());
export const isKnownUnit = (unit: string): boolean => BY_ID.has(unit.trim().toLowerCase());
/** Problem with a custom unit text, or null when it is fine. */
export function validateCustomUnit(text: string): string | null {
const t = text.trim();
if (!t) return "Enter a unit";
if (t.length > MAX_CUSTOM_UNIT_LENGTH) return `Use at most ${MAX_CUSTOM_UNIT_LENGTH} characters`;
if (!/^[A-Za-z0-9][A-Za-z0-9 ./-]*$/.test(t)) return "Use letters, digits, spaces, . / or -";
return null;
}
/** Whether a stored unit can be issued: a built-in id or a valid custom text. */
export const isValidUnit = (unit: string): boolean => isKnownUnit(unit) || validateCustomUnit(unit) === null;
/** A unit value as stored: the id for a built-in unit, the trimmed text otherwise. Falls back to the default. */
export function normalizeUnit(unit: string | null | undefined): string {
const t = (unit ?? "").trim();
if (!t) return DEFAULT_UNIT;
const lower = t.toLowerCase();
if (BY_ID.has(lower)) return lower;
return t;
}
export const unitLabel = (unit: string): string => knownUnit(unit)?.label ?? unit.trim();
/** "hr" for hours; the text itself for a custom unit; "" for the generic unit. */
export const unitShort = (unit: string): string => {
const def = knownUnit(unit);
return def ? def.short : unit.trim();
};
/** "per hour", "per sq ft", "per bundle". */
export const ratePerText = (unit: string): string => `per ${knownUnit(unit)?.noun ?? unit.trim()}`;
/** The ERPNext UOM that suits a built-in unit; undefined for a custom unit. */
export const defaultUom = (unit: string): string | undefined => knownUnit(unit)?.uom;
export type QuantityParse = { ok: true; value: number } | { ok: false; error: string };
const MAX_QUANTITY = 10_000_000;
/**
* Reads what the user typed into a quantity box. Plain decimals (up to 3 places) work for every unit.
* Hour units also take `h:mm` and `h:mm:ss`, minute units take `m:ss`, so "4:30" hours is 4.5.
* An empty box is 0, the same as before this existed.
*/
export function parseQuantity(text: string, unit: string): QuantityParse {
const t = text.trim();
if (!t) return { ok: true, value: 0 };
const clock = knownUnit(unit)?.clock;
if (t.includes(":")) {
if (!clock) return { ok: false, error: "Only time units take h:mm" };
const m = /^(\d{1,7}):(\d{1,2})(?::(\d{1,2}))?$/.exec(t);
if (!m) return { ok: false, error: clock === "hours" ? "Use h:mm, for example 4:30" : "Use m:ss, for example 4:30" };
const first = Number(m[1]);
const second = Number(m[2]);
const third = m[3] === undefined ? null : Number(m[3]);
if (second > 59 || (third !== null && third > 59)) return { ok: false, error: "Minutes and seconds go up to 59" };
if (clock === "minutes" && third !== null) return { ok: false, error: "Use m:ss, for example 4:30" };
const value = clock === "hours" ? first + second / 60 + (third ?? 0) / 3600 : first + second / 60;
return value > MAX_QUANTITY ? { ok: false, error: "That quantity is too large" } : { ok: true, value };
}
const m = /^(\d{0,8})(?:\.(\d{0,3}))?$/.exec(t);
if (!m || (m[1] === "" && !m[2])) {
return { ok: false, error: /^\d*\.\d{4,}$/.test(t) ? "Use at most 3 decimal places" : "Enter a number" };
}
const value = Number(t);
return value > MAX_QUANTITY ? { ok: false, error: "That quantity is too large" } : { ok: true, value };
}
const EPS = 1e-9;
function trimmed(value: number, places: number): string {
return value.toFixed(places).replace(/\.?0+$/, "");
}
/**
* A quantity as text, without the unit. Exact for anything `parseQuantity` accepts: a time that is not a
* whole number of hundredths (4 h 20 min) is written as h:mm so that quantity × rate can be checked by eye.
*/
export function formatQuantity(value: number, unit: string): string {
if (!Number.isFinite(value)) return "0";
const clock = knownUnit(unit)?.clock;
if (!clock) return trimmed(value, 3);
if (Math.abs(value * 100 - Math.round(value * 100)) < EPS * 100) return trimmed(value, 2);
const whole = Math.floor(value + EPS);
const rest = (value - whole) * 60;
const min = Math.floor(rest + EPS);
const sec = Math.round((rest - min) * 60);
const head = clock === "hours" ? `${whole}:${String(min).padStart(2, "0")}` : null;
if (clock === "hours" && sec < 60 && Math.abs(whole + min / 60 + sec / 3600 - value) < EPS) {
return sec === 0 ? head! : `${head}:${String(sec).padStart(2, "0")}`;
}
if (clock === "minutes") {
const s = Math.round((value - whole) * 60);
if (s < 60 && Math.abs(whole + s / 60 - value) < EPS) return `${whole}:${String(s).padStart(2, "0")}`;
}
return trimmed(value, 4);
}
/** The quantity as printed on an invoice: "4:20 hr", "12.75 km", or a bare "3" for the generic unit. */
export function quantityText(value: number, unit: string): string {
const q = formatQuantity(value, unit);
const short = unitShort(unit);
return short ? `${q} ${short}` : q;
}
+3
View File
@@ -1,12 +1,15 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary";
import "./styles/carbon.scss";
import { maybeRunSelfTest } from "./selftestBoot";
createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
);
+17
View File
@@ -181,6 +181,23 @@ export function getMeasurer(): Measurer {
/** The text as it will be drawn: caps applied. */
export const drawnText = (token: TypeToken, text: string) => (token.caps ? text.toUpperCase() : text);
/** A quantity with its unit ("1250.5 sq ft") is allowed this much width before the cell wraps at its spaces. */
export const QTY_WRAP_CAP = 72;
/**
* The texts a quantity column is sized from. A quantity cell can wrap between its words ("120 / wooden crate")
* but never inside one, so a text wider than `cap` counts as its widest word; the column then stays modest
* and the description keeps its room, instead of one long custom unit squeezing the table.
*/
export function wrapAwareTexts(texts: string[], role: RoleName | TypeToken, cap = QTY_WRAP_CAP): string[] {
const m = getMeasurer();
return texts.map((text) => {
if (measureToken(m, role, text) <= cap) return text;
const words = text.split(/\s+/).filter(Boolean);
return words.reduce((widest, w) => (measureToken(m, role, w) > measureToken(m, role, widest) ? w : widest), words[0] ?? text);
});
}
/** Width of `text` set in a role/token at an optional size override, tracking and caps included. */
export function measureToken(
m: Measurer,
+16 -1
View File
@@ -133,7 +133,10 @@ describe("unregistered invoice", () => {
descriptionLines: ["Voice-over", "session A"],
hsnSac: "998397",
rateText: "₹1,200.00 per hour",
qtyText: "2.5",
priceText: "₹1,200.00",
perText: "per hour",
rateShort: "₹1,200.00/hr",
qtyText: "2.5 hr",
amountText: "₹3,000.00",
});
expect(m.items[1]).toEqual({
@@ -141,10 +144,22 @@ describe("unregistered invoice", () => {
descriptionLines: ["—"],
hsnSac: null,
rateText: null,
priceText: null,
perText: null,
rateShort: null,
qtyText: "1",
amountText: "₹2,500.50",
});
});
it("words the unit and the quantity for every kind of unit", () => {
const line = (unit: string, quantity: number, rate = 18.5) =>
make({ items: [{ ...base.items[0], mode: "rate", unit, quantity, rate }] }).items[0];
expect(line("km", 12.75)).toMatchObject({ qtyText: "12.75 km", rateText: "₹18.50 per km", rateShort: "₹18.50/km", perText: "per km" });
expect(line("sqft", 1250.5)).toMatchObject({ qtyText: "1250.5 sq ft", rateShort: "₹18.50/sq ft" });
expect(line("hour", 4 + 1 / 3)).toMatchObject({ qtyText: "4:20 hr" });
expect(line("unit", 3)).toMatchObject({ qtyText: "3", rateText: "₹18.50 per unit", rateShort: "₹18.50/unit" });
expect(line(" bundle ", 2)).toMatchObject({ qtyText: "2 bundle", rateText: "₹18.50 per bundle", rateShort: "₹18.50/bundle" });
});
it("labels the bank lines and defaults the signature to the vendor name", () => {
expect(m.bank).toEqual({
accountName: "Test Vendor",
+20 -3
View File
@@ -1,7 +1,7 @@
import { computeLineAmount } from "../../lib/invoice";
import { quantityText, ratePerText, unitShort, knownUnit } from "../../lib/units";
import {
INDIAN_STATES,
UNIT_LABELS,
type BankAccount,
type Invoice,
type Settings,
@@ -48,7 +48,15 @@ export interface RenderItem {
sno: number;
descriptionLines: string[];
hsnSac: string | null;
/** "₹1,200.00 per hour"; null for a fixed-amount line. */
rateText: string | null;
/** The amount alone: "₹1,200.00". */
priceText: string | null;
/** The unit wording alone: "per hour". */
perText: string | null;
/** The one-line form: "₹1,200.00/hr". */
rateShort: string | null;
/** "4.5 hr", "12.75 km", or a bare "3" for the generic unit; "1" for a fixed-amount line. */
qtyText: string;
amountText: string;
}
@@ -140,6 +148,15 @@ function taxRowsFor(invoice: Invoice, supplierStateCode: string): TaxRow[] {
}
/** Pure: turns the stored invoice into pre-formatted, sanitised data. Totals are the stored values. */
function rateFields(item: Invoice["items"][number]): Pick<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 {
const { invoice, logo, signatureDataUri, appVersion } = input;
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, input.vendor);
@@ -164,8 +181,8 @@ export function buildRenderModel(input: BuildInput): RenderModel {
return lines.length ? lines : ["—"];
})(),
hsnSac: orNull(item.hsnSac),
rateText: item.mode === "rate" ? `${formatRupees(item.rate)} ${UNIT_LABELS[item.unit]}` : null,
qtyText: item.mode === "rate" ? String(item.quantity) : "1",
...rateFields(item),
qtyText: item.mode === "rate" ? quantityText(item.quantity, item.unit) : "1",
amountText: formatRupees(computeLineAmount(item)),
}));
+5 -5
View File
@@ -1,6 +1,6 @@
import { type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight } from "../../engine/textplan";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
@@ -148,17 +148,17 @@ export function planBand(model: RenderModel, frame: PageFrame, v: BandVariant, l
// --- table: description | qty | price | total ------------------------------------------------------------
const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
const colW = (base: number, texts: string[], headText: string) => Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, [headText]))) + 4 + 1);
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
const cw = {
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "QTY"),
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "PRICE"),
qty: colW(v.cols.qty, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "QTY"),
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "PRICE"),
amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "TOTAL"),
};
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }];
const headerCells = ["SERVICE", "QTY", "PRICE", "TOTAL"];
const align: BandPlan["align"] = ["left", "center", "right", "right"];
const rows: TableCellValue[][] = model.items.map((item, i) => {
const rate = rateCell(item.rateText, cw.rate - 4, r.tableBody);
const rate = rateCell(item, cw.rate - 4, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
return [desc, item.qtyText, rateParts, item.amountText];
+15 -2
View File
@@ -1,6 +1,6 @@
import { cols, type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer } from "../../engine/measure";
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight } from "../../engine/textplan";
import { roleToken } from "../../fonts/roles";
import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
@@ -82,6 +82,9 @@ export interface ClassicPlan {
closingH: number;
}
const QTY_MIN = 40;
const QTY_MAX = 84;
const leading = (role: Parameters<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 {
const gaps = elasticGaps(frame);
const hsn = Slot.HsnSac(model);
// The quantity column grows to hold "12.75 km" on one line, but the description keeps its 160 pt: a text wider than
// that room wraps at its spaces.
const fixedBesideQty = 28 + (hsn ? 62 : 0) + 92 + 88 + TABLE_GAP * (hsn ? 5 : 4);
const qtyRoom = Math.max(QTY_MIN, Math.min(QTY_MAX, Math.floor(frame.contentWidth - fixedBesideQty - 160)));
const qtyNeed = Math.ceil(
Math.max(
measureToken(getMeasurer(), "tableHead", "Qty"),
...wrapAwareTexts(model.items.map((i) => i.qtyText), "tableBody", qtyRoom).map((t) => measureToken(getMeasurer(), "tableBody", t)),
),
);
const columns: ColSpec[] = [
{ fixed: 28 },
{ fill: true, min: 160 },
...(hsn ? [{ fixed: 62 } as ColSpec] : []),
{ fixed: 92 },
{ fixed: 40 },
{ fixed: Math.min(qtyRoom, Math.max(QTY_MIN, qtyNeed + 2)) },
{ fixed: 88 },
];
const header = ["S.No", "Description", ...(hsn ? [hsn.header] : []), "Unit price", "Qty", "Amount"];
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText";
import { innerRulePadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
@@ -19,7 +19,7 @@ const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5";
const MIN_ADDRESS_W = 120;
/** Space between a mark and the tagline under a wordmark logo. */
const WORDMARK_TAG_GAP = 3;
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
/** The totals' labels read "Sub Total :", "CGST 9% :" like the design's "Tax 15% :". */
export const labelWithColon = (label: string, rate?: string) => (rate ? `${label} ${rate} :` : `${label} :`);
@@ -65,7 +65,7 @@ export function tableRows(model: RenderModel, v: CobaltVariant, rateW: number):
const r = v.roles;
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, r.tableBody);
const rate = rateCell(item, rateW, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
return [String(i + 1), desc, item.qtyText, rateParts, item.amountText];
@@ -245,10 +245,10 @@ export function planCobalt(model: RenderModel, frame: PageFrame, v: CobaltVarian
const t = v.table;
const rule = t.separator;
const widest = (role: TypeToken, texts: string[]) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, role, x))));
const priced = model.items.map((i) => priceOf(i.rateText));
const priced = model.items.map((i) => priceOf(i));
const amountTexts = model.items.map((i) => i.amountText);
const no = Math.max(t.cols.no, widest(r.tableBody, [String(model.items.length)]) + 12);
const qty = Math.max(t.cols.qty, widest(r.tableBody, model.items.map((i) => i.qtyText)) + 12 + rule);
const qty = Math.max(t.cols.qty, widest(r.tableBody, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody)) + 12 + rule);
const priceNeed = Math.max(widest(r.tableBody, priced), widest(r.tableHead, ["Price"])) + 6 + rule + 6;
const price = Math.max(t.cols.price, priceNeed);
const priceInset = Math.max(6, Math.min(t.priceInset, price - rule - widest(r.tableBody, priced) - 4));
+13
View File
@@ -297,6 +297,19 @@ export const FIXTURES: FixtureSpec[] = [
{ description: "Audio editing and mastering of all deliverables, including noise reduction, levelling, and export to the three formats requested by the client in the brief of August", mode: "rate", rate: 400, unit: "minute", quantity: 25, amount: 0, sortOrder: 2, hsnSac: "998397" },
],
},
{
// Units of measure: a decimal distance, an area, a time that is not a whole number of hundredths (4 h 20 min),
// a 12-character custom unit and a month, so the quantity and rate columns see the longest texts they can.
name: "units",
items: [
{ description: "Travel to the client's studio", mode: "rate", rate: 18.5, unit: "km", quantity: 12.75, amount: 0, sortOrder: 0, hsnSac: "996601" },
{ description: "Acoustic treatment, floor area", mode: "rate", rate: 85, unit: "sqft", quantity: 1250.5, amount: 0, sortOrder: 1, hsnSac: "995411" },
{ description: "Evening studio session", mode: "rate", rate: 1500, unit: "hour", quantity: 4 + 1 / 3, amount: 0, sortOrder: 2, hsnSac: "998397" },
{ description: "Packed equipment cases", mode: "rate", rate: 99.5, unit: "wooden crate", quantity: 120, amount: 0, sortOrder: 3, hsnSac: "998397" },
{ description: "Retainer", mode: "rate", rate: 25000, unit: "month", quantity: 1, amount: 0, sortOrder: 4, hsnSac: "998397" },
{ description: "Setup fee", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 8000, sortOrder: 5, hsnSac: "997212" },
],
},
{
name: "long-notes",
items: 30,
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { drawnText, getMeasurer, measureToken } from "../../engine/measure";
import { drawnText, getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight, textLines } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
@@ -37,7 +37,7 @@ export function tableRows(model: RenderModel, v: HighlighterVariant, rateW: numb
const r = v.roles;
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, r.tableBody);
const rate = rateCell(item, rateW, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
return [desc, item.qtyText, rateParts, item.amountText];
@@ -158,14 +158,14 @@ export function planHighlighter(model: RenderModel, frame: PageFrame, v: Highlig
// --- table -----------------------------------------------------------------------------------------------------------------
const t = v.table;
const innerW = W - 2 * fr;
const rateOnly = model.items.map((i) => (i.rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(i.rateText)?.[1] ?? i.rateText) : "—"));
const rateOnly = model.items.map((i) => (i.priceText ?? "—"));
const need = (texts: string[], head: string, pad: number) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head))) + pad + v.hair + 6;
const shortRate = model.items.map((i) => {
const cell = rateCell(i.rateText, 10000, r.tableBody);
const cell = rateCell(i, 10000, r.tableBody);
return typeof cell === "string" ? cell : "—";
});
const cw = {
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0)),
qty: Math.max(t.cols.qty, need(wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", 0)),
rate: Math.max(t.cols.rate, need(rateOnly, "Price", t.numPad), Math.min(t.cols.rate + 24, need(shortRate, "Price", t.numPad))),
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad) - v.hair),
};
+5 -5
View File
@@ -1,6 +1,6 @@
import { colsDetailed, type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight, textLines } from "../../engine/textplan";
import { gridPadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
@@ -140,13 +140,13 @@ export function planLedger(model: RenderModel, frame: PageFrame, v: LedgerVarian
const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
const colW = (base: number, texts: string[], headText: string, idx: number) =>
Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, headText.split(" ")))) + 2 * v.padX + ruleCols(idx) + 1);
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
const nCols = hsnCol ? 5 : 4;
const idx = (base: number) => (hsnCol ? base + 1 : base);
const c0 = {
hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnCol.header, 1) : 0,
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "Unit Price", idx(1)),
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty", idx(2)),
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "Unit Price", idx(1)),
qty: colW(v.cols.qty, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", idx(2)),
amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "Amount", idx(3)),
};
const columns: ColSpec[] = [{ fill: true }, ...(hsnCol ? [{ fixed: c0.hsn } as ColSpec] : []), { fixed: c0.rate }, { fixed: c0.qty }, { fixed: c0.amount }];
@@ -154,7 +154,7 @@ export function planLedger(model: RenderModel, frame: PageFrame, v: LedgerVarian
const align: LedgerPlan["align"] = ["left", ...(hsnCol ? (["left"] as const) : []), "right", "right", "right"];
const rateInner = c0.rate - 2 * v.padX - ruleCols(idx(1));
const tableRows: TableCellValue[][] = model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateInner, r.tableBody);
const rate = rateCell(item, rateInner, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
return [item.descriptionLines, ...(hsnCol ? [hsnCol.values[i]] : []), rateParts, item.qtyText, item.amountText];
});
+1 -1
View File
@@ -282,7 +282,7 @@ describe("table", () => {
expect(r0.h).toBeCloseTo(32, 0);
expect(r1.y - r0.y).toBeCloseTo(32, 0);
expect(r0.y - (head.y + head.h)).toBeCloseTo(V.table.headGap, 0);
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1")!;
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1 hr")!;
expect((qty.x + qty.right) / 2).toBeCloseTo(355.5, 0);
expect(r0.x).toBeCloseTo(31.5, 0);
});
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
@@ -62,7 +62,7 @@ export function tableRows(model: RenderModel, v: MarbleVariant, rateW: number):
const r = v.roles;
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, r.tableBody);
const rate = rateCell(item, rateW, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
return [desc, item.qtyText, rateParts, item.amountText];
@@ -165,14 +165,14 @@ export function planMarble(model: RenderModel, frame: PageFrame, v: MarbleVarian
const t = v.table;
const need = (texts: string[], head: string, pad: number, headStep: number) =>
Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head) - headStep)) + pad + 6;
const rateOnly = model.items.map((i) => (i.rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(i.rateText)?.[1] ?? i.rateText) : "—"));
const rateOnly = model.items.map((i) => (i.priceText ?? "—"));
// The rate shows as "₹1,200.00/hr" when the column can take it (up to RATE_MAX wide), else as the rate over "per hour".
const shortRate = model.items.map((i) => {
const cell = rateCell(i.rateText, 10000, r.tableBody);
const cell = rateCell(i, 10000, r.tableBody);
return typeof cell === "string" ? cell : "—";
});
const cw = {
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0, 0)),
qty: Math.max(t.cols.qty, need(wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", 0, 0)),
rate: Math.max(t.cols.rate, need(rateOnly, "Rate", t.ratePad, 0), Math.min(RATE_MAX, need(shortRate, "Rate", t.ratePad, 0))),
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad, 0)),
};
+1 -1
View File
@@ -193,7 +193,7 @@ describe("table, totals and footer", () => {
expect(amount.text, `row ${i}`).toMatch(/^₹[\d,]+\.\d\d$/);
expect(amount.right, `row ${i} amount edge`).toBeCloseTo(right - V.table.numPad, 0);
}
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1")!;
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1 hr")!;
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
const left = frameForPrefs(r.prefs).pad.left + plan.W - plan.cw.amount - plan.cw.qty;
expect(qty.x + (qty.right - qty.x) / 2).toBeCloseTo(left + plan.cw.qty / 2, 0);
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
@@ -18,7 +18,7 @@ const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5";
export const REVERSE_GAP = 4;
/** Space between the label of a words / notes block and its text. */
export const LABEL_GAP = 4;
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
/** "Tax" rows read "CGST @ 9%" like the design's "Tax". */
export const atRate = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate}` : label);
@@ -58,7 +58,7 @@ export function tableRows(model: RenderModel, v: MonolithVariant, priceW: number
const r = v.roles;
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, priceW, r.tableBody);
const rate = rateCell(item, priceW, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
return [desc, rateParts, item.qtyText, item.amountText];
@@ -165,11 +165,11 @@ export function planMonolith(model: RenderModel, frame: PageFrame, v: MonolithVa
// --- table ---------------------------------------------------------------------------------------------------------
const t = v.table;
const priced = model.items.map((i) => priceOf(i.rateText));
const priced = model.items.map((i) => priceOf(i));
const need = (texts: string[], head: string, pad: number) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head))) + pad + 6;
const cw = {
price: Math.max(t.cols.price, need(priced, "Price", t.numPad)),
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0)),
qty: Math.max(t.cols.qty, need(wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", 0)),
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad)),
};
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.price }, { fixed: cw.qty }, { fixed: cw.amount }];
+6 -3
View File
@@ -131,9 +131,12 @@ describe("helpers", () => {
it("rateCell keeps '₹1,200.00/hr' on one line when it fits and splits the unit otherwise", () => {
const body = linea.roles.tableBody;
expect(rateCell("₹1,200.00 per hour", 90, body)).toBe("₹1,200.00/hr");
expect(rateCell("₹1,200.00 per session", 60, body)).toEqual(["₹1,200.00", "per session"]);
expect(rateCell(null, 90, body)).toBe("—");
const rate = (price: string, per: string, short: string) => ({ priceText: price, perText: per, rateShort: `${price}/${short}` });
expect(rateCell(rate("₹1,200.00", "per hour", "hr"), 90, body)).toBe("₹1,200.00/hr");
expect(rateCell(rate("₹1,200.00", "per session", "session"), 60, body)).toEqual(["₹1,200.00", "per session"]);
expect(rateCell(rate("₹18.50", "per sq ft", "sq ft"), 200, body)).toBe("₹18.50/sq ft");
expect(rateCell(rate("₹18.50", "per bundle", "bundle"), 20, body)).toEqual(["₹18.50", "per bundle"]);
expect(rateCell({ priceText: null, perText: null, rateShort: null }, 90, body)).toBe("—");
expect(getMeasurer()).toBeTruthy();
});
});
+13 -14
View File
@@ -1,11 +1,11 @@
import { cols, type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight, textLines } from "../../engine/textplan";
import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
import { guardPresence } from "../../blocks/TableGuard";
import type { RenderModel } from "../../model/build";
import type { RenderItem, RenderModel } from "../../model/build";
import type { PlacedLogo } from "../contract";
import type { GapRange, NeutralVariant } from "./variant";
@@ -103,18 +103,17 @@ export function metaRows(model: RenderModel): MetaRowSpec[] {
return rows;
}
const SHORT_UNIT: Record<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
* ("per hour") on a second line, which keeps one-line rows one line tall.
*/
export function rateCell(rateText: string | null, width: number, role: NeutralVariant["roles"]["tableBody"]): TableCellValue {
if (!rateText) return "—";
const m = /^(.*) per (second|minute|hour|session|unit)$/.exec(rateText);
if (!m) return rateText;
const short = `${m[1]}${SHORT_UNIT[m[2]]}`;
return measureToken(getMeasurer(), role, short) <= width ? short : [m[1], `per ${m[2]}`];
export function rateCell(
item: Pick<RenderItem, "priceText" | "perText" | "rateShort">,
width: number,
role: NeutralVariant["roles"]["tableBody"],
): TableCellValue {
if (!item.priceText || !item.perText || !item.rateShort) return "—";
return measureToken(getMeasurer(), role, item.rateShort) <= width ? item.rateShort : [item.priceText, item.perText];
}
export interface NeutralPlan {
@@ -224,12 +223,12 @@ export function planNeutral(model: RenderModel, frame: PageFrame, v: NeutralVari
const widest = (role: NeutralVariant["roles"]["tableBody"], texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
const colW = (base: number, texts: string[], headText: string) =>
Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, headText.split(" ")))) + 2 * v.padX + 1);
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
const hsnHead = hsnCol?.header ?? "";
const c = {
hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnHead) : 0,
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "Unit price"),
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty"),
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "Unit price"),
qty: colW(v.cols.qty, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty"),
amount: colW(v.cols.amount, [...model.items.map((i) => i.amountText), "Total"], "Total"),
};
const columns: ColSpec[] = [
@@ -246,7 +245,7 @@ export function planNeutral(model: RenderModel, frame: PageFrame, v: NeutralVari
const tableRows: TableCellValue[][] = model.items.map((item, i) => [
hsn && !hsnCol && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.fine }] : item.descriptionLines,
...(hsnCol ? [hsnCol.values[i]] : []),
rateCell(item.rateText, rateW, r.tableBody),
rateCell(item, rateW, r.tableBody),
item.qtyText,
item.amountText,
]);
+7 -7
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight, textLines } from "../../engine/textplan";
import { DOME_BOX } from "../../decor/Asterisk";
import { fitSize } from "../../blocks/FitText";
@@ -68,13 +68,13 @@ export function idLines(role: TypeToken, text: string, width: number): number {
/** Plain shrink-wrapped width of a one-line text. */
export const widthOf = (role: TypeToken, text: string, size?: number) => Math.ceil(measureToken(getMeasurer(), role, text, size ? { size } : {})) + 1;
export const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
export const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
/** The table's description + HSN/SAC sub-line, qty, rate (amount over its unit) and amount cells. */
export function tableRows(model: RenderModel, rateW: number, body: TypeToken, sub: TypeToken): TableCellValue[][] {
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, body);
const rate = rateCell(item, rateW, body);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: sub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: sub }] : item.descriptionLines;
return [desc, item.qtyText, rateParts, item.amountText];
@@ -193,12 +193,12 @@ export function planPurple(model: RenderModel, frame: PageFrame, v: PurpleVarian
const align: PurplePlan["align"] = ["left", "center", "right", "right"];
const padY = (t.rowH - leading(r.tableBody)) / 2;
const headPadY = (t.headH - leading(r.tableHead)) / 2;
const priced = model.items.map((i) => priceOf(i.rateText));
const priced = model.items.map((i) => priceOf(i));
const build = (left: number, base: { qty: number; rate: number; amount: number }) => {
const tableW = round2(W - left - t.right);
const inner = tableW - 2 * t.inset;
const cw = {
qty: numericColW(base.qty, r.tableBody, r.tableHead, model.items.map((i) => i.qtyText), "QTY"),
qty: numericColW(base.qty, r.tableBody, r.tableHead, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "QTY"),
rate: numericColW(base.rate, r.tableBody, r.tableHead, priced, "RATE"),
amount: numericColW(base.amount, r.tableBody, r.tableHead, model.items.map((i) => i.amountText), "AMOUNT"),
};
@@ -424,10 +424,10 @@ export function planCitrus(model: RenderModel, frame: PageFrame, v: CitrusVarian
// --- table ----------------------------------------------------------------------------------------------------
const t = v.table;
const tableW = round2(W - t.left);
const priced = model.items.map((i) => priceOf(i.rateText));
const priced = model.items.map((i) => priceOf(i));
const amountNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.amountText)), measureToken(m, r.headNum, "AMOUNT"))) + 14 + 4;
const rateNeed = Math.ceil(Math.max(0, ...priced.map((p) => measureToken(m, r.tableBody, p)), measureToken(m, r.headNum, "RATE"))) + 14 + 4;
const qtyNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.qtyText)), measureToken(m, r.headNum, "QTY"))) + 6;
const qtyNeed = Math.ceil(Math.max(0, ...wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody).map((t) => measureToken(m, r.tableBody, t)), measureToken(m, r.headNum, "QTY"))) + 6;
const amount = Math.max(t.cols.amount, amountNeed);
const rate = Math.max(t.cols.rate, rateNeed);
const qty = Math.max(Math.min(36, panelW), round2(panelW - rate - amount));
+10 -10
View File
@@ -1,26 +1,26 @@
{
"classic-a4-tax-3": {
"pages": 1,
"fingerprint": "87b12a5c1b1a9b45ac6a8fd5862c8e7b5de8d203731fe4e485872dea5bbb07cd",
"fingerprint": "3bc384c321777db5a513f85f2b24f378523e3ddf5f3fadb08ae97e68198ed4e2",
"invoiceNumber": "ST/2026-001",
"totalText": "₹8,625.80",
"totalText": "₹22,077.80",
"pageLabel": "Page 1 of 1",
"textHashes": [
"ddda2419b84480c00d8ed76088898277711f448ef11c2383a48494ecd0e4c524"
"e5b4e97805ecb9a50e26694554f810c89cbcd43fba566b27f0f8255a498f753c"
],
"firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A GSTIN: 29AAAAA0000A1Z5 TAX INVOICE Invoice No. ST/2026-001 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 GSTIN: 29ABCDE1234F1Z5 Place of Supply: 29 – Karnataka S . N O D E S C R I P TI O N H S N /S AC U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 998397 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 998397 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 998397 ₹1,200.00 per hour 3 ₹3,600.00 Subtotal ₹7,310.00 Taxable value ₹7,310.00 CGST (9%) ₹657.90 SGST (9%) ₹657.90 Total ₹8,625.80 Amount in words: Indian Rupees Eight Thousand Six Hundred Twenty Five and Eighty Paise Only Tax is payable on reverse charge: No PAY T O Selftest Studio State Bank of India Account No.: 00000000000 Branch: 000000 IFSC: SBIN0000000 Selftest Studio Proprietor Computer-generated invoice; no signature is required. Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 1"
"firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A GSTIN: 29AAAAA0000A1Z5 TAX INVOICE Invoice No. ST/2026-001 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 GSTIN: 29ABCDE1234F1Z5 Place of Supply: 29 – Karnataka S . N O D E S C R I P TI O N H S N /S AC U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 998397 ₹1,200.00 per hour 1 hr ₹1,200.00 2 Voice-over session 2 - narration for module 2 998397 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 998397 ₹1,200.00 per km 12.5 km ₹15,000.00 Subtotal ₹18,710.00 Taxable value ₹18,710.00 CGST (9%) ₹1,683.90 SGST (9%) ₹1,683.90 Total ₹22,077.80 Amount in words: Indian Rupees Twenty Two Thousand Seventy Seven and Eighty Paise Only Tax is payable on reverse charge: No PAY T O Selftest Studio State Bank of India Account No.: 00000000000 Branch: 000000 IFSC: SBIN0000000 Selftest Studio Proprietor Computer-generated invoice; no signature is required. Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 1"
},
"classic-letter-unreg-60": {
"pages": 3,
"fingerprint": "6999be0875f78e2c87aee1fd0ffa1dfe651faa7a7c0a9b4c2f78c1bdff53c7d4",
"fingerprint": "6d5ee73ce95af3f8871a4634e50a082ca9f34865ba4a5ffcb541dbae8d132719",
"invoiceNumber": "ST/2026-002",
"totalText": "₹1,56,000.00",
"totalText": "₹17,04,000.00",
"pageLabel": "Page 1 of 3",
"textHashes": [
"4a0cf8a7c1565037edd4d9941251e030fd9e6fca25845b971465e5cf9d916936",
"870e9a70c0546550d002e5eb5728f342b1d235094c6b645ddda68a6306b33f7b",
"fca30345bc73289cdcad200f888f917ff9a5386cdd64e41ea7dccc64f2b7e5e0"
"314a9e6ff0e6736435b56bb96014c2d7e8b2398ab7f7904c176a90628015154c",
"fee554a8b908951fc5021a1adfc47b245da8a622c35e64770bfd8f38b9b6db70",
"3c4fc1cbba3081b4dcef564b31e3c4cac0aff951923c747ec63a4ce0fd4811e9"
],
"firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A INVOICE Invoice No. ST/2026-002 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 S . N O D E S C R I P TI O N U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 ₹1,200.00 per hour 3 ₹3,600.00 4 Voice-over session 4 - narration for module 4 — 1 ₹2,530.00 5 Voice-over session 5 - narration for module 5 ₹1,200.00 per hour 2 ₹2,400.00 6 Voice-over session 6 - narration for module 6 — 1 ₹2,550.00 7 Voice-over session 7 - narration for module 7 ₹1,200.00 per hour 1 ₹1,200.00 8 Voice-over session 8 - narration for module 8 — 1 ₹2,570.00 9 Voice-over session 9 - narration for module 9 ₹1,200.00 per hour 3 ₹3,600.00 10 Voice-over session 10 - narration for module 10 — 1 ₹2,590.00 11 Voice-over session 11 - narration for module 11 ₹1,200.00 per hour 2 ₹2,400.00 12 Voice-over session 12 - narration for module 12 — 1 ₹2,610.00 13 Voice-over session 13 - narration for module 13 ₹1,200.00 per hour 1 ₹1,200.00 14 Voice-over session 14 - narration for module 14 — 1 ₹2,630.00 15 Voice-over session 15 - narration for module 15 ₹1,200.00 per hour 3 ₹3,600.00 16 Voice-over session 16 - narration for module 16 — 1 ₹2,650.00 17 Voice-over session 17 - narration for module 17 ₹1,200.00 per hour 2 ₹2,400.00 18 Voice-over session 18 - narration for module 18 — 1 ₹2,670.00 19 Voice-over session 19 - narration for module 19 ₹1,200.00 per hour 1 ₹1,200.00 Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 3"
"firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A INVOICE Invoice No. ST/2026-002 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 S . N O D E S C R I P TI O N U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 ₹1,200.00 per hour 1 hr ₹1,200.00 2 Voice-over session 2 - narration for module 2 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 ₹1,200.00 per km 12.5 km ₹15,000.00 4 Voice-over session 4 - narration for module 4 — 1 ₹2,530.00 5 Voice-over session 5 - narration for module 5 ₹1,200.00 per sq ft 121.5 sq ft ₹1,45,800.00 6 Voice-over session 6 - narration for module 6 — 1 ₹2,550.00 7 Voice-over session 7 - narration for module 7 ₹1,200.00 per hour 1 hr ₹1,200.00 8 Voice-over session 8 - narration for module 8 — 1 ₹2,570.00 9 Voice-over session 9 - narration for module 9 ₹1,200.00 per km 12.5 km ₹15,000.00 10 Voice-over session 10 - narration for module 10 — 1 ₹2,590.00 11 Voice-over session 11 - narration for module 11 ₹1,200.00 per sq ft 121.5 sq ft ₹1,45,800.00 12 Voice-over session 12 - narration for module 12 — 1 ₹2,610.00 13 Voice-over session 13 - narration for module 13 ₹1,200.00 per hour 1 hr ₹1,200.00 14 Voice-over session 14 - narration for module 14 — 1 ₹2,630.00 15 Voice-over session 15 - narration for module 15 ₹1,200.00 per km 12.5 km ₹15,000.00 16 Voice-over session 16 - narration for module 16 — 1 ₹2,650.00 17 Voice-over session 17 - narration for module 17 ₹1,200.00 per sq ft 121.5 sq ft ₹1,45,800.00 18 Voice-over session 18 - narration for module 18 — 1 ₹2,670.00 19 Voice-over session 19 - narration for module 19 ₹1,200.00 per hour 1 hr ₹1,200.00 Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 3"
}
}
+9 -2
View File
@@ -58,14 +58,21 @@ const BANK: BankAccount = {
isDefault: true,
};
// Rate lines cycle through units so the webview self-test also measures "hr", "km" and "sq ft" text.
const UNIT_CYCLE: Array<{ unit: string; quantity: (i: number) => number }> = [
{ unit: "hour", quantity: (i) => 1 + (i % 3) },
{ unit: "km", quantity: (i) => 10.5 + (i % 3) },
{ unit: "sqft", quantity: (i) => 120.5 + (i % 3) },
];
const items = (n: number, hsn: boolean): InvoiceItem[] =>
Array.from({ length: n }, (_, i) => ({
id: i + 1,
description: `Voice-over session ${i + 1} - narration for module ${i + 1}`,
mode: i % 2 === 0 ? "rate" : "fixed",
rate: 1200,
unit: "hour",
quantity: 1 + (i % 3),
unit: UNIT_CYCLE[(i >> 1) % UNIT_CYCLE.length].unit,
quantity: UNIT_CYCLE[(i >> 1) % UNIT_CYCLE.length].quantity(i),
amount: 2500 + i * 10,
sortOrder: i,
...(hsn ? { hsnSac: "998397" } : {}),
+27
View File
@@ -213,6 +213,33 @@ body {
margin-bottom: 2rem;
}
// The invoice form's fields sit on a plain 12-column grid. Carbon's nested <Grid> gives a column only as wide as the
// outer grid's tracks, which left dates, GSTINs and the saved-client box too narrow to show their text.
.voiced-fields {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 1rem;
align-items: start;
}
.voiced-field {
grid-column: span 12;
min-width: 0;
}
@for $n from 1 through 12 {
.voiced-field--#{$n} {
grid-column: span $n;
}
}
@media (max-width: 1000px) {
// A narrow window: each of the three date/number fields still needs room for its text.
.voiced-field--4 {
grid-column: span 6;
}
}
.voiced-stack > * + * {
margin-top: 1.5rem;
}
+5 -1
View File
@@ -44,11 +44,13 @@ import {
syncTag,
} from "../lib/erpnextUi";
import { useReturnFocus } from "../hooks/useReturnFocus";
import ErrorBoundary from "../components/ErrorBoundary";
import ExportFeedback from "../components/ExportFeedback";
import PdfPreview from "../components/PdfPreview";
import RecordPaymentModal from "../components/RecordPaymentModal";
import { useToast } from "../components/ToastProvider";
import { INDIAN_STATES, type Invoice, type Settings } from "../lib/types";
import { quantityText } from "../lib/units";
interface Props {
invoiceId: number;
@@ -385,7 +387,9 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
</OverflowMenu>
</div>
<ErrorBoundary fallback={(e) => <p className="voiced-muted">The export result could not be shown ({e.message}). The PDF was still saved.</p>}>
<ExportFeedback exp={exp} />
</ErrorBoundary>
<div className="voiced-detail__grid">
<div className="voiced-detail__main voiced-stack">
@@ -453,7 +457,7 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
<span className="voiced-detail__pre">{item.description}</span>
{item.mode === "rate" ? (
<div className="voiced-muted">
{item.quantity} {item.unit} × ₹{formatAmount(item.rate)}
{quantityText(item.quantity, item.unit)} × ₹{formatAmount(item.rate)}
</div>
) : null}
</TableCell>
+3
View File
@@ -49,6 +49,7 @@ import { openInErpnext } from "../lib/erpnextActions";
import type { ErpnextConfig, ErpnextSyncStatus } from "../lib/erpnext";
import { canOpenInErpnext, indexSyncStatuses, isConfigured, pushDisabledReason, sendableSelection, syncTag } from "../lib/erpnextUi";
import SendToErpnextModal, { type SendTarget } from "../components/SendToErpnextModal";
import ErrorBoundary from "../components/ErrorBoundary";
import ExportFeedback from "../components/ExportFeedback";
import { useToast } from "../components/ToastProvider";
import type { InvoiceSummary, Settings } from "../lib/types";
@@ -226,7 +227,9 @@ export default function InvoiceHistory({ settings, active, onOpen, onOpenSetting
<h1 className="voiced-page__title">Invoices</h1>
<p className="voiced-page__subtitle">Every invoice you have issued. Open one to see its PDF, record payments or export it.</p>
<ErrorBoundary fallback={(e) => <p className="voiced-muted">The export result could not be shown ({e.message}). The PDF was still saved.</p>}>
<ExportFeedback exp={exp} />
</ErrorBoundary>
{loading ? (
<InlineLoading description="Loading invoices" />
+53 -40
View File
@@ -34,7 +34,8 @@ import {
} from "@carbon/icons-react";
import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
import { EMPTY_ITEM, INDIAN_STATES } from "../lib/types";
import { isValidUnit } from "../lib/units";
import type {
BankAccount,
Client,
@@ -44,7 +45,6 @@ import type {
InvoiceInput,
InvoiceItem,
LineMode,
LineUnit,
Settings,
TaxType,
} from "../lib/types";
@@ -56,10 +56,13 @@ import { formToPreviewInvoice } from "../lib/previewInvoice";
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
import PageSetupControls from "../components/PageSetupControls";
import QuantityInput from "../components/QuantityInput";
import UnitPicker from "../components/UnitPicker";
import { useInvoicePdf } from "../hooks/useInvoicePdf";
import { useInvoiceExport } from "../hooks/useInvoiceExport";
import { autoPushIssued } from "../lib/erpnextUi";
import ExportButton, { ExportModePicker } from "../components/ExportButton";
import ErrorBoundary from "../components/ErrorBoundary";
import ExportFeedback from "../components/ExportFeedback";
import { createExportDeps } from "../lib/exportDeps";
import { issueAndArchive, renderAndArchive } from "../lib/exportFlow";
@@ -255,6 +258,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
? "cgst_sgst"
: "igst";
const clientGstinError = gstinFullError(clientGstin);
// Quantity boxes that hold text which is not a quantity; they keep the last good value, so issuing must wait.
const badQuantities = useRef(new Set<string>());
const markQuantity = (id: string, ok: boolean) => {
if (ok) badQuantities.current.delete(id);
else badQuantities.current.add(id);
};
const bank = useMemo(() => banks.find((b) => b.id === bankId) ?? null, [banks, bankId]);
const payload = useMemo(
@@ -607,6 +616,14 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
setError(`Client GSTIN: ${clientGstinError}`);
return;
}
if (badQuantities.current.size > 0) {
setError("A quantity is not a number. Fix the line items marked in red.");
return;
}
if (items.some((i) => i.mode === "rate" && !isValidUnit(i.unit))) {
setError("A line item has no valid unit. Pick one, or type a custom unit.");
return;
}
setSaving(true);
setError(null);
exp.dismiss();
@@ -778,7 +795,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
onActionButtonClick={() => void retryArchive()}
/>
) : null}
<ErrorBoundary fallback={(e) => <p className="voiced-muted">The export result could not be shown ({e.message}). The PDF was still saved.</p>}>
<ExportFeedback exp={exp} />
</ErrorBoundary>
<div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}>
<ExportButton onExport={(mode) => void exp.start(exportable, mode, "original")} disabled={busy} />
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={busy}>
@@ -794,8 +813,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
</Column>
<Column sm={4} md={8} lg={9} className="voiced-stack">
<Grid narrow>
<Column sm={4} md={4} lg={5}>
<div className="voiced-fields">
<div className="voiced-field voiced-field--4">
<TextInput
id="invoiceDate"
disabled={locked}
@@ -807,8 +826,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
setDueDate(addDays(e.target.value, settings.paymentTermsDays));
}}
/>
</Column>
<Column sm={4} md={4} lg={5}>
</div>
<div className="voiced-field voiced-field--4">
<TextInput
id="dueDate"
disabled={locked}
@@ -817,12 +836,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</Column>
<Column sm={4} md={8} lg={6}>
</div>
<div className="voiced-field voiced-field--4">
<TextInput id="invoiceNumber" labelText="Invoice number" value={issued?.number ?? number} readOnly />
</Column>
</div>
<Column sm={4} md={8} lg={8}>
<div className="voiced-field voiced-field--12">
<ComboBox
id="savedClient"
disabled={locked}
@@ -847,8 +866,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
>
Create client
</Button>
</Column>
<Column sm={4} md={8} lg={8}>
</div>
<div className="voiced-field voiced-field--12">
<TextInput
id="clientName"
disabled={locked}
@@ -859,8 +878,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
setClientId(null);
}}
/>
</Column>
<Column sm={4} md={8} lg={16}>
</div>
<div className="voiced-field voiced-field--12">
<TextArea
id="clientAddress"
disabled={locked}
@@ -869,8 +888,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
value={clientAddress}
onChange={(e) => setClientAddress(e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={5}>
</div>
<div className="voiced-field voiced-field--5">
<TextInput
id="clientGstin"
disabled={locked}
@@ -880,8 +899,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
invalid={Boolean(clientGstinError)}
invalidText={clientGstinError}
/>
</Column>
<Column sm={4} md={4} lg={5}>
</div>
<div className="voiced-field voiced-field--7">
<TextInput
id="poNumber"
disabled={locked}
@@ -889,8 +908,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
value={poNumber}
onChange={(e) => setPoNumber(e.target.value)}
/>
</Column>
<Column sm={4} md={8} lg={6}>
</div>
<div className="voiced-field voiced-field--12">
<Select
id="placeOfSupply"
disabled={locked}
@@ -902,8 +921,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
))}
</Select>
</Column>
<Column sm={4} md={8} lg={16}>
</div>
<div className="voiced-field voiced-field--12">
<Toggle
id="saveClient"
disabled={locked}
@@ -911,8 +930,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
toggled={saveClient}
onToggle={setSaveClient}
/>
</Column>
</Grid>
</div>
</div>
<div>
<h3 style={{ marginBottom: "0.75rem" }}>Line items</h3>
@@ -963,7 +982,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
/>
</div>
) : null}
<div style={{ flex: "0 1 150px" }}>
<div style={{ flex: "0 1 180px" }}>
<Select
id={`mode-${index}`}
disabled={locked}
@@ -988,28 +1007,22 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
onChange={(_e, { value }) => updateItem(index, { rate: Number(value) || 0 })}
/>
</div>
<div style={{ flex: "0 1 140px" }}>
<Select
<div style={{ flex: "0 1 160px" }}>
<UnitPicker
id={`unit-${index}`}
disabled={locked}
labelText="Unit"
value={item.unit}
onChange={(e) => updateItem(index, { unit: e.target.value as LineUnit })}
>
{Object.entries(UNIT_LABELS).map(([value, text]) => (
<SelectItem key={value} value={value} text={text} />
))}
</Select>
onChange={(unit) => updateItem(index, { unit })}
/>
</div>
<div style={{ flex: "0 1 110px" }}>
<NumberInput
<div style={{ flex: "0 1 130px" }}>
<QuantityInput
id={`qty-${index}`}
disabled={locked}
label="Quantity"
min={0}
step={1}
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>
</>