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