9 Commits
Author SHA1 Message Date
xavierk e87fd8f364 Merge pull request 'Release 1.2.0: Reset all data' (#3) from release/1.2.0 into main
CI / security (push) Successful in 39s
CI / lint (push) Successful in 2m44s
CI / e2e (push) Successful in 2m26s
CI / ai-review (push) Skipped
2026-10-06 07:14:35 +00:00
xavierkandClaude Sonnet 5.5 8122129a19 Add Reset all data to Settings > Data and release 1.2.0
Windows build / build (push) Waiting to run
CI / security (pull_request) Successful in 40s
CI / lint (pull_request) Successful in 2m50s
CI / e2e (pull_request) Successful in 3m3s
CI / ai-review (pull_request) Failing after 10m2s
Reset all data stages a wipe behind a typed RESET confirmation and applies it
on the next start, before the database is opened. It deletes the database,
assets, PDF archive, imported fonts and every backup, then the setup wizard
shows again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:29:52 +05:30
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
75 changed files with 1658 additions and 216 deletions
+17 -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). 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.2.0**. Installers and the Void package are attached to the release on the project's Gitea
page (see "Installing"). page (see "Installing").
## Features ## Features
@@ -19,8 +19,8 @@ page (see "Installing").
series prefix or digits. series prefix or digits.
- **Invoices.** Automatic numbering in a persistent series (for example `INV/2026-001`) with a one-click new series, - **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 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 and layout. Line items are a fixed amount or rate x quantity in a unit of measure (time, distance, area, weight,
item presets. 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 - **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 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. tax. GSTIN, PAN, IFSC, email and phone are validated; amounts are written in Indian words.
@@ -38,6 +38,7 @@ page (see "Installing").
- **Payments.** Record payments against an issued invoice, including TDS deducted by the client. Outstanding - **Payments.** Record payments against an issued invoice, including TDS deducted by the client. Outstanding
balance is cash plus TDS against the invoice total. balance is cash plus TDS against the invoice total.
- **History.** Filterable invoice list, detail view with payments, and CSV or JSON export of the list. - **History.** Filterable invoice list, detail view with payments, and CSV or JSON export of the list.
- **Reset all data.** Settings, Data wipes everything and returns to first-run setup after a typed `RESET`.
- **Backup and restore.** Manual and automatic daily backups of the database, assets, PDF archive and imported fonts. - **Backup and restore.** Manual and automatic daily backups of the database, assets, PDF archive and imported fonts.
- **Export for other software.** `voiced.invoice.v1` JSON and line-item CSV for selected invoices - **Export for other software.** `voiced.invoice.v1` JSON and line-item CSV for selected invoices
(see `docs/voiced-invoice-v1.md`). (see `docs/voiced-invoice-v1.md`).
@@ -85,11 +86,11 @@ The release procedure, including the manual first-run checklist, is in `docs/REL
## Installing ## Installing
Download the files for your platform from the release page of the project on `git.bongbetic.com` (Releases, then Download the files for your platform from the release page of the project on `git.bongbetic.com` (Releases, then
`v1.0.2`). `v1.2.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.2.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". 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.2.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 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 pre-filled. Uninstalling does not delete your data (see "Where data lives"); to start from scratch use
@@ -126,6 +127,16 @@ Settings, Data tab.
backup files on storage you trust, do not email or upload them casually, and rotate the ERPNext API key if a backup files on storage you trust, do not email or upload them casually, and rotate the ERPNext API key if a
backup is lost. backup is lost.
## Reset all data
Settings, Data, Reset all data. This permanently deletes the database (invoices, clients, payments, business details
and settings), `assets/`, the PDF `archive/`, imported `fonts/` and the whole `backups/` folder, including automatic
backups and the `pre-restore-*` safety copies, then starts Voiced as if it were new (the setup wizard shows again).
You must type `RESET` to enable the button. The wipe is staged and applied on the next start, before the database is
opened, so the "Delete everything and restart" button restarts the app for you; if a file cannot be deleted the
wipe is retried on the next start. Backup zips you saved elsewhere are not touched. Take a backup first if you may
want the data back.
## Fonts and licences ## Fonts and licences
Eleven families are bundled as static TTF files in `public/fonts/<family>/`, each with its `OFL.txt`: DM Sans, Eleven families are bundled as static TTF files in `public/fonts/<family>/`, each with its `OFL.txt`: DM Sans,
+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. - **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" - **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. 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 - **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. a template that uses it changes, remove it again.
- **Clients.** Create a client with structured address and GST category; use it on an invoice. - **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 - **Items.** `taxable_value` of a line is the line amount before the invoice-level discount. `totals.taxable_value` is
`subtotal - discount`, the base the tax is charged on. A fixed-amount line has `quantity "1"` and `unit null`. `subtotal - discount`, the base the tax is charged on. A fixed-amount line has `quantity "1"` and `unit null`.
- **Units.** On a rate line `unit` is a built-in id (`unit`, `piece`, `set`, `session`, `second`, `minute`, `hour`, `day`,
`week`, `month`, `km`, `m`, `sqft`, `sqm`, `kg`, `litre`) or the custom text typed on the invoice (at most 12 characters:
letters, digits, spaces and `. / -`). `quantity` may be fractional; a time typed as `4:20` hours is stored as the exact
repeating decimal.
- **UTGST.** When the supplier is in a union territory without a legislature (state codes 04, 26, 31, 35, 38) the second - **UTGST.** When the supplier is in a union territory without a legislature (state codes 04, 26, 31, 35, 38) the second
head is reported under `utgst` and `sgst` is zero. head is reported under `utgst` and `sgst` is zero.
- **Client details.** The name, GSTIN, printed address and place of supply are frozen on the invoice. The structured - **Client details.** The name, GSTIN, printed address and place of supply are frozen on the invoice. The structured
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "voiced", "name": "voiced",
"version": "1.0.2", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "voiced", "name": "voiced",
"version": "1.0.2", "version": "1.2.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@carbon/icons-react": "^11.89.0", "@carbon/icons-react": "^11.89.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "voiced", "name": "voiced",
"private": true, "private": true,
"version": "1.0.2", "version": "1.2.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Voiced — offline invoicing desktop app by Bongbetic", "description": "Voiced — offline invoicing desktop app by Bongbetic",
"type": "module", "type": "module",
+1 -1
View File
@@ -28,7 +28,7 @@
# pure Rust and need no extra system libraries. # pure Rust and need no extra system libraries.
# #
pkgname=voiced pkgname=voiced
version=1.0.2 version=1.2.0
revision=1 revision=1
archs="x86_64*" archs="x86_64*"
hostmakedepends="rust nodejs pkg-config" 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]] [[package]]
name = "voiced" name = "voiced"
version = "1.0.2" version = "1.2.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "voiced" name = "voiced"
version = "1.0.2" version = "1.2.0"
license = "Apache-2.0" license = "Apache-2.0"
description = "Voiced — offline invoicing desktop app by Bongbetic" description = "Voiced — offline invoicing desktop app by Bongbetic"
authors = ["Bongbetic"] authors = ["Bongbetic"]
+6 -6
View File
@@ -52,11 +52,11 @@ pub const BACKUP_FORMAT: &str = "voiced.backup.v1";
pub const AUTO_KEEP: usize = 14; pub const AUTO_KEEP: usize = 14;
const MANIFEST_NAME: &str = "manifest.json"; const MANIFEST_NAME: &str = "manifest.json";
const DB_ENTRY: &str = "voiced.db"; pub(super) const DB_ENTRY: &str = "voiced.db";
const ROOTS: [&str; 3] = ["assets", "archive", "fonts"]; pub(super) const ROOTS: [&str; 3] = ["assets", "archive", "fonts"];
const MARKER_NAME: &str = "pending_restore.json"; pub(super) const MARKER_NAME: &str = "pending_restore.json";
const LAST_RESTORE_NAME: &str = "last_restore.json"; pub(super) const LAST_RESTORE_NAME: &str = "last_restore.json";
const STAGING_NAME: &str = "pending-restore"; pub(super) const STAGING_NAME: &str = "pending-restore";
const AUTO_PREFIX: &str = "voiced-auto-"; const AUTO_PREFIX: &str = "voiced-auto-";
const AUTO_SUFFIX: &str = ".zip"; const AUTO_SUFFIX: &str = ".zip";
@@ -100,7 +100,7 @@ impl DataDirs {
self.data.join(LAST_RESTORE_NAME) self.data.join(LAST_RESTORE_NAME)
} }
/// Where a root (`assets`, `archive`, `fonts`) lives. /// Where a root (`assets`, `archive`, `fonts`) lives.
fn root_dir(&self, root: &str) -> PathBuf { pub(super) fn root_dir(&self, root: &str) -> PathBuf {
match root { match root {
"assets" => self.data.join("assets"), "assets" => self.data.join("assets"),
_ => self.local.join(root), _ => self.local.join(root),
+49 -1
View File
@@ -8,6 +8,7 @@ use crate::models::{
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary,
Settings, Settings,
}; };
use crate::units;
use crate::AppState; use crate::AppState;
use chrono::NaiveDate; use chrono::NaiveDate;
use rusqlite::{named_params, params, Connection, OptionalExtension, Row}; use rusqlite::{named_params, params, Connection, OptionalExtension, Row};
@@ -131,6 +132,15 @@ fn parse_date(label: &str, value: &str) -> Result<(), String> {
/// overflow integer paise arithmetic. /// overflow integer paise arithmetic.
const MAX_LINE_RUPEES: f64 = 1e11; const MAX_LINE_RUPEES: f64 = 1e11;
/// The unit written to the database. A fixed-amount line has no unit of its own, so whatever the form held is not kept.
fn stored_unit(item: &InvoiceItem) -> String {
if item.mode == "rate" {
units::normalize(&item.unit)
} else {
"unit".to_string()
}
}
fn validate_items(items: &[InvoiceItem]) -> Result<(), String> { fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
if items.is_empty() { if items.is_empty() {
return Err("Add at least one line item".into()); return Err("Add at least one line item".into());
@@ -148,6 +158,7 @@ fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
if item.quantity > MAX_LINE_RUPEES { if item.quantity > MAX_LINE_RUPEES {
return Err(format!("Line {n}: quantity is too large")); return Err(format!("Line {n}: quantity is too large"));
} }
units::validate(&item.unit).map_err(|e| format!("Line {n}: {e}"))?;
let amount = gst::line_amount_paise(item); let amount = gst::line_amount_paise(item);
if amount <= 0 { if amount <= 0 {
return Err(format!("Line {n}: amount must be greater than zero")); return Err(format!("Line {n}: amount must be greater than zero"));
@@ -441,7 +452,7 @@ pub fn issue_invoice_impl(
item.description, item.description,
item.mode, item.mode,
item.rate, item.rate,
item.unit, stored_unit(item),
item.quantity, item.quantity,
paise_to_rupees(gst::line_amount_paise(item)), paise_to_rupees(gst::line_amount_paise(item)),
i as i64, i as i64,
@@ -916,6 +927,43 @@ mod tests {
assert_eq!(inv.items[1].hsn_sac, ""); assert_eq!(inv.items[1].hsn_sac, "");
} }
#[test]
fn lines_can_use_any_unit_and_fractional_quantities() {
let mut conn = registered();
let inv = issue(
&mut conn,
input(json!({
"items": [
{"description": "Travel", "mode": "rate", "rate": 18.0, "quantity": 12.75, "unit": "km"},
{"description": "Studio", "mode": "rate", "rate": 1200.0, "quantity": 4.0 + 1.0 / 3.0, "unit": "HOUR"},
{"description": "Parcels", "mode": "rate", "rate": 99.5, "quantity": 3.0, "unit": " bundle "},
{"description": "Fee", "mode": "fixed", "amount": 10.0, "unit": "not a unit;"},
],
})),
)
.unwrap();
let units: Vec<&str> = inv.items.iter().map(|i| i.unit.as_str()).collect();
assert_eq!(units, ["km", "hour", "bundle", "unit"]);
let amounts: Vec<f64> = inv.items.iter().map(|i| i.amount).collect();
// 18 x 12.75 = 229.50; 1200 x 4h20m = 5200.00 (one rounding, on the exact quantity); 99.50 x 3 = 298.50.
assert_eq!(amounts, [229.5, 5200.0, 298.5, 10.0]);
}
#[test]
fn a_rate_line_with_a_bad_unit_is_refused_and_the_counter_stays() {
let mut conn = registered();
for bad in ["", " ", "thirteen char", "x;y", "\u{20b9}"] {
let err = issue(
&mut conn,
input(json!({"items": [{"description": "Work", "mode": "rate", "rate": 10.0, "quantity": 1.0, "unit": bad}]})),
)
.unwrap_err();
assert!(err.starts_with("Line 1: "), "{bad:?}: {err}");
}
assert_eq!(next_number(&conn), 1);
assert_eq!(invoice_count(&conn), 0);
}
#[test] #[test]
fn tax_type_mismatch_is_rejected_and_the_counter_stays() { fn tax_type_mismatch_is_rejected_and_the_counter_stays() {
let mut conn = registered(); let mut conn = registered();
+1
View File
@@ -10,5 +10,6 @@ pub mod logo;
pub mod payments; pub mod payments;
pub mod presets; pub mod presets;
pub mod raw; pub mod raw;
pub mod reset;
pub mod series; pub mod series;
pub mod settings; pub mod settings;
+27 -9
View File
@@ -1,10 +1,10 @@
use crate::models::ItemPreset; use crate::models::ItemPreset;
use crate::units;
use crate::AppState; use crate::AppState;
use rusqlite::{params, Connection, Row}; use rusqlite::{params, Connection, Row};
use tauri::State; use tauri::State;
const MODES: &[&str] = &["fixed", "rate"]; const MODES: &[&str] = &["fixed", "rate"];
const UNITS: &[&str] = &["second", "minute", "hour", "session", "unit"];
/// Upper bound on a preset amount (paise); keeps later integer-paise arithmetic far from overflow. /// Upper bound on a preset amount (paise); keeps later integer-paise arithmetic far from overflow.
const MAX_RATE_PAISE: i64 = 100_000_000_000; const MAX_RATE_PAISE: i64 = 100_000_000_000;
@@ -45,9 +45,8 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
if !MODES.contains(&preset.mode.as_str()) { if !MODES.contains(&preset.mode.as_str()) {
return Err(format!("Unknown line type \"{}\"", preset.mode)); return Err(format!("Unknown line type \"{}\"", preset.mode));
} }
if !UNITS.contains(&preset.unit.as_str()) { units::validate(&preset.unit)?;
return Err(format!("Unknown unit \"{}\"", preset.unit)); let unit = units::normalize(&preset.unit);
}
if !(0..=MAX_RATE_PAISE).contains(&preset.rate_paise) { if !(0..=MAX_RATE_PAISE).contains(&preset.rate_paise) {
return Err("Amount is out of range".into()); return Err("Amount is out of range".into());
} }
@@ -58,7 +57,7 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
.execute( .execute(
"UPDATE item_presets SET description=?1, hsn_sac=?2, mode=?3, unit=?4, rate_paise=?5, sort_order=?6 "UPDATE item_presets SET description=?1, hsn_sac=?2, mode=?3, unit=?4, rate_paise=?5, sort_order=?6
WHERE id=?7", WHERE id=?7",
params![description, hsn, preset.mode, preset.unit, preset.rate_paise, preset.sort_order, id], params![description, hsn, preset.mode, unit, preset.rate_paise, preset.sort_order, id],
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if n == 0 { if n == 0 {
@@ -74,7 +73,7 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
description, description,
hsn, hsn,
preset.mode, preset.mode,
preset.unit, unit,
preset.rate_paise, preset.rate_paise,
preset.sort_order, preset.sort_order,
chrono::Utc::now().to_rfc3339() chrono::Utc::now().to_rfc3339()
@@ -159,6 +158,23 @@ mod tests {
delete_preset_impl(&conn, 12345).unwrap(); delete_preset_impl(&conn, 12345).unwrap();
} }
#[test]
fn units_beyond_the_original_five_are_stored_as_given() {
let conn = open_in_memory().unwrap();
for (typed, stored) in [
("km", "km"),
("SQFT", "sqft"),
(" bundle ", "bundle"),
("sq.ft/day", "sq.ft/day"),
] {
let mut p = preset(typed);
p.mode = "rate".into();
p.unit = typed.into();
p.rate_paise = 5_000;
assert_eq!(save_preset_impl(&conn, p).unwrap().unit, stored, "{typed}");
}
}
#[test] #[test]
fn validation_rejects_bad_presets() { fn validation_rejects_bad_presets() {
let conn = open_in_memory().unwrap(); let conn = open_in_memory().unwrap();
@@ -169,9 +185,11 @@ mod tests {
let mut p = preset("X"); let mut p = preset("X");
p.mode = "weird".into(); p.mode = "weird".into();
assert!(save_preset_impl(&conn, p).is_err()); assert!(save_preset_impl(&conn, p).is_err());
let mut p = preset("X"); for bad in ["", " ", "far too long a unit", "bad;unit", "₹"] {
p.unit = "decade".into(); let mut p = preset("X");
assert!(save_preset_impl(&conn, p).is_err()); p.unit = bad.into();
assert!(save_preset_impl(&conn, p).is_err(), "{bad:?}");
}
let mut ghost = preset("Ghost"); let mut ghost = preset("Ghost");
ghost.id = Some(77); ghost.id = Some(77);
assert!(save_preset_impl(&conn, ghost).is_err()); assert!(save_preset_impl(&conn, ghost).is_err());
+231
View File
@@ -0,0 +1,231 @@
//! Reset all data: wipe everything the app owns, then start blank (the first-run wizard shows again).
//!
//! The running app holds `voiced.db` open (WAL) and serves files out of `assets/`, `archive/` and `fonts/`,
//! so deleting them live is unsafe. Like a restore, a reset has two phases:
//!
//! 1. `reset_all_data` (`stage_wipe_impl`) writes `<data>/pending_wipe.json` and touches nothing else.
//! 2. `apply_pending_wipe` runs in `init_state` BEFORE a staged restore is applied and before the database is
//! opened. It deletes exactly the names in `OWNED_DB_FILES` and `OWNED_DIRS`, never anything else in the
//! data directories (on Linux both roots are the same folder and may hold unrelated files), and removes the
//! marker last, so a failed wipe is retried on the next start. The frontend restarts the app after staging.
//!
//! This also deletes the backups (`<data>/backups`, including pre-restore safety copies and pre-migration
//! copies); that is the point of a reset.
use super::backup::{DataDirs, DB_ENTRY, LAST_RESTORE_NAME, MARKER_NAME, ROOTS, STAGING_NAME};
use super::raw::write_atomic;
use crate::AppState;
use std::fs;
use std::path::Path;
use tauri::State;
const WIPE_MARKER_NAME: &str = "pending_wipe.json";
/// The database and its SQLite sidecars, in the data dir.
const OWNED_DB_FILES: [&str; 4] = [
DB_ENTRY,
"voiced.db-wal",
"voiced.db-shm",
"voiced.db-journal",
];
/// Marker files of the restore mechanism, in the data dir.
const OWNED_MARKERS: [&str; 2] = [MARKER_NAME, LAST_RESTORE_NAME];
#[derive(Debug, PartialEq, Eq)]
pub enum WipeOutcome {
NothingPending,
Wiped,
/// Something could not be deleted; the marker is kept so the next start retries.
Failed {
message: String,
},
}
fn marker(dirs: &DataDirs) -> std::path::PathBuf {
dirs.data.join(WIPE_MARKER_NAME)
}
/// Remove a file or directory tree if it exists. A symlink is unlinked, never followed.
fn remove_if_exists(path: &Path) -> Result<(), String> {
let meta = match fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(format!("Could not delete {}: {e}", path.display())),
};
let result = if meta.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
};
result.map_err(|e| format!("Could not delete {}: {e}", path.display()))
}
/// Record that the next start must wipe everything. The live data is not touched.
pub fn stage_wipe_impl(dirs: &DataDirs) -> Result<(), String> {
write_atomic(&marker(dirs), b"{}")
}
/// Call after the data directories exist and before anything else (restore, database) uses them.
pub fn apply_pending_wipe(dirs: &DataDirs) -> WipeOutcome {
if fs::symlink_metadata(marker(dirs)).is_err() {
return WipeOutcome::NothingPending;
}
let mut targets = Vec::new();
// Order matters: the database goes first so a half-finished wipe never leaves data with no settings row.
for name in OWNED_DB_FILES {
targets.push(dirs.data.join(name));
}
targets.push(dirs.backups());
for root in ROOTS {
targets.push(dirs.root_dir(root));
}
for name in OWNED_MARKERS {
targets.push(dirs.data.join(name));
}
targets.push(dirs.data.join(STAGING_NAME));
targets.push(dirs.local.join(STAGING_NAME));
let errors: Vec<String> = targets
.iter()
.filter_map(|p| remove_if_exists(p).err())
.collect();
if !errors.is_empty() {
return WipeOutcome::Failed {
message: errors.join("; "),
};
}
match remove_if_exists(&marker(dirs)) {
Ok(()) => WipeOutcome::Wiped,
Err(message) => WipeOutcome::Failed { message },
}
}
/// Phase one of a reset: stage the wipe. The app must be restarted to carry it out.
#[tauri::command]
pub fn reset_all_data(state: State<AppState>) -> Result<(), String> {
stage_wipe_impl(&DataDirs::new(&state.data_dir, &state.local_data_dir))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn populate(dirs: &DataDirs) {
for name in OWNED_DB_FILES.iter().chain(OWNED_MARKERS.iter()) {
fs::write(dirs.data.join(name), b"x").unwrap();
}
fs::create_dir_all(dirs.backups().join("auto")).unwrap();
fs::write(
dirs.backups().join("auto").join("voiced-auto-20260101.zip"),
b"x",
)
.unwrap();
for root in ROOTS {
fs::create_dir_all(dirs.root_dir(root)).unwrap();
fs::write(dirs.root_dir(root).join("f.bin"), b"x").unwrap();
}
for staging in [dirs.data.join(STAGING_NAME), dirs.local.join(STAGING_NAME)] {
fs::create_dir_all(&staging).unwrap();
fs::write(staging.join("manifest.json"), b"x").unwrap();
}
fs::write(dirs.data.join("unrelated.txt"), b"keep").unwrap();
fs::create_dir_all(dirs.data.join("other")).unwrap();
fs::write(dirs.data.join("other").join("keep.txt"), b"keep").unwrap();
}
fn assert_wiped(dirs: &DataDirs) {
for name in OWNED_DB_FILES.iter().chain(OWNED_MARKERS.iter()) {
assert!(!dirs.data.join(name).exists(), "{name}");
}
assert!(!dirs.backups().exists());
for root in ROOTS {
assert!(!dirs.root_dir(root).exists(), "{root}");
}
assert!(!dirs.data.join(STAGING_NAME).exists());
assert!(!dirs.local.join(STAGING_NAME).exists());
assert!(!marker(dirs).exists());
}
fn assert_unrelated_kept(dirs: &DataDirs) {
assert_eq!(fs::read(dirs.data.join("unrelated.txt")).unwrap(), b"keep");
assert!(dirs.data.join("other").join("keep.txt").exists());
}
#[test]
fn nothing_staged_means_nothing_is_deleted() {
let tmp = tempdir().unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
populate(&dirs);
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::NothingPending);
assert!(dirs.db().exists());
assert!(dirs.backups().exists());
}
#[test]
fn staging_alone_does_not_touch_live_data() {
let tmp = tempdir().unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
populate(&dirs);
stage_wipe_impl(&dirs).unwrap();
assert!(marker(&dirs).exists());
assert!(dirs.db().exists());
assert!(dirs.root_dir("archive").join("f.bin").exists());
}
#[test]
fn wipe_removes_owned_data_and_keeps_unrelated_files() {
let tmp = tempdir().unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
populate(&dirs);
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert_wiped(&dirs);
assert_unrelated_kept(&dirs);
assert!(dirs.data.exists());
}
#[test]
fn wipe_handles_separate_data_and_local_roots() {
let tmp = tempdir().unwrap();
let data = tmp.path().join("roaming");
let local = tmp.path().join("local");
fs::create_dir_all(&data).unwrap();
fs::create_dir_all(&local).unwrap();
let dirs = DataDirs::new(&data, &local);
populate(&dirs);
fs::write(local.join("unrelated-local.txt"), b"keep").unwrap();
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert_wiped(&dirs);
assert_unrelated_kept(&dirs);
assert!(local.join("unrelated-local.txt").exists());
}
#[test]
fn wipe_is_idempotent_when_things_are_missing() {
let tmp = tempdir().unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::NothingPending);
populate(&dirs);
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert_wiped(&dirs);
}
#[cfg(unix)]
#[test]
fn a_symlinked_root_is_unlinked_not_followed() {
let tmp = tempdir().unwrap();
let outside = tempdir().unwrap();
fs::write(outside.path().join("precious.txt"), b"keep").unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
std::os::unix::fs::symlink(outside.path(), dirs.root_dir("archive")).unwrap();
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert!(!dirs.root_dir("archive").exists());
assert!(outside.path().join("precious.txt").exists());
}
}
+104 -2
View File
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
const BACKUP_PREFIX: &str = "voiced-pre-v"; const BACKUP_PREFIX: &str = "voiced-pre-v";
/// Highest schema version, i.e. the number of entries in `migrations()`. /// Highest schema version, i.e. the number of entries in `migrations()`.
pub(crate) const LATEST_VERSION: i64 = 11; pub(crate) const LATEST_VERSION: i64 = 12;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum DbError { pub enum DbError {
@@ -345,6 +345,27 @@ DROP TABLE erpnext_sync;
ALTER TABLE erpnext_sync_new RENAME TO erpnext_sync; ALTER TABLE erpnext_sync_new RENAME TO erpnext_sync;
"#; "#;
/// Version 12: `item_presets.unit` no longer lists five units. Units of measure now cover time, distance, area, weight and
/// volume, and a preset may carry a custom unit, so the allow-list becomes a length check and src/units.rs does the rest.
/// SQLite cannot alter a CHECK constraint, so the table is rebuilt; every M7/M10 row is copied over with its id.
const M12: &str = r#"
CREATE TABLE item_presets_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
description TEXT NOT NULL,
hsn_sac TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'fixed' CHECK (mode IN ('fixed', 'rate')),
unit TEXT NOT NULL DEFAULT 'unit' CHECK (length(trim(unit)) BETWEEN 1 AND 12),
rate_paise INTEGER NOT NULL DEFAULT 0 CHECK (rate_paise >= 0),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
erpnext_item_code TEXT
);
INSERT INTO item_presets_new (id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code)
SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code FROM item_presets;
DROP TABLE item_presets;
ALTER TABLE item_presets_new RENAME TO item_presets;
"#;
fn migrations() -> Migrations<'static> { fn migrations() -> Migrations<'static> {
Migrations::new(vec![ Migrations::new(vec![
M::up(SCHEMA), M::up(SCHEMA),
@@ -358,6 +379,7 @@ fn migrations() -> Migrations<'static> {
M::up(M9), M::up(M9),
M::up(M10), M::up(M10),
M::up(M11), M::up(M11),
M::up(M12),
]) ])
} }
@@ -1049,7 +1071,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.is_err()); .is_err());
migrations().to_latest(&mut conn).unwrap(); migrations().to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 11); assert_eq!(user_version(&conn), LATEST_VERSION);
let row: (String, i64, String, String, String, Option<String>) = conn let row: (String, i64, String, String, String, Option<String>) = conn
.query_row( .query_row(
"SELECT remote_name, remote_docstatus, status, payload_hash, attachment_sha256, synced_at FROM erpnext_sync WHERE invoice_id = 1", "SELECT remote_name, remote_docstatus, status, payload_hash, attachment_sha256, synced_at FROM erpnext_sync WHERE invoice_id = 1",
@@ -1082,6 +1104,86 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.is_err()); .is_err());
} }
#[test]
fn m12_rebuilds_item_presets_keeping_rows_and_allowing_new_units() {
let mut conn = Connection::open_in_memory().unwrap();
let v11 = vec![
M::up(SCHEMA),
M::up(M2),
M::up(M3),
M::up(M4),
M::up(M5),
M::up(M6),
M::up(M7),
M::up(M8),
M::up(M9),
M::up(M10),
M::up(M11),
];
Migrations::new(v11).to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 11);
conn.execute(
"INSERT INTO item_presets (description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code)
VALUES ('Studio', '998397', 'rate', 'hour', 150050, 3, 'then', 'ITEM-1'), ('Fee', '', 'fixed', 'unit', 500000, 0, 'then', NULL)",
[],
)
.unwrap();
assert!(conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('X', 'km', 'now')", []).is_err());
migrations().to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 12);
let rows: Vec<(i64, String, String, String, String, i64, i64, String, Option<String>)> = conn
.prepare("SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code FROM item_presets ORDER BY id")
.unwrap()
.query_map([], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?, r.get(8)?))
})
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(
rows,
vec![
(
1,
"Studio".into(),
"998397".into(),
"rate".into(),
"hour".into(),
150050,
3,
"then".into(),
Some("ITEM-1".into())
),
(
2,
"Fee".into(),
"".into(),
"fixed".into(),
"unit".into(),
500000,
0,
"then".into(),
None
),
]
);
// New units and custom text are allowed; an empty or over-long unit is not; ids keep counting up.
conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('Travel', 'km', 'now')", []).unwrap();
conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('Parcel', 'bundle', 'now')", []).unwrap();
assert!(conn
.execute(
"INSERT INTO item_presets (description, unit, created_at) VALUES ('X', '', 'now')",
[]
)
.is_err());
assert!(conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('X', 'thirteen char', 'now')", []).is_err());
let last: i64 = conn
.query_row("SELECT MAX(id) FROM item_presets", [], |r| r.get(0))
.unwrap();
assert_eq!(last, 4);
}
fn build_v1_database(path: &Path) { fn build_v1_database(path: &Path) {
let conn = Connection::open(path).unwrap(); let conn = Connection::open(path).unwrap();
conn.execute_batch(V1_SCHEMA).unwrap(); conn.execute_batch(V1_SCHEMA).unwrap();
+5 -11
View File
@@ -65,18 +65,12 @@ impl NamingMode {
} }
/// Voiced line unit to ERPNext UOM. Whole-number UOMs such as "Nos" reject fractional quantities, /// Voiced line unit to ERPNext UOM. Whole-number UOMs such as "Nos" reject fractional quantities,
/// which is why hours and minutes map to their own UOMs. /// which is why hours, minutes, kilometres and the like map to their own UOMs (see `units::KNOWN`).
pub fn default_uom_map() -> BTreeMap<String, String> { pub fn default_uom_map() -> BTreeMap<String, String> {
[ crate::units::KNOWN
("second", "Second"), .iter()
("minute", "Minute"), .map(|(id, uom)| (id.to_string(), uom.to_string()))
("hour", "Hour"), .collect()
("session", "Nos"),
("unit", "Nos"),
]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
} }
/// Everything stored for the integration, secret included. Backend-only. /// Everything stored for the integration, secret included. Backend-only.
+11 -1
View File
@@ -5,6 +5,7 @@ mod integrations;
mod logo; mod logo;
mod models; mod models;
mod selftest; mod selftest;
mod units;
use rusqlite::Connection; use rusqlite::Connection;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -73,8 +74,16 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
); );
return Err(e.into()); return Err(e.into());
} }
// A restore staged by the Data tab is swapped in now, before anything opens the database. // A reset staged by the Data tab is carried out first (it also discards any staged restore).
let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir); let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir);
match commands::reset::apply_pending_wipe(&dirs) {
commands::reset::WipeOutcome::NothingPending => {}
commands::reset::WipeOutcome::Wiped => eprintln!("All data was reset."),
commands::reset::WipeOutcome::Failed { message } => {
eprintln!("Reset failed, it will be retried on the next start: {message}")
}
}
// A restore staged by the Data tab is swapped in now, before anything opens the database.
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) { match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
commands::backup::ApplyOutcome::NothingPending => {} commands::backup::ApplyOutcome::NothingPending => {}
commands::backup::ApplyOutcome::Applied { safety_dir } => { commands::backup::ApplyOutcome::Applied { safety_dir } => {
@@ -185,6 +194,7 @@ pub fn run() {
commands::backup::get_backup_status, commands::backup::get_backup_status,
commands::backup::set_auto_backup, commands::backup::set_auto_backup,
commands::backup::restart_app, commands::backup::restart_app,
commands::reset::reset_all_data,
commands::invoice::list_invoice_ledger, commands::invoice::list_invoice_ledger,
commands::assets::import_asset, commands::assets::import_asset,
commands::assets::save_asset_bytes, commands::assets::save_asset_bytes,
+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", "$schema": "https://schema.tauri.app/config/2",
"productName": "Voiced", "productName": "Voiced",
"version": "1.0.2", "version": "1.2.0",
"identifier": "com.bongbetic.voiced", "identifier": "com.bongbetic.voiced",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+73 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react"; import { Button, InlineLoading, InlineNotification, Modal, TextInput, Tile, Toggle } from "@carbon/react";
import { confirm, open, save } from "@tauri-apps/plugin-dialog"; import { confirm, open, save } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api"; import { api } from "../lib/api";
import type { Settings } from "../lib/types"; import type { Settings } from "../lib/types";
@@ -11,6 +11,7 @@ import {
type BackupStatus, type BackupStatus,
type PendingRestore, type PendingRestore,
} from "../lib/backup"; } from "../lib/backup";
import { clearVoicedLocalState, isResetConfirmed, RESET_WORD } from "../lib/reset";
import { useReturnFocus } from "../hooks/useReturnFocus"; import { useReturnFocus } from "../hooks/useReturnFocus";
import { useToast } from "./ToastProvider"; import { useToast } from "./ToastProvider";
@@ -28,7 +29,7 @@ interface DataBackupPanelProps {
onRerunSetup: () => void; onRerunSetup: () => void;
} }
/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup and re-running setup. */ /** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup, re-running setup and a full reset. */
export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPanelProps) { export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPanelProps) {
const toast = useToast(); const toast = useToast();
const [status, setStatus] = useState<BackupStatus | null>(null); const [status, setStatus] = useState<BackupStatus | null>(null);
@@ -38,7 +39,10 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
const [staged, setStaged] = useState<PendingRestore | null>(null); const [staged, setStaged] = useState<PendingRestore | null>(null);
const [restoreError, setRestoreError] = useState<string | null>(null); const [restoreError, setRestoreError] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false); const [restarting, setRestarting] = useState(false);
useReturnFocus(restorePath !== null); const [resetOpen, setResetOpen] = useState(false);
const [resetTyped, setResetTyped] = useState("");
const [resetting, setResetting] = useState(false);
useReturnFocus(restorePath !== null || resetOpen);
const rerunSetup = async () => { const rerunSetup = async () => {
const ok = await confirm( const ok = await confirm(
@@ -54,6 +58,31 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
} }
}; };
const closeReset = () => {
if (resetting) return;
setResetOpen(false);
setResetTyped("");
};
// Stage the wipe, clear this webview's own saved state, then restart; the next start deletes the data.
const resetAll = async () => {
setResetting(true);
try {
await api.resetAllData();
} catch (e) {
setResetting(false);
toast.error("Could not reset", String(e));
return;
}
clearVoicedLocalState();
try {
await api.restartApp();
} catch (e) {
setResetting(false);
toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the reset.`);
}
};
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
setStatus(await api.getBackupStatus()); setStatus(await api.getBackupStatus());
@@ -264,6 +293,47 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
</div> </div>
</Tile> </Tile>
<Tile>
<h2 className="voiced-section-title">Reset all data</h2>
<p className="voiced-muted">
Permanently deletes every invoice, client, payment, your business details, archived PDFs and all backups, then starts Voiced
as if it were new.
</p>
<div className="voiced-actions">
<Button size="md" kind="danger--tertiary" onClick={() => setResetOpen(true)} disabled={resetOpen}>
Reset all data…
</Button>
</div>
</Tile>
<Modal
open={resetOpen}
danger
modalHeading="Reset all data?"
primaryButtonText={resetting ? "Resetting…" : "Delete everything and restart"}
secondaryButtonText="Cancel"
primaryButtonDisabled={!isResetConfirmed(resetTyped) || resetting}
onRequestClose={closeReset}
onRequestSubmit={() => void (isResetConfirmed(resetTyped) ? resetAll() : undefined)}
onSecondarySubmit={closeReset}
>
<div className="voiced-stack">
<p>
All invoices, clients, payments, vendor details, settings, logo and signature images, imported fonts, archived PDFs and
every backup (automatic, manual copies kept in Voiced&rsquo;s folder, and safety copies) will be permanently deleted. This
cannot be undone.
</p>
<p>Backup files you saved elsewhere are not touched. Voiced restarts and shows the setup wizard.</p>
<TextInput
id="resetConfirm"
labelText={`Type ${RESET_WORD} to confirm`}
value={resetTyped}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setResetTyped(e.target.value)}
autoComplete="off"
/>
</div>
</Modal>
<Modal <Modal
open={restorePath !== null} open={restorePath !== null}
danger={step === "confirm"} danger={step === "confirm"}
+3 -2
View File
@@ -35,6 +35,7 @@ import {
type FormProblems, type FormProblems,
} from "../lib/erpnextUi"; } from "../lib/erpnextUi";
import { useToast } from "./ToastProvider"; import { useToast } from "./ToastProvider";
import { unitLabel } from "../lib/units";
const SEVERITY_TITLE = { error: "Needs fixing", warning: "Warning", info: "Note" } as const; const SEVERITY_TITLE = { error: "Needs fixing", warning: "Warning", info: "Note" } as const;
@@ -496,12 +497,12 @@ export default function ErpnextSettingsPanel({ active }: { active: boolean }) {
/> />
<fieldset className="voiced-erp__uoms"> <fieldset className="voiced-erp__uoms">
<legend>Units of measure</legend> <legend>Units of measure</legend>
<p className="voiced-muted">The ERPNext unit used for each Voiced unit. Whole-number units such as Nos reject fractional quantities.</p> <p className="voiced-muted">The ERPNext unit used for each Voiced unit. Whole-number units such as Nos reject fractional quantities. A custom unit typed on an invoice is sent as Nos. Month is not a stock ERPNext unit: create it there or pick another.</p>
{UOM_UNITS.map((unit) => ( {UOM_UNITS.map((unit) => (
<PickField <PickField
key={unit} key={unit}
id={`erp-uom-${unit}`} id={`erp-uom-${unit}`}
label={`Voiced ${unit}`} label={`Voiced ${unitLabel(unit).toLowerCase()}`}
value={form.uomMap[unit] ?? ""} value={form.uomMap[unit] ?? ""}
onChange={(v) => setForm((f) => (f ? { ...f, uomMap: { ...f.uomMap, [unit]: v } } : f))} onChange={(v) => setForm((f) => (f ? { ...f, uomMap: { ...f.uomMap, [unit]: v } } : f))}
items={opts.uoms} items={opts.uoms}
+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>
);
}
}
+23 -18
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; 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 { api } from "../lib/api";
import type { InvoiceExport } from "../hooks/useInvoiceExport"; import type { InvoiceExport } from "../hooks/useInvoiceExport";
import FlattenProgressModal from "./FlattenProgressModal"; 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} {busy && !modalOpen ? <InlineLoading description={progress?.phase === "saving" ? "Waiting for the save dialog…" : "Preparing the PDF…"} /> : null}
{error && !flattenFailed ? ( {error && !flattenFailed ? (
<InlineNotification kind="error" lowContrast title="The PDF was not exported" subtitle={error.message} onCloseButtonClick={exp.dismiss}> // Carbon throws if a notification has buttons inside it, and an uncaught throw blanks the whole window.
<div className="voiced-notice-actions"> // A single action goes through ActionableNotification's own button instead.
<Button kind="ghost" size="sm" onClick={exp.retry}> <ActionableNotification
Retry inline
</Button> kind="error"
</div> lowContrast
</InlineNotification> title="The PDF was not exported"
subtitle={error.message}
actionButtonLabel="Retry"
onActionButtonClick={exp.retry}
onCloseButtonClick={exp.dismiss}
/>
) : null} ) : null}
{result ? ( {result ? (
@@ -54,16 +59,16 @@ export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
title={`Saved to ${result.path}`} title={`Saved to ${result.path}`}
subtitle={result.identicalToIssued === true ? "The layout is identical to the issued invoice." : undefined} subtitle={result.identicalToIssued === true ? "The layout is identical to the issued invoice." : undefined}
onCloseButtonClick={exp.dismiss} onCloseButtonClick={exp.dismiss}
> />
<div className="voiced-notice-actions"> {/* Beside the notification, not inside it: Carbon throws on interactive children. */}
<Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}> <div className="voiced-notice-actions">
Open file <Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}>
</Button> Open file
<Button kind="ghost" size="sm" onClick={() => void run(() => api.revealInFolder(result.path))}> </Button>
Show in folder <Button kind="ghost" size="sm" onClick={() => void run(() => api.revealInFolder(result.path))}>
</Button> Show in folder
</div> </Button>
</InlineNotification> </div>
{result.identicalToIssued === false ? ( {result.identicalToIssued === false ? (
<InlineNotification <InlineNotification
kind="warning" kind="warning"
+9 -13
View File
@@ -18,7 +18,9 @@ import {
} from "@carbon/react"; } from "@carbon/react";
import { Add, Edit, TrashCan } from "@carbon/icons-react"; import { Add, Edit, TrashCan } from "@carbon/icons-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { UNIT_LABELS, blankPreset, type ItemPreset, type LineMode, type LineUnit } from "../lib/types"; import { blankPreset, type ItemPreset, type LineMode } from "../lib/types";
import { isValidUnit, ratePerText } from "../lib/units";
import UnitPicker from "./UnitPicker";
import { paiseToRupees, rupeesToPaise } from "../lib/presets"; import { paiseToRupees, rupeesToPaise } from "../lib/presets";
import { formatAmount } from "../lib/format"; import { formatAmount } from "../lib/format";
import { useToast } from "./ToastProvider"; import { useToast } from "./ToastProvider";
@@ -61,9 +63,12 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
if (!editing) return; if (!editing) return;
setTouched(true); setTouched(true);
if (!editing.description.trim()) return; if (!editing.description.trim()) return;
if (editing.mode === "rate" && !isValidUnit(editing.unit)) return;
setBusy(true); setBusy(true);
try { try {
await api.saveItemPreset({ ...editing, description: editing.description.trim() }); // A fixed-amount preset has no unit of its own; whatever the picker last held must not block the save.
const unit = isValidUnit(editing.unit) ? editing.unit : "unit";
await api.saveItemPreset({ ...editing, unit, description: editing.description.trim() });
toast.success(editing.id === null ? "Preset added" : "Preset updated", editing.description.trim()); toast.success(editing.id === null ? "Preset added" : "Preset updated", editing.description.trim());
setEditing(null); setEditing(null);
await load(); await load();
@@ -123,7 +128,7 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
<TableRow key={p.id ?? p.description}> <TableRow key={p.id ?? p.description}>
<TableCell>{p.description}</TableCell> <TableCell>{p.description}</TableCell>
<TableCell>{p.hsnSac || "—"}</TableCell> <TableCell>{p.hsnSac || "—"}</TableCell>
<TableCell>{p.mode === "rate" ? UNIT_LABELS[p.unit] : "Fixed amount"}</TableCell> <TableCell>{p.mode === "rate" ? ratePerText(p.unit) : "Fixed amount"}</TableCell>
<TableCell>₹{formatAmount(paiseToRupees(p.ratePaise))}</TableCell> <TableCell>₹{formatAmount(paiseToRupees(p.ratePaise))}</TableCell>
<TableCell> <TableCell>
<div className="voiced-header-action"> <div className="voiced-header-action">
@@ -199,16 +204,7 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
</Column> </Column>
{editing.mode === "rate" ? ( {editing.mode === "rate" ? (
<Column sm={4} md={4} lg={8}> <Column sm={4} md={4} lg={8}>
<Select <UnitPicker id="ip-unit" value={editing.unit} onChange={(unit) => set("unit", unit)} />
id="ip-unit"
labelText="Unit"
value={editing.unit}
onChange={(e) => set("unit", e.target.value as LineUnit)}
>
{Object.entries(UNIT_LABELS).map(([value, text]) => (
<SelectItem key={value} value={value} text={text} />
))}
</Select>
</Column> </Column>
) : null} ) : null}
<Column sm={4} md={4} lg={8}> <Column sm={4} md={4} lg={8}>
+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([]);
});
});
+2
View File
@@ -122,6 +122,8 @@ export const api = {
getBackupStatus: () => invoke<BackupStatus>("get_backup_status"), getBackupStatus: () => invoke<BackupStatus>("get_backup_status"),
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }), setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
restartApp: () => invoke<void>("restart_app"), restartApp: () => invoke<void>("restart_app"),
// Stages a wipe of all data; it is carried out by the next start (see commands/reset.rs), so restart afterwards.
resetAllData: () => invoke<void>("reset_all_data"),
// ERPNext integration. All HTTP runs in Rust; the API secret is write-only from the webview. // ERPNext integration. All HTTP runs in Rust; the API secret is write-only from the webview.
erpnextGetConfig: () => invoke<ErpnextConfig>("erpnext_get_config"), erpnextGetConfig: () => invoke<ErpnextConfig>("erpnext_get_config"),
+1 -1
View File
@@ -23,7 +23,7 @@ export interface ErpnextConfig {
paymentBankAccount: string; paymentBankAccount: string;
tdsAccount: string; tdsAccount: string;
defaultItemCode: string; defaultItemCode: string;
/** Voiced unit (second, minute, hour, session, unit) to ERPNext UOM. */ /** Voiced unit id (see lib/units.ts) to ERPNext UOM. */
uomMap: Record<string, string>; uomMap: Record<string, string>;
customerGroup: string; customerGroup: string;
territory: string; territory: string;
+1 -1
View File
@@ -232,7 +232,7 @@ describe("settings form", () => {
expect(input).not.toHaveProperty("apiSecretSet"); expect(input).not.toHaveProperty("apiSecretSet");
expect(input).not.toHaveProperty("lastDetectResult"); expect(input).not.toHaveProperty("lastDetectResult");
expect(input.apiSecret).toBe(""); expect(input.apiSecret).toBe("");
expect(input.uomMap).toEqual({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos" }); expect(input.uomMap).toMatchObject({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos", km: "Kilometer", sqft: "Square Foot" });
expect(withDefaultUoms({ hour: " " }).hour).toBe("Hour"); expect(withDefaultUoms({ hour: " " }).hour).toBe("Hour");
}); });
+3 -8
View File
@@ -1,4 +1,5 @@
/** Pure helpers behind the ERPNext screens (Settings, History, invoice detail, auto-push). No UI, no Tauri. */ /** Pure helpers behind the ERPNext screens (Settings, History, invoice detail, auto-push). No UI, no Tauri. */
import { UNITS } from "./units";
import type { import type {
ErpnextConfig, ErpnextConfig,
ErpnextConfigInput, ErpnextConfigInput,
@@ -259,14 +260,8 @@ export function versionsLine(test: ErpnextConnectionTest): string {
// ---- settings form ---- // ---- settings form ----
export const UOM_UNITS = ["second", "minute", "hour", "session", "unit"] as const; export const UOM_UNITS: readonly string[] = UNITS.map((u) => u.id);
export const DEFAULT_UOM_MAP: Record<string, string> = { export const DEFAULT_UOM_MAP: Record<string, string> = Object.fromEntries(UNITS.map((u) => [u.id, u.uom]));
second: "Second",
minute: "Minute",
hour: "Hour",
session: "Nos",
unit: "Nos",
};
export function withDefaultUoms(map: Record<string, string> | undefined): Record<string, string> { export function withDefaultUoms(map: Record<string, string> | undefined): Record<string, string> {
const out: Record<string, string> = { ...DEFAULT_UOM_MAP }; const out: Record<string, string> = { ...DEFAULT_UOM_MAP };
+14
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { computeLineAmount, computeTotals } from "./invoice"; import { computeLineAmount, computeTotals } from "./invoice";
import { EMPTY_ITEM, type InvoiceItem } from "./types"; import { EMPTY_ITEM, type InvoiceItem } from "./types";
import { parseQuantity } from "./units";
// These cases mirror the unit tests in src-tauri/src/gst.rs, so the on-screen preview // These cases mirror the unit tests in src-tauri/src/gst.rs, so the on-screen preview
// and the stored totals stay identical. // and the stored totals stay identical.
@@ -40,6 +41,19 @@ describe("computeTotals", () => {
expect(computeLineAmount(rate(99.99, 2.5))).toBe(249.98); expect(computeLineAmount(rate(99.99, 2.5))).toBe(249.98);
}); });
it("counts a typed time as its decimal and rounds once, like Rust (units test: lines_can_use_any_unit...)", () => {
const q = (text: string, unit: string) => {
const r = parseQuantity(text, unit);
if (!r.ok) throw new Error(r.error);
return r.value;
};
expect(computeLineAmount(rate(1200, q("4:30", "hour")))).toBe(5400);
// 4 h 20 min is 4.333...; 1200 x that is 5200.00 only if the exact value is kept, not 4.33.
expect(computeLineAmount(rate(1200, q("4:20", "hour")))).toBe(5200);
expect(computeLineAmount({ ...rate(18, q("12.75", "km")), unit: "km" })).toBe(229.5);
expect(computeLineAmount(rate(99.5, 3))).toBe(298.5);
});
it("clamps the discount to the subtotal", () => { it("clamps the discount to the subtotal", () => {
const items = [fixed(100), fixed(50.5)]; const items = [fixed(100), fixed(50.5)];
const over = computeTotals(items, 999.99, "igst", 18); const over = computeTotals(items, 999.99, "igst", 18);
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearVoicedLocalState, isResetConfirmed } from "./reset";
describe("isResetConfirmed", () => {
it("accepts the word in any case with surrounding spaces", () => {
expect(isResetConfirmed("RESET")).toBe(true);
expect(isResetConfirmed(" reset ")).toBe(true);
});
it("rejects anything else", () => {
expect(isResetConfirmed("")).toBe(false);
expect(isResetConfirmed("rese")).toBe(false);
expect(isResetConfirmed("reset all")).toBe(false);
});
});
describe("clearVoicedLocalState", () => {
// The node test environment has no localStorage, so stub a minimal one.
beforeEach(() => {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
get length() {
return data.size;
},
key: (i: number) => [...data.keys()][i] ?? null,
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => void data.set(k, v),
removeItem: (k: string) => void data.delete(k),
});
});
afterEach(() => vi.unstubAllGlobals());
it("removes only the app's own keys", () => {
localStorage.setItem("voiced.theme", "g100");
localStorage.setItem("voiced.settingsTab", "data");
localStorage.setItem("other.key", "keep");
clearVoicedLocalState();
expect(localStorage.getItem("voiced.theme")).toBeNull();
expect(localStorage.getItem("voiced.settingsTab")).toBeNull();
expect(localStorage.getItem("other.key")).toBe("keep");
});
});
+18
View File
@@ -0,0 +1,18 @@
/** The word typed to enable "Reset all data". */
export const RESET_WORD = "RESET";
export const isResetConfirmed = (typed: string): boolean => typed.trim().toUpperCase() === RESET_WORD;
/** Removes every `voiced.*` key this app keeps in the webview's localStorage (theme, last tab, export mode). */
export function clearVoicedLocalState(): void {
try {
const keys: string[] = [];
for (let i = 0; i < localStorage.length; i += 1) {
const key = localStorage.key(i);
if (key && key.startsWith("voiced.")) keys.push(key);
}
keys.forEach((key) => localStorage.removeItem(key));
} catch {
// Storage can be blocked; there is then nothing to clear.
}
}
+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}` }; 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 () => { await log.run("e2e: export flattened original", async () => {
const r = await exportWith("flattened", "original", flattenedPath); const r = await exportWith("flattened", "original", flattenedPath);
return { ok: r?.path === flattenedPath, detail: `wrote ${r?.path}` }; 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 () => { await log.run("e2e: archiving different bytes is refused", async () => {
const other = new TextEncoder().encode("%PDF-1.4\n% a different file\n"); const other = new TextEncoder().encode("%PDF-1.4\n% a different file\n");
const before = await api.readArchive(inv.id); 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 TaxType = "none" | "cgst_sgst" | "igst";
export type LineMode = "fixed" | "rate"; export type LineMode = "fixed" | "rate";
export type LineUnit = "second" | "minute" | "hour" | "session" | "unit"; /** A built-in unit id (see lib/units.ts) or, for a custom unit, the text the vendor typed. */
export type LineUnit = string;
export interface Settings { export interface Settings {
vendorName: string; vendorName: string;
@@ -302,10 +303,3 @@ export const INDIAN_STATES: { code: string; name: string }[] = [
{ code: "97", name: "Other Territory" }, { code: "97", name: "Other Territory" },
]; ];
export const UNIT_LABELS: Record<LineUnit, string> = {
second: "per second",
minute: "per minute",
hour: "per hour",
session: "per session",
unit: "per unit",
};
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import {
UNITS,
formatQuantity,
normalizeUnit,
parseQuantity,
quantityText,
ratePerText,
unitShort,
validateCustomUnit,
} from "./units";
const value = (text: string, unit: string) => {
const r = parseQuantity(text, unit);
if (!r.ok) throw new Error(r.error);
return r.value;
};
describe("parseQuantity", () => {
it("reads h:mm as hours for hour units", () => {
expect(value("4:30", "hour")).toBe(4.5);
expect(value("0:15", "hour")).toBe(0.25);
expect(value("4:20", "hour")).toBeCloseTo(4 + 1 / 3, 12);
expect(value("1:00:30", "hour")).toBeCloseTo(1 + 30 / 3600, 12);
});
it("reads m:ss as minutes for minute units", () => {
expect(value("4:30", "minute")).toBe(4.5);
expect(parseQuantity("1:00:30", "minute").ok).toBe(false);
});
it("refuses a clock quantity for other units and out-of-range parts", () => {
expect(parseQuantity("4:30", "km").ok).toBe(false);
expect(parseQuantity("4:75", "hour").ok).toBe(false);
expect(parseQuantity("4:", "hour").ok).toBe(false);
});
it("reads plain decimals for every unit", () => {
expect(value("12.75", "km")).toBe(12.75);
expect(value("120.5", "sqft")).toBe(120.5);
expect(value("3", "unit")).toBe(3);
expect(value(".5", "hour")).toBe(0.5);
expect(value("2.", "kg")).toBe(2);
expect(value("", "hour")).toBe(0);
});
it("rejects text, negatives, more than 3 decimals and huge values", () => {
for (const bad of ["abc", "-1", "1,5", "1.2345", ".", "1e3", "99999999"]) {
expect(parseQuantity(bad, "hour").ok, bad).toBe(false);
}
});
});
describe("formatQuantity", () => {
it("trims zeros", () => {
expect(formatQuantity(2, "unit")).toBe("2");
expect(formatQuantity(1.5, "km")).toBe("1.5");
expect(formatQuantity(12.75, "km")).toBe("12.75");
expect(formatQuantity(0.125, "kg")).toBe("0.125");
});
it("writes a repeating time as h:mm", () => {
expect(formatQuantity(value("4:20", "hour"), "hour")).toBe("4:20");
expect(formatQuantity(value("0:20", "hour"), "hour")).toBe("0:20");
expect(formatQuantity(value("1:00:30", "hour"), "hour")).toBe("1:00:30");
expect(formatQuantity(value("4:30", "hour"), "hour")).toBe("4.5");
});
it("round-trips whatever parseQuantity accepts", () => {
for (const [text, unit] of [["4:20", "hour"], ["7:45", "hour"], ["12.75", "km"], ["0:05", "hour"], ["3:10", "minute"]] as const) {
const v = value(text, unit);
expect(value(formatQuantity(v, unit), unit)).toBeCloseTo(v, 9);
}
});
});
describe("unit text", () => {
it("keeps the old rate wording for the original five units", () => {
expect(["second", "minute", "hour", "session", "unit"].map(ratePerText)).toEqual([
"per second",
"per minute",
"per hour",
"per session",
"per unit",
]);
});
it("shows the unit beside the quantity, but not for the generic unit", () => {
expect(quantityText(4.5, "hour")).toBe("4.5 hr");
expect(quantityText(12.75, "km")).toBe("12.75 km");
expect(quantityText(3, "unit")).toBe("3");
expect(quantityText(2, "bundle")).toBe("2 bundle");
expect(unitShort("sqft")).toBe("sq ft");
expect(ratePerText("bundle")).toBe("per bundle");
});
it("validates custom units", () => {
expect(validateCustomUnit("bundle")).toBeNull();
expect(validateCustomUnit("sq.ft/day")).toBeNull();
expect(validateCustomUnit("")).not.toBeNull();
expect(validateCustomUnit("a".repeat(13))).not.toBeNull();
expect(validateCustomUnit("₹")).not.toBeNull();
});
it("normalises stored values", () => {
expect(normalizeUnit("HOUR")).toBe("hour");
expect(normalizeUnit(" bundle ")).toBe("bundle");
expect(normalizeUnit("")).toBe("unit");
expect(normalizeUnit(undefined)).toBe("unit");
});
it("has unique ids and a short for every unit except the generic one", () => {
expect(new Set(UNITS.map((u) => u.id)).size).toBe(UNITS.length);
for (const u of UNITS) if (u.id !== "unit") expect(u.short).not.toBe("");
});
});
+162
View File
@@ -0,0 +1,162 @@
/**
* Units of measure for rate × quantity lines. The ids of the first five are stored in saved invoices
* and must never change. Any other text is a custom unit and is stored as typed (see `validateCustomUnit`).
* The Rust side keeps the same rules in src-tauri/src/units.rs.
*/
export type UnitGroup = "Count" | "Time" | "Distance" | "Area" | "Weight" | "Volume";
/** How `h:mm` style quantities are read for a unit: "4:30" is 4 h 30 min for hours, 4 min 30 s for minutes. */
export type ClockKind = "hours" | "minutes";
export interface UnitDef {
id: string;
/** Shown in pickers: "Hour". */
label: string;
/** Lower-case word used after "per": "per hour". */
noun: string;
/** Short form beside a quantity: "4.5 hr". Empty for the generic "unit", which stays a bare number. */
short: string;
group: UnitGroup;
clock?: ClockKind;
/** ERPNext UOM used when the integration has no mapping of its own for this unit. */
uom: string;
}
export const UNITS: readonly UnitDef[] = [
{ id: "unit", label: "Unit", noun: "unit", short: "", group: "Count", uom: "Nos" },
{ id: "piece", label: "Piece", noun: "piece", short: "pc", group: "Count", uom: "Nos" },
{ id: "set", label: "Set", noun: "set", short: "set", group: "Count", uom: "Set" },
{ id: "session", label: "Session", noun: "session", short: "session", group: "Time", uom: "Nos" },
{ id: "second", label: "Second", noun: "second", short: "sec", group: "Time", uom: "Second" },
{ id: "minute", label: "Minute", noun: "minute", short: "min", group: "Time", clock: "minutes", uom: "Minute" },
{ id: "hour", label: "Hour", noun: "hour", short: "hr", group: "Time", clock: "hours", uom: "Hour" },
{ id: "day", label: "Day", noun: "day", short: "day", group: "Time", uom: "Day" },
{ id: "week", label: "Week", noun: "week", short: "wk", group: "Time", uom: "Week" },
{ id: "month", label: "Month", noun: "month", short: "mo", group: "Time", uom: "Month" },
{ id: "km", label: "Kilometre", noun: "km", short: "km", group: "Distance", uom: "Kilometer" },
{ id: "m", label: "Metre", noun: "m", short: "m", group: "Distance", uom: "Meter" },
{ id: "sqft", label: "Square foot", noun: "sq ft", short: "sq ft", group: "Area", uom: "Square Foot" },
{ id: "sqm", label: "Square metre", noun: "sq m", short: "sq m", group: "Area", uom: "Square Meter" },
{ id: "kg", label: "Kilogram", noun: "kg", short: "kg", group: "Weight", uom: "Kg" },
{ id: "litre", label: "Litre", noun: "litre", short: "L", group: "Volume", uom: "Litre" },
];
export const UNIT_GROUPS: readonly UnitGroup[] = ["Count", "Time", "Distance", "Area", "Weight", "Volume"];
const BY_ID = new Map(UNITS.map((u) => [u.id, u]));
export const DEFAULT_UNIT = "unit";
export const MAX_CUSTOM_UNIT_LENGTH = 12;
/** The built-in unit with this id, or undefined for a custom unit. */
export const knownUnit = (unit: string): UnitDef | undefined => BY_ID.get(unit.trim().toLowerCase());
export const isKnownUnit = (unit: string): boolean => BY_ID.has(unit.trim().toLowerCase());
/** Problem with a custom unit text, or null when it is fine. */
export function validateCustomUnit(text: string): string | null {
const t = text.trim();
if (!t) return "Enter a unit";
if (t.length > MAX_CUSTOM_UNIT_LENGTH) return `Use at most ${MAX_CUSTOM_UNIT_LENGTH} characters`;
if (!/^[A-Za-z0-9][A-Za-z0-9 ./-]*$/.test(t)) return "Use letters, digits, spaces, . / or -";
return null;
}
/** Whether a stored unit can be issued: a built-in id or a valid custom text. */
export const isValidUnit = (unit: string): boolean => isKnownUnit(unit) || validateCustomUnit(unit) === null;
/** A unit value as stored: the id for a built-in unit, the trimmed text otherwise. Falls back to the default. */
export function normalizeUnit(unit: string | null | undefined): string {
const t = (unit ?? "").trim();
if (!t) return DEFAULT_UNIT;
const lower = t.toLowerCase();
if (BY_ID.has(lower)) return lower;
return t;
}
export const unitLabel = (unit: string): string => knownUnit(unit)?.label ?? unit.trim();
/** "hr" for hours; the text itself for a custom unit; "" for the generic unit. */
export const unitShort = (unit: string): string => {
const def = knownUnit(unit);
return def ? def.short : unit.trim();
};
/** "per hour", "per sq ft", "per bundle". */
export const ratePerText = (unit: string): string => `per ${knownUnit(unit)?.noun ?? unit.trim()}`;
/** The ERPNext UOM that suits a built-in unit; undefined for a custom unit. */
export const defaultUom = (unit: string): string | undefined => knownUnit(unit)?.uom;
export type QuantityParse = { ok: true; value: number } | { ok: false; error: string };
const MAX_QUANTITY = 10_000_000;
/**
* Reads what the user typed into a quantity box. Plain decimals (up to 3 places) work for every unit.
* Hour units also take `h:mm` and `h:mm:ss`, minute units take `m:ss`, so "4:30" hours is 4.5.
* An empty box is 0, the same as before this existed.
*/
export function parseQuantity(text: string, unit: string): QuantityParse {
const t = text.trim();
if (!t) return { ok: true, value: 0 };
const clock = knownUnit(unit)?.clock;
if (t.includes(":")) {
if (!clock) return { ok: false, error: "Only time units take h:mm" };
const m = /^(\d{1,7}):(\d{1,2})(?::(\d{1,2}))?$/.exec(t);
if (!m) return { ok: false, error: clock === "hours" ? "Use h:mm, for example 4:30" : "Use m:ss, for example 4:30" };
const first = Number(m[1]);
const second = Number(m[2]);
const third = m[3] === undefined ? null : Number(m[3]);
if (second > 59 || (third !== null && third > 59)) return { ok: false, error: "Minutes and seconds go up to 59" };
if (clock === "minutes" && third !== null) return { ok: false, error: "Use m:ss, for example 4:30" };
const value = clock === "hours" ? first + second / 60 + (third ?? 0) / 3600 : first + second / 60;
return value > MAX_QUANTITY ? { ok: false, error: "That quantity is too large" } : { ok: true, value };
}
const m = /^(\d{0,8})(?:\.(\d{0,3}))?$/.exec(t);
if (!m || (m[1] === "" && !m[2])) {
return { ok: false, error: /^\d*\.\d{4,}$/.test(t) ? "Use at most 3 decimal places" : "Enter a number" };
}
const value = Number(t);
return value > MAX_QUANTITY ? { ok: false, error: "That quantity is too large" } : { ok: true, value };
}
const EPS = 1e-9;
function trimmed(value: number, places: number): string {
return value.toFixed(places).replace(/\.?0+$/, "");
}
/**
* A quantity as text, without the unit. Exact for anything `parseQuantity` accepts: a time that is not a
* whole number of hundredths (4 h 20 min) is written as h:mm so that quantity × rate can be checked by eye.
*/
export function formatQuantity(value: number, unit: string): string {
if (!Number.isFinite(value)) return "0";
const clock = knownUnit(unit)?.clock;
if (!clock) return trimmed(value, 3);
if (Math.abs(value * 100 - Math.round(value * 100)) < EPS * 100) return trimmed(value, 2);
const whole = Math.floor(value + EPS);
const rest = (value - whole) * 60;
const min = Math.floor(rest + EPS);
const sec = Math.round((rest - min) * 60);
const head = clock === "hours" ? `${whole}:${String(min).padStart(2, "0")}` : null;
if (clock === "hours" && sec < 60 && Math.abs(whole + min / 60 + sec / 3600 - value) < EPS) {
return sec === 0 ? head! : `${head}:${String(sec).padStart(2, "0")}`;
}
if (clock === "minutes") {
const s = Math.round((value - whole) * 60);
if (s < 60 && Math.abs(whole + s / 60 - value) < EPS) return `${whole}:${String(s).padStart(2, "0")}`;
}
return trimmed(value, 4);
}
/** The quantity as printed on an invoice: "4:20 hr", "12.75 km", or a bare "3" for the generic unit. */
export function quantityText(value: number, unit: string): string {
const q = formatQuantity(value, unit);
const short = unitShort(unit);
return short ? `${q} ${short}` : q;
}
+4 -1
View File
@@ -1,12 +1,15 @@
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary";
import "./styles/carbon.scss"; import "./styles/carbon.scss";
import { maybeRunSelfTest } from "./selftestBoot"; import { maybeRunSelfTest } from "./selftestBoot";
createRoot(document.getElementById("root") as HTMLElement).render( createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<App /> <ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );
+17
View File
@@ -181,6 +181,23 @@ export function getMeasurer(): Measurer {
/** The text as it will be drawn: caps applied. */ /** The text as it will be drawn: caps applied. */
export const drawnText = (token: TypeToken, text: string) => (token.caps ? text.toUpperCase() : text); export const drawnText = (token: TypeToken, text: string) => (token.caps ? text.toUpperCase() : text);
/** A quantity with its unit ("1250.5 sq ft") is allowed this much width before the cell wraps at its spaces. */
export const QTY_WRAP_CAP = 72;
/**
* The texts a quantity column is sized from. A quantity cell can wrap between its words ("120 / wooden crate")
* but never inside one, so a text wider than `cap` counts as its widest word; the column then stays modest
* and the description keeps its room, instead of one long custom unit squeezing the table.
*/
export function wrapAwareTexts(texts: string[], role: RoleName | TypeToken, cap = QTY_WRAP_CAP): string[] {
const m = getMeasurer();
return texts.map((text) => {
if (measureToken(m, role, text) <= cap) return text;
const words = text.split(/\s+/).filter(Boolean);
return words.reduce((widest, w) => (measureToken(m, role, w) > measureToken(m, role, widest) ? w : widest), words[0] ?? text);
});
}
/** Width of `text` set in a role/token at an optional size override, tracking and caps included. */ /** Width of `text` set in a role/token at an optional size override, tracking and caps included. */
export function measureToken( export function measureToken(
m: Measurer, m: Measurer,
+16 -1
View File
@@ -133,7 +133,10 @@ describe("unregistered invoice", () => {
descriptionLines: ["Voice-over", "session A"], descriptionLines: ["Voice-over", "session A"],
hsnSac: "998397", hsnSac: "998397",
rateText: "₹1,200.00 per hour", rateText: "₹1,200.00 per hour",
qtyText: "2.5", priceText: "₹1,200.00",
perText: "per hour",
rateShort: "₹1,200.00/hr",
qtyText: "2.5 hr",
amountText: "₹3,000.00", amountText: "₹3,000.00",
}); });
expect(m.items[1]).toEqual({ expect(m.items[1]).toEqual({
@@ -141,10 +144,22 @@ describe("unregistered invoice", () => {
descriptionLines: ["—"], descriptionLines: ["—"],
hsnSac: null, hsnSac: null,
rateText: null, rateText: null,
priceText: null,
perText: null,
rateShort: null,
qtyText: "1", qtyText: "1",
amountText: "₹2,500.50", amountText: "₹2,500.50",
}); });
}); });
it("words the unit and the quantity for every kind of unit", () => {
const line = (unit: string, quantity: number, rate = 18.5) =>
make({ items: [{ ...base.items[0], mode: "rate", unit, quantity, rate }] }).items[0];
expect(line("km", 12.75)).toMatchObject({ qtyText: "12.75 km", rateText: "₹18.50 per km", rateShort: "₹18.50/km", perText: "per km" });
expect(line("sqft", 1250.5)).toMatchObject({ qtyText: "1250.5 sq ft", rateShort: "₹18.50/sq ft" });
expect(line("hour", 4 + 1 / 3)).toMatchObject({ qtyText: "4:20 hr" });
expect(line("unit", 3)).toMatchObject({ qtyText: "3", rateText: "₹18.50 per unit", rateShort: "₹18.50/unit" });
expect(line(" bundle ", 2)).toMatchObject({ qtyText: "2 bundle", rateText: "₹18.50 per bundle", rateShort: "₹18.50/bundle" });
});
it("labels the bank lines and defaults the signature to the vendor name", () => { it("labels the bank lines and defaults the signature to the vendor name", () => {
expect(m.bank).toEqual({ expect(m.bank).toEqual({
accountName: "Test Vendor", accountName: "Test Vendor",
+20 -3
View File
@@ -1,7 +1,7 @@
import { computeLineAmount } from "../../lib/invoice"; import { computeLineAmount } from "../../lib/invoice";
import { quantityText, ratePerText, unitShort, knownUnit } from "../../lib/units";
import { import {
INDIAN_STATES, INDIAN_STATES,
UNIT_LABELS,
type BankAccount, type BankAccount,
type Invoice, type Invoice,
type Settings, type Settings,
@@ -48,7 +48,15 @@ export interface RenderItem {
sno: number; sno: number;
descriptionLines: string[]; descriptionLines: string[];
hsnSac: string | null; hsnSac: string | null;
/** "₹1,200.00 per hour"; null for a fixed-amount line. */
rateText: string | null; rateText: string | null;
/** The amount alone: "₹1,200.00". */
priceText: string | null;
/** The unit wording alone: "per hour". */
perText: string | null;
/** The one-line form: "₹1,200.00/hr". */
rateShort: string | null;
/** "4.5 hr", "12.75 km", or a bare "3" for the generic unit; "1" for a fixed-amount line. */
qtyText: string; qtyText: string;
amountText: string; amountText: string;
} }
@@ -140,6 +148,15 @@ function taxRowsFor(invoice: Invoice, supplierStateCode: string): TaxRow[] {
} }
/** Pure: turns the stored invoice into pre-formatted, sanitised data. Totals are the stored values. */ /** Pure: turns the stored invoice into pre-formatted, sanitised data. Totals are the stored values. */
function rateFields(item: Invoice["items"][number]): Pick<RenderItem, "rateText" | "priceText" | "perText" | "rateShort"> {
if (item.mode !== "rate") return { rateText: null, priceText: null, perText: null, rateShort: null };
const priceText = formatRupees(item.rate);
const perText = ratePerText(item.unit);
// The generic "unit" has no short form beside a quantity, but its rate still reads "/unit".
const suffix = unitShort(item.unit) || knownUnit(item.unit)?.noun || item.unit.trim();
return { rateText: `${priceText} ${perText}`, priceText, perText, rateShort: `${priceText}/${suffix}` };
}
export function buildRenderModel(input: BuildInput): RenderModel { export function buildRenderModel(input: BuildInput): RenderModel {
const { invoice, logo, signatureDataUri, appVersion } = input; const { invoice, logo, signatureDataUri, appVersion } = input;
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, input.vendor); const vendor = vendorFromSnapshot(invoice.vendorSnapshot, input.vendor);
@@ -164,8 +181,8 @@ export function buildRenderModel(input: BuildInput): RenderModel {
return lines.length ? lines : ["—"]; return lines.length ? lines : ["—"];
})(), })(),
hsnSac: orNull(item.hsnSac), hsnSac: orNull(item.hsnSac),
rateText: item.mode === "rate" ? `${formatRupees(item.rate)} ${UNIT_LABELS[item.unit]}` : null, ...rateFields(item),
qtyText: item.mode === "rate" ? String(item.quantity) : "1", qtyText: item.mode === "rate" ? quantityText(item.quantity, item.unit) : "1",
amountText: formatRupees(computeLineAmount(item)), amountText: formatRupees(computeLineAmount(item)),
})); }));
+5 -5
View File
@@ -1,6 +1,6 @@
import { type ColSpec } from "../../engine/columns"; import { type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure"; import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight } from "../../engine/textplan"; import { textHeight } from "../../engine/textplan";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots"; import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
@@ -148,17 +148,17 @@ export function planBand(model: RenderModel, frame: PageFrame, v: BandVariant, l
// --- table: description | qty | price | total ------------------------------------------------------------ // --- table: description | qty | price | total ------------------------------------------------------------
const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t))); const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
const colW = (base: number, texts: string[], headText: string) => Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, [headText]))) + 4 + 1); const colW = (base: number, texts: string[], headText: string) => Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, [headText]))) + 4 + 1);
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—"); const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
const cw = { const cw = {
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "QTY"), qty: colW(v.cols.qty, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "QTY"),
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "PRICE"), rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "PRICE"),
amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "TOTAL"), amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "TOTAL"),
}; };
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }]; const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }];
const headerCells = ["SERVICE", "QTY", "PRICE", "TOTAL"]; const headerCells = ["SERVICE", "QTY", "PRICE", "TOTAL"];
const align: BandPlan["align"] = ["left", "center", "right", "right"]; const align: BandPlan["align"] = ["left", "center", "right", "right"];
const rows: TableCellValue[][] = model.items.map((item, i) => { const rows: TableCellValue[][] = model.items.map((item, i) => {
const rate = rateCell(item.rateText, cw.rate - 4, r.tableBody); const rate = rateCell(item, cw.rate - 4, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]); const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines; const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
return [desc, item.qtyText, rateParts, item.amountText]; return [desc, item.qtyText, rateParts, item.amountText];
+15 -2
View File
@@ -1,6 +1,6 @@
import { cols, type ColSpec } from "../../engine/columns"; import { cols, type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; import type { PageFrame } from "../../engine/geometry";
import { getMeasurer } from "../../engine/measure"; import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight } from "../../engine/textplan"; import { textHeight } from "../../engine/textplan";
import { roleToken } from "../../fonts/roles"; import { roleToken } from "../../fonts/roles";
import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
@@ -82,6 +82,9 @@ export interface ClassicPlan {
closingH: number; closingH: number;
} }
const QTY_MIN = 40;
const QTY_MAX = 84;
const leading = (role: Parameters<typeof roleToken>[0]) => roleToken(role).leading as number; const leading = (role: Parameters<typeof roleToken>[0]) => roleToken(role).leading as number;
/** /**
@@ -92,12 +95,22 @@ const leading = (role: Parameters<typeof roleToken>[0]) => roleToken(role).leadi
export function planClassic(model: RenderModel, frame: PageFrame): ClassicPlan { export function planClassic(model: RenderModel, frame: PageFrame): ClassicPlan {
const gaps = elasticGaps(frame); const gaps = elasticGaps(frame);
const hsn = Slot.HsnSac(model); const hsn = Slot.HsnSac(model);
// The quantity column grows to hold "12.75 km" on one line, but the description keeps its 160 pt: a text wider than
// that room wraps at its spaces.
const fixedBesideQty = 28 + (hsn ? 62 : 0) + 92 + 88 + TABLE_GAP * (hsn ? 5 : 4);
const qtyRoom = Math.max(QTY_MIN, Math.min(QTY_MAX, Math.floor(frame.contentWidth - fixedBesideQty - 160)));
const qtyNeed = Math.ceil(
Math.max(
measureToken(getMeasurer(), "tableHead", "Qty"),
...wrapAwareTexts(model.items.map((i) => i.qtyText), "tableBody", qtyRoom).map((t) => measureToken(getMeasurer(), "tableBody", t)),
),
);
const columns: ColSpec[] = [ const columns: ColSpec[] = [
{ fixed: 28 }, { fixed: 28 },
{ fill: true, min: 160 }, { fill: true, min: 160 },
...(hsn ? [{ fixed: 62 } as ColSpec] : []), ...(hsn ? [{ fixed: 62 } as ColSpec] : []),
{ fixed: 92 }, { fixed: 92 },
{ fixed: 40 }, { fixed: Math.min(qtyRoom, Math.max(QTY_MIN, qtyNeed + 2)) },
{ fixed: 88 }, { fixed: 88 },
]; ];
const header = ["S.No", "Description", ...(hsn ? [hsn.header] : []), "Unit price", "Qty", "Amount"]; const header = ["S.No", "Description", ...(hsn ? [hsn.header] : []), "Unit price", "Qty", "Amount"];
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns"; import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure"; import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight } from "../../engine/textplan"; import { textHeight } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText"; import { fitSize } from "../../blocks/FitText";
import { innerRulePadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; import { innerRulePadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
@@ -19,7 +19,7 @@ const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5";
const MIN_ADDRESS_W = 120; const MIN_ADDRESS_W = 120;
/** Space between a mark and the tagline under a wordmark logo. */ /** Space between a mark and the tagline under a wordmark logo. */
const WORDMARK_TAG_GAP = 3; const WORDMARK_TAG_GAP = 3;
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—"); const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
/** The totals' labels read "Sub Total :", "CGST 9% :" like the design's "Tax 15% :". */ /** The totals' labels read "Sub Total :", "CGST 9% :" like the design's "Tax 15% :". */
export const labelWithColon = (label: string, rate?: string) => (rate ? `${label} ${rate} :` : `${label} :`); export const labelWithColon = (label: string, rate?: string) => (rate ? `${label} ${rate} :` : `${label} :`);
@@ -65,7 +65,7 @@ export function tableRows(model: RenderModel, v: CobaltVariant, rateW: number):
const r = v.roles; const r = v.roles;
const hsn = Slot.HsnSac(model); const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => { return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, r.tableBody); const rate = rateCell(item, rateW, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]); const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines; const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
return [String(i + 1), desc, item.qtyText, rateParts, item.amountText]; return [String(i + 1), desc, item.qtyText, rateParts, item.amountText];
@@ -245,10 +245,10 @@ export function planCobalt(model: RenderModel, frame: PageFrame, v: CobaltVarian
const t = v.table; const t = v.table;
const rule = t.separator; const rule = t.separator;
const widest = (role: TypeToken, texts: string[]) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, role, x)))); const widest = (role: TypeToken, texts: string[]) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, role, x))));
const priced = model.items.map((i) => priceOf(i.rateText)); const priced = model.items.map((i) => priceOf(i));
const amountTexts = model.items.map((i) => i.amountText); const amountTexts = model.items.map((i) => i.amountText);
const no = Math.max(t.cols.no, widest(r.tableBody, [String(model.items.length)]) + 12); const no = Math.max(t.cols.no, widest(r.tableBody, [String(model.items.length)]) + 12);
const qty = Math.max(t.cols.qty, widest(r.tableBody, model.items.map((i) => i.qtyText)) + 12 + rule); const qty = Math.max(t.cols.qty, widest(r.tableBody, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody)) + 12 + rule);
const priceNeed = Math.max(widest(r.tableBody, priced), widest(r.tableHead, ["Price"])) + 6 + rule + 6; const priceNeed = Math.max(widest(r.tableBody, priced), widest(r.tableHead, ["Price"])) + 6 + rule + 6;
const price = Math.max(t.cols.price, priceNeed); const price = Math.max(t.cols.price, priceNeed);
const priceInset = Math.max(6, Math.min(t.priceInset, price - rule - widest(r.tableBody, priced) - 4)); const priceInset = Math.max(6, Math.min(t.priceInset, price - rule - widest(r.tableBody, priced) - 4));
+13
View File
@@ -297,6 +297,19 @@ export const FIXTURES: FixtureSpec[] = [
{ description: "Audio editing and mastering of all deliverables, including noise reduction, levelling, and export to the three formats requested by the client in the brief of August", mode: "rate", rate: 400, unit: "minute", quantity: 25, amount: 0, sortOrder: 2, hsnSac: "998397" }, { description: "Audio editing and mastering of all deliverables, including noise reduction, levelling, and export to the three formats requested by the client in the brief of August", mode: "rate", rate: 400, unit: "minute", quantity: 25, amount: 0, sortOrder: 2, hsnSac: "998397" },
], ],
}, },
{
// Units of measure: a decimal distance, an area, a time that is not a whole number of hundredths (4 h 20 min),
// a 12-character custom unit and a month, so the quantity and rate columns see the longest texts they can.
name: "units",
items: [
{ description: "Travel to the client's studio", mode: "rate", rate: 18.5, unit: "km", quantity: 12.75, amount: 0, sortOrder: 0, hsnSac: "996601" },
{ description: "Acoustic treatment, floor area", mode: "rate", rate: 85, unit: "sqft", quantity: 1250.5, amount: 0, sortOrder: 1, hsnSac: "995411" },
{ description: "Evening studio session", mode: "rate", rate: 1500, unit: "hour", quantity: 4 + 1 / 3, amount: 0, sortOrder: 2, hsnSac: "998397" },
{ description: "Packed equipment cases", mode: "rate", rate: 99.5, unit: "wooden crate", quantity: 120, amount: 0, sortOrder: 3, hsnSac: "998397" },
{ description: "Retainer", mode: "rate", rate: 25000, unit: "month", quantity: 1, amount: 0, sortOrder: 4, hsnSac: "998397" },
{ description: "Setup fee", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 8000, sortOrder: 5, hsnSac: "997212" },
],
},
{ {
name: "long-notes", name: "long-notes",
items: 30, items: 30,
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns"; import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; import type { PageFrame } from "../../engine/geometry";
import { drawnText, getMeasurer, measureToken } from "../../engine/measure"; import { drawnText, getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight, textLines } from "../../engine/textplan"; import { textHeight, textLines } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText"; import { fitSize } from "../../blocks/FitText";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
@@ -37,7 +37,7 @@ export function tableRows(model: RenderModel, v: HighlighterVariant, rateW: numb
const r = v.roles; const r = v.roles;
const hsn = Slot.HsnSac(model); const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => { return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, r.tableBody); const rate = rateCell(item, rateW, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]); const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines; const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
return [desc, item.qtyText, rateParts, item.amountText]; return [desc, item.qtyText, rateParts, item.amountText];
@@ -158,14 +158,14 @@ export function planHighlighter(model: RenderModel, frame: PageFrame, v: Highlig
// --- table ----------------------------------------------------------------------------------------------------------------- // --- table -----------------------------------------------------------------------------------------------------------------
const t = v.table; const t = v.table;
const innerW = W - 2 * fr; const innerW = W - 2 * fr;
const rateOnly = model.items.map((i) => (i.rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(i.rateText)?.[1] ?? i.rateText) : "—")); const rateOnly = model.items.map((i) => (i.priceText ?? "—"));
const need = (texts: string[], head: string, pad: number) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head))) + pad + v.hair + 6; const need = (texts: string[], head: string, pad: number) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head))) + pad + v.hair + 6;
const shortRate = model.items.map((i) => { const shortRate = model.items.map((i) => {
const cell = rateCell(i.rateText, 10000, r.tableBody); const cell = rateCell(i, 10000, r.tableBody);
return typeof cell === "string" ? cell : "—"; return typeof cell === "string" ? cell : "—";
}); });
const cw = { const cw = {
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0)), qty: Math.max(t.cols.qty, need(wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", 0)),
rate: Math.max(t.cols.rate, need(rateOnly, "Price", t.numPad), Math.min(t.cols.rate + 24, need(shortRate, "Price", t.numPad))), rate: Math.max(t.cols.rate, need(rateOnly, "Price", t.numPad), Math.min(t.cols.rate + 24, need(shortRate, "Price", t.numPad))),
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad) - v.hair), amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad) - v.hair),
}; };
+5 -5
View File
@@ -1,6 +1,6 @@
import { colsDetailed, type ColSpec } from "../../engine/columns"; import { colsDetailed, type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure"; import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight, textLines } from "../../engine/textplan"; import { textHeight, textLines } from "../../engine/textplan";
import { gridPadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; import { gridPadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots"; import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
@@ -140,13 +140,13 @@ export function planLedger(model: RenderModel, frame: PageFrame, v: LedgerVarian
const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t))); const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
const colW = (base: number, texts: string[], headText: string, idx: number) => const colW = (base: number, texts: string[], headText: string, idx: number) =>
Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, headText.split(" ")))) + 2 * v.padX + ruleCols(idx) + 1); Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, headText.split(" ")))) + 2 * v.padX + ruleCols(idx) + 1);
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—"); const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
const nCols = hsnCol ? 5 : 4; const nCols = hsnCol ? 5 : 4;
const idx = (base: number) => (hsnCol ? base + 1 : base); const idx = (base: number) => (hsnCol ? base + 1 : base);
const c0 = { const c0 = {
hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnCol.header, 1) : 0, hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnCol.header, 1) : 0,
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "Unit Price", idx(1)), rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "Unit Price", idx(1)),
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty", idx(2)), qty: colW(v.cols.qty, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", idx(2)),
amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "Amount", idx(3)), amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "Amount", idx(3)),
}; };
const columns: ColSpec[] = [{ fill: true }, ...(hsnCol ? [{ fixed: c0.hsn } as ColSpec] : []), { fixed: c0.rate }, { fixed: c0.qty }, { fixed: c0.amount }]; const columns: ColSpec[] = [{ fill: true }, ...(hsnCol ? [{ fixed: c0.hsn } as ColSpec] : []), { fixed: c0.rate }, { fixed: c0.qty }, { fixed: c0.amount }];
@@ -154,7 +154,7 @@ export function planLedger(model: RenderModel, frame: PageFrame, v: LedgerVarian
const align: LedgerPlan["align"] = ["left", ...(hsnCol ? (["left"] as const) : []), "right", "right", "right"]; const align: LedgerPlan["align"] = ["left", ...(hsnCol ? (["left"] as const) : []), "right", "right", "right"];
const rateInner = c0.rate - 2 * v.padX - ruleCols(idx(1)); const rateInner = c0.rate - 2 * v.padX - ruleCols(idx(1));
const tableRows: TableCellValue[][] = model.items.map((item, i) => { const tableRows: TableCellValue[][] = model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateInner, r.tableBody); const rate = rateCell(item, rateInner, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]); const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
return [item.descriptionLines, ...(hsnCol ? [hsnCol.values[i]] : []), rateParts, item.qtyText, item.amountText]; return [item.descriptionLines, ...(hsnCol ? [hsnCol.values[i]] : []), rateParts, item.qtyText, item.amountText];
}); });
+1 -1
View File
@@ -282,7 +282,7 @@ describe("table", () => {
expect(r0.h).toBeCloseTo(32, 0); expect(r0.h).toBeCloseTo(32, 0);
expect(r1.y - r0.y).toBeCloseTo(32, 0); expect(r1.y - r0.y).toBeCloseTo(32, 0);
expect(r0.y - (head.y + head.h)).toBeCloseTo(V.table.headGap, 0); expect(r0.y - (head.y + head.h)).toBeCloseTo(V.table.headGap, 0);
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1")!; const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1 hr")!;
expect((qty.x + qty.right) / 2).toBeCloseTo(355.5, 0); expect((qty.x + qty.right) / 2).toBeCloseTo(355.5, 0);
expect(r0.x).toBeCloseTo(31.5, 0); expect(r0.x).toBeCloseTo(31.5, 0);
}); });
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns"; import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure"; import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight } from "../../engine/textplan"; import { textHeight } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText"; import { fitSize } from "../../blocks/FitText";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
@@ -62,7 +62,7 @@ export function tableRows(model: RenderModel, v: MarbleVariant, rateW: number):
const r = v.roles; const r = v.roles;
const hsn = Slot.HsnSac(model); const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => { return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, r.tableBody); const rate = rateCell(item, rateW, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]); const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines; const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
return [desc, item.qtyText, rateParts, item.amountText]; return [desc, item.qtyText, rateParts, item.amountText];
@@ -165,14 +165,14 @@ export function planMarble(model: RenderModel, frame: PageFrame, v: MarbleVarian
const t = v.table; const t = v.table;
const need = (texts: string[], head: string, pad: number, headStep: number) => const need = (texts: string[], head: string, pad: number, headStep: number) =>
Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head) - headStep)) + pad + 6; Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head) - headStep)) + pad + 6;
const rateOnly = model.items.map((i) => (i.rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(i.rateText)?.[1] ?? i.rateText) : "—")); const rateOnly = model.items.map((i) => (i.priceText ?? "—"));
// The rate shows as "₹1,200.00/hr" when the column can take it (up to RATE_MAX wide), else as the rate over "per hour". // The rate shows as "₹1,200.00/hr" when the column can take it (up to RATE_MAX wide), else as the rate over "per hour".
const shortRate = model.items.map((i) => { const shortRate = model.items.map((i) => {
const cell = rateCell(i.rateText, 10000, r.tableBody); const cell = rateCell(i, 10000, r.tableBody);
return typeof cell === "string" ? cell : "—"; return typeof cell === "string" ? cell : "—";
}); });
const cw = { const cw = {
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0, 0)), qty: Math.max(t.cols.qty, need(wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", 0, 0)),
rate: Math.max(t.cols.rate, need(rateOnly, "Rate", t.ratePad, 0), Math.min(RATE_MAX, need(shortRate, "Rate", t.ratePad, 0))), rate: Math.max(t.cols.rate, need(rateOnly, "Rate", t.ratePad, 0), Math.min(RATE_MAX, need(shortRate, "Rate", t.ratePad, 0))),
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad, 0)), amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad, 0)),
}; };
+1 -1
View File
@@ -193,7 +193,7 @@ describe("table, totals and footer", () => {
expect(amount.text, `row ${i}`).toMatch(/^₹[\d,]+\.\d\d$/); expect(amount.text, `row ${i}`).toMatch(/^₹[\d,]+\.\d\d$/);
expect(amount.right, `row ${i} amount edge`).toBeCloseTo(right - V.table.numPad, 0); expect(amount.right, `row ${i} amount edge`).toBeCloseTo(right - V.table.numPad, 0);
} }
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1")!; const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1 hr")!;
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false); const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
const left = frameForPrefs(r.prefs).pad.left + plan.W - plan.cw.amount - plan.cw.qty; const left = frameForPrefs(r.prefs).pad.left + plan.W - plan.cw.amount - plan.cw.qty;
expect(qty.x + (qty.right - qty.x) / 2).toBeCloseTo(left + plan.cw.qty / 2, 0); expect(qty.x + (qty.right - qty.x) / 2).toBeCloseTo(left + plan.cw.qty / 2, 0);
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns"; import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure"; import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight } from "../../engine/textplan"; import { textHeight } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText"; import { fitSize } from "../../blocks/FitText";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
@@ -18,7 +18,7 @@ const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5";
export const REVERSE_GAP = 4; export const REVERSE_GAP = 4;
/** Space between the label of a words / notes block and its text. */ /** Space between the label of a words / notes block and its text. */
export const LABEL_GAP = 4; export const LABEL_GAP = 4;
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—"); const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
/** "Tax" rows read "CGST @ 9%" like the design's "Tax". */ /** "Tax" rows read "CGST @ 9%" like the design's "Tax". */
export const atRate = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate}` : label); export const atRate = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate}` : label);
@@ -58,7 +58,7 @@ export function tableRows(model: RenderModel, v: MonolithVariant, priceW: number
const r = v.roles; const r = v.roles;
const hsn = Slot.HsnSac(model); const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => { return model.items.map((item, i) => {
const rate = rateCell(item.rateText, priceW, r.tableBody); const rate = rateCell(item, priceW, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]); const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines; const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
return [desc, rateParts, item.qtyText, item.amountText]; return [desc, rateParts, item.qtyText, item.amountText];
@@ -165,11 +165,11 @@ export function planMonolith(model: RenderModel, frame: PageFrame, v: MonolithVa
// --- table --------------------------------------------------------------------------------------------------------- // --- table ---------------------------------------------------------------------------------------------------------
const t = v.table; const t = v.table;
const priced = model.items.map((i) => priceOf(i.rateText)); const priced = model.items.map((i) => priceOf(i));
const need = (texts: string[], head: string, pad: number) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head))) + pad + 6; const need = (texts: string[], head: string, pad: number) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head))) + pad + 6;
const cw = { const cw = {
price: Math.max(t.cols.price, need(priced, "Price", t.numPad)), price: Math.max(t.cols.price, need(priced, "Price", t.numPad)),
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0)), qty: Math.max(t.cols.qty, need(wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", 0)),
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad)), amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad)),
}; };
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.price }, { fixed: cw.qty }, { fixed: cw.amount }]; const columns: ColSpec[] = [{ fill: true }, { fixed: cw.price }, { fixed: cw.qty }, { fixed: cw.amount }];
+6 -3
View File
@@ -131,9 +131,12 @@ describe("helpers", () => {
it("rateCell keeps '₹1,200.00/hr' on one line when it fits and splits the unit otherwise", () => { it("rateCell keeps '₹1,200.00/hr' on one line when it fits and splits the unit otherwise", () => {
const body = linea.roles.tableBody; const body = linea.roles.tableBody;
expect(rateCell("₹1,200.00 per hour", 90, body)).toBe("₹1,200.00/hr"); const rate = (price: string, per: string, short: string) => ({ priceText: price, perText: per, rateShort: `${price}/${short}` });
expect(rateCell("₹1,200.00 per session", 60, body)).toEqual(["₹1,200.00", "per session"]); expect(rateCell(rate("₹1,200.00", "per hour", "hr"), 90, body)).toBe("₹1,200.00/hr");
expect(rateCell(null, 90, body)).toBe("—"); expect(rateCell(rate("₹1,200.00", "per session", "session"), 60, body)).toEqual(["₹1,200.00", "per session"]);
expect(rateCell(rate("₹18.50", "per sq ft", "sq ft"), 200, body)).toBe("₹18.50/sq ft");
expect(rateCell(rate("₹18.50", "per bundle", "bundle"), 20, body)).toEqual(["₹18.50", "per bundle"]);
expect(rateCell({ priceText: null, perText: null, rateShort: null }, 90, body)).toBe("—");
expect(getMeasurer()).toBeTruthy(); expect(getMeasurer()).toBeTruthy();
}); });
}); });
+13 -14
View File
@@ -1,11 +1,11 @@
import { cols, type ColSpec } from "../../engine/columns"; import { cols, type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure"; import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight, textLines } from "../../engine/textplan"; import { textHeight, textLines } from "../../engine/textplan";
import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable"; import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots"; import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
import { guardPresence } from "../../blocks/TableGuard"; import { guardPresence } from "../../blocks/TableGuard";
import type { RenderModel } from "../../model/build"; import type { RenderItem, RenderModel } from "../../model/build";
import type { PlacedLogo } from "../contract"; import type { PlacedLogo } from "../contract";
import type { GapRange, NeutralVariant } from "./variant"; import type { GapRange, NeutralVariant } from "./variant";
@@ -103,18 +103,17 @@ export function metaRows(model: RenderModel): MetaRowSpec[] {
return rows; return rows;
} }
const SHORT_UNIT: Record<string, string> = { second: "/sec", minute: "/min", hour: "/hr", session: "/session", unit: "/unit" };
/** /**
* The rate cell: "₹1,200.00/hr" on one line when it fits the column, else the amount with its unit * The rate cell: "₹1,200.00/hr" on one line when it fits the column, else the amount with its unit
* ("per hour") on a second line, which keeps one-line rows one line tall. * ("per hour") on a second line, which keeps one-line rows one line tall.
*/ */
export function rateCell(rateText: string | null, width: number, role: NeutralVariant["roles"]["tableBody"]): TableCellValue { export function rateCell(
if (!rateText) return "—"; item: Pick<RenderItem, "priceText" | "perText" | "rateShort">,
const m = /^(.*) per (second|minute|hour|session|unit)$/.exec(rateText); width: number,
if (!m) return rateText; role: NeutralVariant["roles"]["tableBody"],
const short = `${m[1]}${SHORT_UNIT[m[2]]}`; ): TableCellValue {
return measureToken(getMeasurer(), role, short) <= width ? short : [m[1], `per ${m[2]}`]; if (!item.priceText || !item.perText || !item.rateShort) return "—";
return measureToken(getMeasurer(), role, item.rateShort) <= width ? item.rateShort : [item.priceText, item.perText];
} }
export interface NeutralPlan { export interface NeutralPlan {
@@ -224,12 +223,12 @@ export function planNeutral(model: RenderModel, frame: PageFrame, v: NeutralVari
const widest = (role: NeutralVariant["roles"]["tableBody"], texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t))); const widest = (role: NeutralVariant["roles"]["tableBody"], texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
const colW = (base: number, texts: string[], headText: string) => const colW = (base: number, texts: string[], headText: string) =>
Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, headText.split(" ")))) + 2 * v.padX + 1); Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, headText.split(" ")))) + 2 * v.padX + 1);
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—"); const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
const hsnHead = hsnCol?.header ?? ""; const hsnHead = hsnCol?.header ?? "";
const c = { const c = {
hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnHead) : 0, hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnHead) : 0,
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "Unit price"), rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "Unit price"),
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty"), qty: colW(v.cols.qty, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty"),
amount: colW(v.cols.amount, [...model.items.map((i) => i.amountText), "Total"], "Total"), amount: colW(v.cols.amount, [...model.items.map((i) => i.amountText), "Total"], "Total"),
}; };
const columns: ColSpec[] = [ const columns: ColSpec[] = [
@@ -246,7 +245,7 @@ export function planNeutral(model: RenderModel, frame: PageFrame, v: NeutralVari
const tableRows: TableCellValue[][] = model.items.map((item, i) => [ const tableRows: TableCellValue[][] = model.items.map((item, i) => [
hsn && !hsnCol && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.fine }] : item.descriptionLines, hsn && !hsnCol && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.fine }] : item.descriptionLines,
...(hsnCol ? [hsnCol.values[i]] : []), ...(hsnCol ? [hsnCol.values[i]] : []),
rateCell(item.rateText, rateW, r.tableBody), rateCell(item, rateW, r.tableBody),
item.qtyText, item.qtyText,
item.amountText, item.amountText,
]); ]);
+7 -7
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns"; import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry"; import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure"; import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight, textLines } from "../../engine/textplan"; import { textHeight, textLines } from "../../engine/textplan";
import { DOME_BOX } from "../../decor/Asterisk"; import { DOME_BOX } from "../../decor/Asterisk";
import { fitSize } from "../../blocks/FitText"; import { fitSize } from "../../blocks/FitText";
@@ -68,13 +68,13 @@ export function idLines(role: TypeToken, text: string, width: number): number {
/** Plain shrink-wrapped width of a one-line text. */ /** Plain shrink-wrapped width of a one-line text. */
export const widthOf = (role: TypeToken, text: string, size?: number) => Math.ceil(measureToken(getMeasurer(), role, text, size ? { size } : {})) + 1; export const widthOf = (role: TypeToken, text: string, size?: number) => Math.ceil(measureToken(getMeasurer(), role, text, size ? { size } : {})) + 1;
export const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—"); export const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
/** The table's description + HSN/SAC sub-line, qty, rate (amount over its unit) and amount cells. */ /** The table's description + HSN/SAC sub-line, qty, rate (amount over its unit) and amount cells. */
export function tableRows(model: RenderModel, rateW: number, body: TypeToken, sub: TypeToken): TableCellValue[][] { export function tableRows(model: RenderModel, rateW: number, body: TypeToken, sub: TypeToken): TableCellValue[][] {
const hsn = Slot.HsnSac(model); const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => { return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, body); const rate = rateCell(item, rateW, body);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: sub }] as CellPart[]); const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: sub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: sub }] : item.descriptionLines; const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: sub }] : item.descriptionLines;
return [desc, item.qtyText, rateParts, item.amountText]; return [desc, item.qtyText, rateParts, item.amountText];
@@ -193,12 +193,12 @@ export function planPurple(model: RenderModel, frame: PageFrame, v: PurpleVarian
const align: PurplePlan["align"] = ["left", "center", "right", "right"]; const align: PurplePlan["align"] = ["left", "center", "right", "right"];
const padY = (t.rowH - leading(r.tableBody)) / 2; const padY = (t.rowH - leading(r.tableBody)) / 2;
const headPadY = (t.headH - leading(r.tableHead)) / 2; const headPadY = (t.headH - leading(r.tableHead)) / 2;
const priced = model.items.map((i) => priceOf(i.rateText)); const priced = model.items.map((i) => priceOf(i));
const build = (left: number, base: { qty: number; rate: number; amount: number }) => { const build = (left: number, base: { qty: number; rate: number; amount: number }) => {
const tableW = round2(W - left - t.right); const tableW = round2(W - left - t.right);
const inner = tableW - 2 * t.inset; const inner = tableW - 2 * t.inset;
const cw = { const cw = {
qty: numericColW(base.qty, r.tableBody, r.tableHead, model.items.map((i) => i.qtyText), "QTY"), qty: numericColW(base.qty, r.tableBody, r.tableHead, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "QTY"),
rate: numericColW(base.rate, r.tableBody, r.tableHead, priced, "RATE"), rate: numericColW(base.rate, r.tableBody, r.tableHead, priced, "RATE"),
amount: numericColW(base.amount, r.tableBody, r.tableHead, model.items.map((i) => i.amountText), "AMOUNT"), amount: numericColW(base.amount, r.tableBody, r.tableHead, model.items.map((i) => i.amountText), "AMOUNT"),
}; };
@@ -424,10 +424,10 @@ export function planCitrus(model: RenderModel, frame: PageFrame, v: CitrusVarian
// --- table ---------------------------------------------------------------------------------------------------- // --- table ----------------------------------------------------------------------------------------------------
const t = v.table; const t = v.table;
const tableW = round2(W - t.left); const tableW = round2(W - t.left);
const priced = model.items.map((i) => priceOf(i.rateText)); const priced = model.items.map((i) => priceOf(i));
const amountNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.amountText)), measureToken(m, r.headNum, "AMOUNT"))) + 14 + 4; const amountNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.amountText)), measureToken(m, r.headNum, "AMOUNT"))) + 14 + 4;
const rateNeed = Math.ceil(Math.max(0, ...priced.map((p) => measureToken(m, r.tableBody, p)), measureToken(m, r.headNum, "RATE"))) + 14 + 4; const rateNeed = Math.ceil(Math.max(0, ...priced.map((p) => measureToken(m, r.tableBody, p)), measureToken(m, r.headNum, "RATE"))) + 14 + 4;
const qtyNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.qtyText)), measureToken(m, r.headNum, "QTY"))) + 6; const qtyNeed = Math.ceil(Math.max(0, ...wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody).map((t) => measureToken(m, r.tableBody, t)), measureToken(m, r.headNum, "QTY"))) + 6;
const amount = Math.max(t.cols.amount, amountNeed); const amount = Math.max(t.cols.amount, amountNeed);
const rate = Math.max(t.cols.rate, rateNeed); const rate = Math.max(t.cols.rate, rateNeed);
const qty = Math.max(Math.min(36, panelW), round2(panelW - rate - amount)); const qty = Math.max(Math.min(36, panelW), round2(panelW - rate - amount));
+10 -10
View File
@@ -1,26 +1,26 @@
{ {
"classic-a4-tax-3": { "classic-a4-tax-3": {
"pages": 1, "pages": 1,
"fingerprint": "87b12a5c1b1a9b45ac6a8fd5862c8e7b5de8d203731fe4e485872dea5bbb07cd", "fingerprint": "3bc384c321777db5a513f85f2b24f378523e3ddf5f3fadb08ae97e68198ed4e2",
"invoiceNumber": "ST/2026-001", "invoiceNumber": "ST/2026-001",
"totalText": "₹8,625.80", "totalText": "₹22,077.80",
"pageLabel": "Page 1 of 1", "pageLabel": "Page 1 of 1",
"textHashes": [ "textHashes": [
"ddda2419b84480c00d8ed76088898277711f448ef11c2383a48494ecd0e4c524" "e5b4e97805ecb9a50e26694554f810c89cbcd43fba566b27f0f8255a498f753c"
], ],
"firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A GSTIN: 29AAAAA0000A1Z5 TAX INVOICE Invoice No. ST/2026-001 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 GSTIN: 29ABCDE1234F1Z5 Place of Supply: 29 – Karnataka S . N O D E S C R I P TI O N H S N /S AC U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 998397 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 998397 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 998397 ₹1,200.00 per hour 3 ₹3,600.00 Subtotal ₹7,310.00 Taxable value ₹7,310.00 CGST (9%) ₹657.90 SGST (9%) ₹657.90 Total ₹8,625.80 Amount in words: Indian Rupees Eight Thousand Six Hundred Twenty Five and Eighty Paise Only Tax is payable on reverse charge: No PAY T O Selftest Studio State Bank of India Account No.: 00000000000 Branch: 000000 IFSC: SBIN0000000 Selftest Studio Proprietor Computer-generated invoice; no signature is required. Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 1" "firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A GSTIN: 29AAAAA0000A1Z5 TAX INVOICE Invoice No. ST/2026-001 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 GSTIN: 29ABCDE1234F1Z5 Place of Supply: 29 – Karnataka S . N O D E S C R I P TI O N H S N /S AC U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 998397 ₹1,200.00 per hour 1 hr ₹1,200.00 2 Voice-over session 2 - narration for module 2 998397 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 998397 ₹1,200.00 per km 12.5 km ₹15,000.00 Subtotal ₹18,710.00 Taxable value ₹18,710.00 CGST (9%) ₹1,683.90 SGST (9%) ₹1,683.90 Total ₹22,077.80 Amount in words: Indian Rupees Twenty Two Thousand Seventy Seven and Eighty Paise Only Tax is payable on reverse charge: No PAY T O Selftest Studio State Bank of India Account No.: 00000000000 Branch: 000000 IFSC: SBIN0000000 Selftest Studio Proprietor Computer-generated invoice; no signature is required. Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 1"
}, },
"classic-letter-unreg-60": { "classic-letter-unreg-60": {
"pages": 3, "pages": 3,
"fingerprint": "6999be0875f78e2c87aee1fd0ffa1dfe651faa7a7c0a9b4c2f78c1bdff53c7d4", "fingerprint": "6d5ee73ce95af3f8871a4634e50a082ca9f34865ba4a5ffcb541dbae8d132719",
"invoiceNumber": "ST/2026-002", "invoiceNumber": "ST/2026-002",
"totalText": "₹1,56,000.00", "totalText": "₹17,04,000.00",
"pageLabel": "Page 1 of 3", "pageLabel": "Page 1 of 3",
"textHashes": [ "textHashes": [
"4a0cf8a7c1565037edd4d9941251e030fd9e6fca25845b971465e5cf9d916936", "314a9e6ff0e6736435b56bb96014c2d7e8b2398ab7f7904c176a90628015154c",
"870e9a70c0546550d002e5eb5728f342b1d235094c6b645ddda68a6306b33f7b", "fee554a8b908951fc5021a1adfc47b245da8a622c35e64770bfd8f38b9b6db70",
"fca30345bc73289cdcad200f888f917ff9a5386cdd64e41ea7dccc64f2b7e5e0" "3c4fc1cbba3081b4dcef564b31e3c4cac0aff951923c747ec63a4ce0fd4811e9"
], ],
"firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A INVOICE Invoice No. ST/2026-002 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 S . N O D E S C R I P TI O N U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 ₹1,200.00 per hour 3 ₹3,600.00 4 Voice-over session 4 - narration for module 4 — 1 ₹2,530.00 5 Voice-over session 5 - narration for module 5 ₹1,200.00 per hour 2 ₹2,400.00 6 Voice-over session 6 - narration for module 6 — 1 ₹2,550.00 7 Voice-over session 7 - narration for module 7 ₹1,200.00 per hour 1 ₹1,200.00 8 Voice-over session 8 - narration for module 8 — 1 ₹2,570.00 9 Voice-over session 9 - narration for module 9 ₹1,200.00 per hour 3 ₹3,600.00 10 Voice-over session 10 - narration for module 10 — 1 ₹2,590.00 11 Voice-over session 11 - narration for module 11 ₹1,200.00 per hour 2 ₹2,400.00 12 Voice-over session 12 - narration for module 12 — 1 ₹2,610.00 13 Voice-over session 13 - narration for module 13 ₹1,200.00 per hour 1 ₹1,200.00 14 Voice-over session 14 - narration for module 14 — 1 ₹2,630.00 15 Voice-over session 15 - narration for module 15 ₹1,200.00 per hour 3 ₹3,600.00 16 Voice-over session 16 - narration for module 16 — 1 ₹2,650.00 17 Voice-over session 17 - narration for module 17 ₹1,200.00 per hour 2 ₹2,400.00 18 Voice-over session 18 - narration for module 18 — 1 ₹2,670.00 19 Voice-over session 19 - narration for module 19 ₹1,200.00 per hour 1 ₹1,200.00 Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 3" "firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A INVOICE Invoice No. ST/2026-002 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 S . N O D E S C R I P TI O N U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 ₹1,200.00 per hour 1 hr ₹1,200.00 2 Voice-over session 2 - narration for module 2 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 ₹1,200.00 per km 12.5 km ₹15,000.00 4 Voice-over session 4 - narration for module 4 — 1 ₹2,530.00 5 Voice-over session 5 - narration for module 5 ₹1,200.00 per sq ft 121.5 sq ft ₹1,45,800.00 6 Voice-over session 6 - narration for module 6 — 1 ₹2,550.00 7 Voice-over session 7 - narration for module 7 ₹1,200.00 per hour 1 hr ₹1,200.00 8 Voice-over session 8 - narration for module 8 — 1 ₹2,570.00 9 Voice-over session 9 - narration for module 9 ₹1,200.00 per km 12.5 km ₹15,000.00 10 Voice-over session 10 - narration for module 10 — 1 ₹2,590.00 11 Voice-over session 11 - narration for module 11 ₹1,200.00 per sq ft 121.5 sq ft ₹1,45,800.00 12 Voice-over session 12 - narration for module 12 — 1 ₹2,610.00 13 Voice-over session 13 - narration for module 13 ₹1,200.00 per hour 1 hr ₹1,200.00 14 Voice-over session 14 - narration for module 14 — 1 ₹2,630.00 15 Voice-over session 15 - narration for module 15 ₹1,200.00 per km 12.5 km ₹15,000.00 16 Voice-over session 16 - narration for module 16 — 1 ₹2,650.00 17 Voice-over session 17 - narration for module 17 ₹1,200.00 per sq ft 121.5 sq ft ₹1,45,800.00 18 Voice-over session 18 - narration for module 18 — 1 ₹2,670.00 19 Voice-over session 19 - narration for module 19 ₹1,200.00 per hour 1 hr ₹1,200.00 Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 3"
} }
} }
+9 -2
View File
@@ -58,14 +58,21 @@ const BANK: BankAccount = {
isDefault: true, isDefault: true,
}; };
// Rate lines cycle through units so the webview self-test also measures "hr", "km" and "sq ft" text.
const UNIT_CYCLE: Array<{ unit: string; quantity: (i: number) => number }> = [
{ unit: "hour", quantity: (i) => 1 + (i % 3) },
{ unit: "km", quantity: (i) => 10.5 + (i % 3) },
{ unit: "sqft", quantity: (i) => 120.5 + (i % 3) },
];
const items = (n: number, hsn: boolean): InvoiceItem[] => const items = (n: number, hsn: boolean): InvoiceItem[] =>
Array.from({ length: n }, (_, i) => ({ Array.from({ length: n }, (_, i) => ({
id: i + 1, id: i + 1,
description: `Voice-over session ${i + 1} - narration for module ${i + 1}`, description: `Voice-over session ${i + 1} - narration for module ${i + 1}`,
mode: i % 2 === 0 ? "rate" : "fixed", mode: i % 2 === 0 ? "rate" : "fixed",
rate: 1200, rate: 1200,
unit: "hour", unit: UNIT_CYCLE[(i >> 1) % UNIT_CYCLE.length].unit,
quantity: 1 + (i % 3), quantity: UNIT_CYCLE[(i >> 1) % UNIT_CYCLE.length].quantity(i),
amount: 2500 + i * 10, amount: 2500 + i * 10,
sortOrder: i, sortOrder: i,
...(hsn ? { hsnSac: "998397" } : {}), ...(hsn ? { hsnSac: "998397" } : {}),
+27
View File
@@ -213,6 +213,33 @@ body {
margin-bottom: 2rem; 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 > * + * { .voiced-stack > * + * {
margin-top: 1.5rem; margin-top: 1.5rem;
} }
+6 -2
View File
@@ -44,11 +44,13 @@ import {
syncTag, syncTag,
} from "../lib/erpnextUi"; } from "../lib/erpnextUi";
import { useReturnFocus } from "../hooks/useReturnFocus"; import { useReturnFocus } from "../hooks/useReturnFocus";
import ErrorBoundary from "../components/ErrorBoundary";
import ExportFeedback from "../components/ExportFeedback"; import ExportFeedback from "../components/ExportFeedback";
import PdfPreview from "../components/PdfPreview"; import PdfPreview from "../components/PdfPreview";
import RecordPaymentModal from "../components/RecordPaymentModal"; import RecordPaymentModal from "../components/RecordPaymentModal";
import { useToast } from "../components/ToastProvider"; import { useToast } from "../components/ToastProvider";
import { INDIAN_STATES, type Invoice, type Settings } from "../lib/types"; import { INDIAN_STATES, type Invoice, type Settings } from "../lib/types";
import { quantityText } from "../lib/units";
interface Props { interface Props {
invoiceId: number; invoiceId: number;
@@ -385,7 +387,9 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
</OverflowMenu> </OverflowMenu>
</div> </div>
<ExportFeedback exp={exp} /> <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__grid">
<div className="voiced-detail__main voiced-stack"> <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> <span className="voiced-detail__pre">{item.description}</span>
{item.mode === "rate" ? ( {item.mode === "rate" ? (
<div className="voiced-muted"> <div className="voiced-muted">
{item.quantity} {item.unit} × ₹{formatAmount(item.rate)} {quantityText(item.quantity, item.unit)} × ₹{formatAmount(item.rate)}
</div> </div>
) : null} ) : null}
</TableCell> </TableCell>
+4 -1
View File
@@ -49,6 +49,7 @@ import { openInErpnext } from "../lib/erpnextActions";
import type { ErpnextConfig, ErpnextSyncStatus } from "../lib/erpnext"; import type { ErpnextConfig, ErpnextSyncStatus } from "../lib/erpnext";
import { canOpenInErpnext, indexSyncStatuses, isConfigured, pushDisabledReason, sendableSelection, syncTag } from "../lib/erpnextUi"; import { canOpenInErpnext, indexSyncStatuses, isConfigured, pushDisabledReason, sendableSelection, syncTag } from "../lib/erpnextUi";
import SendToErpnextModal, { type SendTarget } from "../components/SendToErpnextModal"; import SendToErpnextModal, { type SendTarget } from "../components/SendToErpnextModal";
import ErrorBoundary from "../components/ErrorBoundary";
import ExportFeedback from "../components/ExportFeedback"; import ExportFeedback from "../components/ExportFeedback";
import { useToast } from "../components/ToastProvider"; import { useToast } from "../components/ToastProvider";
import type { InvoiceSummary, Settings } from "../lib/types"; 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> <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> <p className="voiced-page__subtitle">Every invoice you have issued. Open one to see its PDF, record payments or export it.</p>
<ExportFeedback exp={exp} /> <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 ? ( {loading ? (
<InlineLoading description="Loading invoices" /> <InlineLoading description="Loading invoices" />
+54 -41
View File
@@ -34,7 +34,8 @@ import {
} from "@carbon/icons-react"; } from "@carbon/icons-react";
import { confirm } from "@tauri-apps/plugin-dialog"; import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types"; import { EMPTY_ITEM, INDIAN_STATES } from "../lib/types";
import { isValidUnit } from "../lib/units";
import type { import type {
BankAccount, BankAccount,
Client, Client,
@@ -44,7 +45,6 @@ import type {
InvoiceInput, InvoiceInput,
InvoiceItem, InvoiceItem,
LineMode, LineMode,
LineUnit,
Settings, Settings,
TaxType, TaxType,
} from "../lib/types"; } from "../lib/types";
@@ -56,10 +56,13 @@ import { formToPreviewInvoice } from "../lib/previewInvoice";
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft"; import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts"; import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
import PageSetupControls from "../components/PageSetupControls"; import PageSetupControls from "../components/PageSetupControls";
import QuantityInput from "../components/QuantityInput";
import UnitPicker from "../components/UnitPicker";
import { useInvoicePdf } from "../hooks/useInvoicePdf"; import { useInvoicePdf } from "../hooks/useInvoicePdf";
import { useInvoiceExport } from "../hooks/useInvoiceExport"; import { useInvoiceExport } from "../hooks/useInvoiceExport";
import { autoPushIssued } from "../lib/erpnextUi"; import { autoPushIssued } from "../lib/erpnextUi";
import ExportButton, { ExportModePicker } from "../components/ExportButton"; import ExportButton, { ExportModePicker } from "../components/ExportButton";
import ErrorBoundary from "../components/ErrorBoundary";
import ExportFeedback from "../components/ExportFeedback"; import ExportFeedback from "../components/ExportFeedback";
import { createExportDeps } from "../lib/exportDeps"; import { createExportDeps } from "../lib/exportDeps";
import { issueAndArchive, renderAndArchive } from "../lib/exportFlow"; import { issueAndArchive, renderAndArchive } from "../lib/exportFlow";
@@ -255,6 +258,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
? "cgst_sgst" ? "cgst_sgst"
: "igst"; : "igst";
const clientGstinError = gstinFullError(clientGstin); const clientGstinError = gstinFullError(clientGstin);
// Quantity boxes that hold text which is not a quantity; they keep the last good value, so issuing must wait.
const badQuantities = useRef(new Set<string>());
const markQuantity = (id: string, ok: boolean) => {
if (ok) badQuantities.current.delete(id);
else badQuantities.current.add(id);
};
const bank = useMemo(() => banks.find((b) => b.id === bankId) ?? null, [banks, bankId]); const bank = useMemo(() => banks.find((b) => b.id === bankId) ?? null, [banks, bankId]);
const payload = useMemo( const payload = useMemo(
@@ -607,6 +616,14 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
setError(`Client GSTIN: ${clientGstinError}`); setError(`Client GSTIN: ${clientGstinError}`);
return; return;
} }
if (badQuantities.current.size > 0) {
setError("A quantity is not a number. Fix the line items marked in red.");
return;
}
if (items.some((i) => i.mode === "rate" && !isValidUnit(i.unit))) {
setError("A line item has no valid unit. Pick one, or type a custom unit.");
return;
}
setSaving(true); setSaving(true);
setError(null); setError(null);
exp.dismiss(); exp.dismiss();
@@ -778,7 +795,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
onActionButtonClick={() => void retryArchive()} onActionButtonClick={() => void retryArchive()}
/> />
) : null} ) : null}
<ExportFeedback exp={exp} /> <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" }}> <div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}>
<ExportButton onExport={(mode) => void exp.start(exportable, mode, "original")} disabled={busy} /> <ExportButton onExport={(mode) => void exp.start(exportable, mode, "original")} disabled={busy} />
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} 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>
<Column sm={4} md={8} lg={9} className="voiced-stack"> <Column sm={4} md={8} lg={9} className="voiced-stack">
<Grid narrow> <div className="voiced-fields">
<Column sm={4} md={4} lg={5}> <div className="voiced-field voiced-field--4">
<TextInput <TextInput
id="invoiceDate" id="invoiceDate"
disabled={locked} disabled={locked}
@@ -807,8 +826,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
setDueDate(addDays(e.target.value, settings.paymentTermsDays)); setDueDate(addDays(e.target.value, settings.paymentTermsDays));
}} }}
/> />
</Column> </div>
<Column sm={4} md={4} lg={5}> <div className="voiced-field voiced-field--4">
<TextInput <TextInput
id="dueDate" id="dueDate"
disabled={locked} disabled={locked}
@@ -817,12 +836,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
value={dueDate} value={dueDate}
onChange={(e) => setDueDate(e.target.value)} onChange={(e) => setDueDate(e.target.value)}
/> />
</Column> </div>
<Column sm={4} md={8} lg={6}> <div className="voiced-field voiced-field--4">
<TextInput id="invoiceNumber" labelText="Invoice number" value={issued?.number ?? number} readOnly /> <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 <ComboBox
id="savedClient" id="savedClient"
disabled={locked} disabled={locked}
@@ -847,8 +866,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
> >
Create client Create client
</Button> </Button>
</Column> </div>
<Column sm={4} md={8} lg={8}> <div className="voiced-field voiced-field--12">
<TextInput <TextInput
id="clientName" id="clientName"
disabled={locked} disabled={locked}
@@ -859,8 +878,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
setClientId(null); setClientId(null);
}} }}
/> />
</Column> </div>
<Column sm={4} md={8} lg={16}> <div className="voiced-field voiced-field--12">
<TextArea <TextArea
id="clientAddress" id="clientAddress"
disabled={locked} disabled={locked}
@@ -869,8 +888,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
value={clientAddress} value={clientAddress}
onChange={(e) => setClientAddress(e.target.value)} onChange={(e) => setClientAddress(e.target.value)}
/> />
</Column> </div>
<Column sm={4} md={4} lg={5}> <div className="voiced-field voiced-field--5">
<TextInput <TextInput
id="clientGstin" id="clientGstin"
disabled={locked} disabled={locked}
@@ -880,8 +899,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
invalid={Boolean(clientGstinError)} invalid={Boolean(clientGstinError)}
invalidText={clientGstinError} invalidText={clientGstinError}
/> />
</Column> </div>
<Column sm={4} md={4} lg={5}> <div className="voiced-field voiced-field--7">
<TextInput <TextInput
id="poNumber" id="poNumber"
disabled={locked} disabled={locked}
@@ -889,8 +908,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
value={poNumber} value={poNumber}
onChange={(e) => setPoNumber(e.target.value)} onChange={(e) => setPoNumber(e.target.value)}
/> />
</Column> </div>
<Column sm={4} md={8} lg={6}> <div className="voiced-field voiced-field--12">
<Select <Select
id="placeOfSupply" id="placeOfSupply"
disabled={locked} 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}`} /> <SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
))} ))}
</Select> </Select>
</Column> </div>
<Column sm={4} md={8} lg={16}> <div className="voiced-field voiced-field--12">
<Toggle <Toggle
id="saveClient" id="saveClient"
disabled={locked} disabled={locked}
@@ -911,8 +930,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
toggled={saveClient} toggled={saveClient}
onToggle={setSaveClient} onToggle={setSaveClient}
/> />
</Column> </div>
</Grid> </div>
<div> <div>
<h3 style={{ marginBottom: "0.75rem" }}>Line items</h3> <h3 style={{ marginBottom: "0.75rem" }}>Line items</h3>
@@ -963,7 +982,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
/> />
</div> </div>
) : null} ) : null}
<div style={{ flex: "0 1 150px" }}> <div style={{ flex: "0 1 180px" }}>
<Select <Select
id={`mode-${index}`} id={`mode-${index}`}
disabled={locked} disabled={locked}
@@ -988,28 +1007,22 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
onChange={(_e, { value }) => updateItem(index, { rate: Number(value) || 0 })} onChange={(_e, { value }) => updateItem(index, { rate: Number(value) || 0 })}
/> />
</div> </div>
<div style={{ flex: "0 1 140px" }}> <div style={{ flex: "0 1 160px" }}>
<Select <UnitPicker
id={`unit-${index}`} id={`unit-${index}`}
disabled={locked} disabled={locked}
labelText="Unit"
value={item.unit} value={item.unit}
onChange={(e) => updateItem(index, { unit: e.target.value as LineUnit })} onChange={(unit) => updateItem(index, { unit })}
> />
{Object.entries(UNIT_LABELS).map(([value, text]) => (
<SelectItem key={value} value={value} text={text} />
))}
</Select>
</div> </div>
<div style={{ flex: "0 1 110px" }}> <div style={{ flex: "0 1 130px" }}>
<NumberInput <QuantityInput
id={`qty-${index}`} id={`qty-${index}`}
disabled={locked} disabled={locked}
label="Quantity"
min={0}
step={1}
value={item.quantity} value={item.quantity}
onChange={(_e, { value }) => updateItem(index, { quantity: Number(value) || 0 })} unit={item.unit}
onChange={(quantity) => updateItem(index, { quantity })}
onValidity={(ok) => markQuantity(`qty-${index}`, ok)}
/> />
</div> </div>
</> </>