Add backup/restore, daily auto-backup and history CSV/JSON export (Phase E3)
- create_backup writes a zip (manifest with sha256 per file, a VACUUM INTO database snapshot, assets, archive, fonts) atomically. restore_backup validates the manifest, rejects tampered files, path traversal, symlinks and newer schemas, checks the staged DB with integrity_check, then stages a pending restore. The swap runs on the next start before the database opens, moves the current data to backups/pre-restore-<ts>/ and rolls back on any failure. - Daily automatic backup (migration M9 auto_backup, default on) keeps the newest 14 and never blocks startup. - Settings Data tab: back up now, restore with confirmation and restart, auto-backup toggle, last backup time. - History exports the filtered rows as CSV (UTF-8 BOM, CRLF, formula-injection guard) or JSON (voiced.history.v1) with CA-friendly tax columns from a new list_invoice_ledger command. - Rust round-trip, rejection, swap-rollback and retention tests; vitest for the export and backup helpers. Adds the zip 8.6.0 and typed-path 0.12.3 crates (pure Rust); package builds must vendor them. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
This commit is contained in:
Generated
+20
@@ -4188,6 +4188,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typed-path"
|
||||||
|
version = "0.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -4307,6 +4313,7 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5182,6 +5189,19 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "8.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"flate2",
|
||||||
|
"indexmap 2.14.2",
|
||||||
|
"memchr",
|
||||||
|
"typed-path",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.8"
|
version = "0.6.8"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ thiserror = "2"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
|
zip = { version = "8", default-features = false, features = ["deflate-flate2"] }
|
||||||
percent-encoding = "2"
|
percent-encoding = "2"
|
||||||
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -546,6 +546,83 @@ pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, Stri
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the History export needs beyond `InvoiceSummary`: the tax split and CA-facing fields, all money as
|
||||||
|
/// integer paise. Joined to the list rows by `id` on the TypeScript side.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InvoiceLedgerRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub client_gstin: String,
|
||||||
|
pub place_of_supply_code: String,
|
||||||
|
pub doc_type: String,
|
||||||
|
/// Distinct non-empty HSN/SAC codes of the lines, in line order.
|
||||||
|
pub hsn_sac: Vec<String>,
|
||||||
|
/// Subtotal less discount.
|
||||||
|
pub taxable_paise: i64,
|
||||||
|
pub cgst_paise: i64,
|
||||||
|
pub sgst_paise: i64,
|
||||||
|
pub igst_paise: i64,
|
||||||
|
pub reverse_charge: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> {
|
||||||
|
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
|
||||||
|
{
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let items = stmt
|
||||||
|
.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
for item in items {
|
||||||
|
let (id, code) = item.map_err(|e| e.to_string())?;
|
||||||
|
let list = hsn_by_invoice.entry(id).or_default();
|
||||||
|
if !code.is_empty() && !list.contains(&code) {
|
||||||
|
list.push(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, client_gstin, place_of_supply_state_code, doc_type, subtotal, discount,
|
||||||
|
cgst_amount, sgst_amount, igst_amount, reverse_charge
|
||||||
|
FROM invoices ORDER BY id DESC",
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |r| {
|
||||||
|
let id: i64 = r.get(0)?;
|
||||||
|
Ok(InvoiceLedgerRow {
|
||||||
|
id,
|
||||||
|
client_gstin: r.get(1)?,
|
||||||
|
place_of_supply_code: r.get(2)?,
|
||||||
|
doc_type: r.get(3)?,
|
||||||
|
hsn_sac: Vec::new(),
|
||||||
|
taxable_paise: gst::rupees_to_paise(r.get(4)?) - gst::rupees_to_paise(r.get(5)?),
|
||||||
|
cgst_paise: gst::rupees_to_paise(r.get(6)?),
|
||||||
|
sgst_paise: gst::rupees_to_paise(r.get(7)?),
|
||||||
|
igst_paise: gst::rupees_to_paise(r.get(8)?),
|
||||||
|
reverse_charge: r.get::<_, i64>(9)? != 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut row| {
|
||||||
|
row.hsn_sac = hsn_by_invoice.remove(&row.id).unwrap_or_default();
|
||||||
|
row
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_invoice_ledger(state: State<AppState>) -> Result<Vec<InvoiceLedgerRow>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
list_invoice_ledger_impl(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_invoice_impl(conn: &Connection, id: i64) -> Result<Invoice, String> {
|
pub fn get_invoice_impl(conn: &Connection, id: i64) -> Result<Invoice, String> {
|
||||||
fetch_invoice(conn, id).map_err(|e| e.to_string())
|
fetch_invoice(conn, id).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@@ -696,6 +773,28 @@ mod tests {
|
|||||||
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap()
|
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() {
|
||||||
|
let mut conn = registered();
|
||||||
|
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
|
||||||
|
conn.execute("UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1", params![first.id]).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoice_items (invoice_id, description, amount, sort_order, hsn_sac) VALUES (?1, 'b', 1, 5, '9983'), (?1, 'c', 1, 6, '9984'), (?1, 'd', 1, 7, '')",
|
||||||
|
params![first.id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let ledger = list_invoice_ledger_impl(&conn).unwrap();
|
||||||
|
assert_eq!(ledger.len(), 1);
|
||||||
|
let row = &ledger[0];
|
||||||
|
assert_eq!(row.id, first.id);
|
||||||
|
assert_eq!(row.place_of_supply_code, "27");
|
||||||
|
assert_eq!(row.doc_type, "tax_invoice");
|
||||||
|
assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]);
|
||||||
|
assert_eq!(row.taxable_paise, 731_000);
|
||||||
|
assert_eq!((row.cgst_paise, row.sgst_paise, row.igst_paise), (65_790, 65_790, 0));
|
||||||
|
assert!(!row.reverse_charge);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn issues_numbered_tax_invoices_with_derived_totals() {
|
fn issues_numbered_tax_invoices_with_derived_totals() {
|
||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod archive;
|
pub mod archive;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
|
pub mod backup;
|
||||||
pub mod clients;
|
pub mod clients;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod fonts;
|
pub mod fonts;
|
||||||
|
|||||||
+17
-4
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
|
|||||||
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
||||||
|
|
||||||
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
||||||
const LATEST_VERSION: i64 = 8;
|
pub(crate) const LATEST_VERSION: i64 = 9;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum DbError {
|
pub enum DbError {
|
||||||
@@ -264,6 +264,12 @@ CREATE TABLE payments (
|
|||||||
CREATE INDEX idx_payments_invoice ON payments(invoice_id);
|
CREATE INDEX idx_payments_invoice ON payments(invoice_id);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// Version 9: `auto_backup` switches the daily automatic backup (see commands::backup). On by default. It is read and
|
||||||
|
/// written through its own commands, not through `Settings`, so a settings save can never clobber it.
|
||||||
|
const M9: &str = r#"
|
||||||
|
ALTER TABLE app_settings ADD COLUMN auto_backup INTEGER NOT NULL DEFAULT 1;
|
||||||
|
"#;
|
||||||
|
|
||||||
fn migrations() -> Migrations<'static> {
|
fn migrations() -> Migrations<'static> {
|
||||||
Migrations::new(vec![
|
Migrations::new(vec![
|
||||||
M::up(SCHEMA),
|
M::up(SCHEMA),
|
||||||
@@ -274,6 +280,7 @@ fn migrations() -> Migrations<'static> {
|
|||||||
M::up(M6),
|
M::up(M6),
|
||||||
M::up(M7),
|
M::up(M7),
|
||||||
M::up(M8),
|
M::up(M8),
|
||||||
|
M::up(M9),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +301,14 @@ pub fn open(path: &Path, backup_dir: &Path) -> Result<Connection, DbError> {
|
|||||||
Ok(conn)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
|
/// Write a consistent, compacted snapshot of the open database to `target` (which must not exist yet).
|
||||||
|
/// Safe while other connections read and write: SQLite takes a read transaction for the copy.
|
||||||
|
pub(crate) fn vacuum_into(conn: &Connection, target: &Path) -> rusqlite::Result<()> {
|
||||||
|
let quoted = target.to_string_lossy().replace('\'', "''");
|
||||||
|
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
|
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
|
||||||
[],
|
[],
|
||||||
@@ -312,8 +326,7 @@ fn backup_before_migration(
|
|||||||
fs::create_dir_all(backup_dir)?;
|
fs::create_dir_all(backup_dir)?;
|
||||||
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
||||||
let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db"));
|
let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db"));
|
||||||
let quoted = target.to_string_lossy().replace('\'', "''");
|
vacuum_into(conn, &target)?;
|
||||||
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))?;
|
|
||||||
// A failed prune must not block startup; the backup itself already succeeded.
|
// A failed prune must not block startup; the backup itself already succeeded.
|
||||||
let _ = prune_backups(backup_dir, MAX_BACKUPS);
|
let _ = prune_backups(backup_dir, MAX_BACKUPS);
|
||||||
Ok(target)
|
Ok(target)
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir));
|
show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir));
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
|
// A restore staged by the Data tab is swapped in now, before anything opens the database.
|
||||||
|
let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir);
|
||||||
|
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
|
||||||
|
commands::backup::ApplyOutcome::NothingPending => {}
|
||||||
|
commands::backup::ApplyOutcome::Applied { safety_dir } => {
|
||||||
|
eprintln!("Backup restored; the previous data is in {}", safety_dir.display());
|
||||||
|
}
|
||||||
|
commands::backup::ApplyOutcome::Failed { message } => eprintln!("Backup restore failed: {message}"),
|
||||||
|
}
|
||||||
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
||||||
Ok(conn) => conn,
|
Ok(conn) => conn,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -71,6 +80,7 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
if let Err(e) = commands::logo::ensure_logo_derived_impl(&conn, &data_dir) {
|
if let Err(e) = commands::logo::ensure_logo_derived_impl(&conn, &data_dir) {
|
||||||
eprintln!("Could not derive the logo images: {e}");
|
eprintln!("Could not derive the logo images: {e}");
|
||||||
}
|
}
|
||||||
|
commands::backup::spawn_auto_backup(dirs);
|
||||||
Ok(AppState {
|
Ok(AppState {
|
||||||
db: Mutex::new(conn),
|
db: Mutex::new(conn),
|
||||||
data_dir,
|
data_dir,
|
||||||
@@ -80,6 +90,9 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
// After "Restart" in the Data tab the new process waits for the old one (and its single-instance lock) to go.
|
||||||
|
commands::backup::wait_if_relaunched();
|
||||||
|
|
||||||
// WebKitGTK's DMABUF renderer shows a blank window on several Linux GPU/driver
|
// WebKitGTK's DMABUF renderer shows a blank window on several Linux GPU/driver
|
||||||
// combinations. Respect an explicit user setting, otherwise turn it off.
|
// combinations. Respect an explicit user setting, otherwise turn it off.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -136,6 +149,13 @@ pub fn run() {
|
|||||||
commands::payments::record_payment,
|
commands::payments::record_payment,
|
||||||
commands::payments::list_payments,
|
commands::payments::list_payments,
|
||||||
commands::payments::delete_payment,
|
commands::payments::delete_payment,
|
||||||
|
commands::backup::create_backup,
|
||||||
|
commands::backup::restore_backup,
|
||||||
|
commands::backup::cancel_pending_restore,
|
||||||
|
commands::backup::get_backup_status,
|
||||||
|
commands::backup::set_auto_backup,
|
||||||
|
commands::backup::restart_app,
|
||||||
|
commands::invoice::list_invoice_ledger,
|
||||||
commands::assets::import_asset,
|
commands::assets::import_asset,
|
||||||
commands::assets::save_asset_bytes,
|
commands::assets::save_asset_bytes,
|
||||||
commands::assets::read_asset_data_uri,
|
commands::assets::read_asset_data_uri,
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
|
||||||
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import {
|
||||||
|
backupFileName,
|
||||||
|
ensureZipExtension,
|
||||||
|
formatBytes,
|
||||||
|
formatWhen,
|
||||||
|
type BackupStatus,
|
||||||
|
type PendingRestore,
|
||||||
|
} from "../lib/backup";
|
||||||
|
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||||
|
import { useToast } from "./ToastProvider";
|
||||||
|
|
||||||
|
const ZIP_FILTER = [{ name: "Voiced backup", extensions: ["zip"] }];
|
||||||
|
|
||||||
|
type RestoreStep = "confirm" | "staging" | "ready";
|
||||||
|
|
||||||
|
const joinPath = (dir: string, name: string) =>
|
||||||
|
!dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
|
||||||
|
|
||||||
|
/** Settings > Data: manual backup, restore (staged, applied after a restart) and the automatic daily backup. */
|
||||||
|
export default function DataBackupPanel() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [status, setStatus] = useState<BackupStatus | null>(null);
|
||||||
|
const [backingUp, setBackingUp] = useState(false);
|
||||||
|
const [restorePath, setRestorePath] = useState<string | null>(null);
|
||||||
|
const [step, setStep] = useState<RestoreStep>("confirm");
|
||||||
|
const [staged, setStaged] = useState<PendingRestore | null>(null);
|
||||||
|
const [restoreError, setRestoreError] = useState<string | null>(null);
|
||||||
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
useReturnFocus(restorePath !== null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setStatus(await api.getBackupStatus());
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not load backup status", String(e));
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const backUpNow = async () => {
|
||||||
|
setBackingUp(true);
|
||||||
|
try {
|
||||||
|
let dir = "";
|
||||||
|
try {
|
||||||
|
dir = await api.getLastExportDir();
|
||||||
|
} catch {
|
||||||
|
// No remembered folder: the dialog opens wherever the system prefers.
|
||||||
|
}
|
||||||
|
const chosen = await save({ defaultPath: joinPath(dir, backupFileName()), filters: ZIP_FILTER });
|
||||||
|
if (!chosen) return;
|
||||||
|
const summary = await api.createBackup(ensureZipExtension(chosen));
|
||||||
|
toast.success("Backup saved", `${summary.fileCount} files, ${formatBytes(summary.totalBytes)}: ${summary.path}`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Backup failed", String(e));
|
||||||
|
} finally {
|
||||||
|
setBackingUp(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseRestoreFile = async () => {
|
||||||
|
try {
|
||||||
|
const picked = await open({ multiple: false, directory: false, filters: ZIP_FILTER });
|
||||||
|
if (typeof picked !== "string") return;
|
||||||
|
setRestoreError(null);
|
||||||
|
setStaged(null);
|
||||||
|
setStep("confirm");
|
||||||
|
setRestorePath(picked);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not open the file picker", String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRestore = () => {
|
||||||
|
if (step === "staging") return;
|
||||||
|
setRestorePath(null);
|
||||||
|
void refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stageRestore = async () => {
|
||||||
|
if (!restorePath) return;
|
||||||
|
setStep("staging");
|
||||||
|
setRestoreError(null);
|
||||||
|
try {
|
||||||
|
setStaged(await api.restoreBackup(restorePath));
|
||||||
|
setStep("ready");
|
||||||
|
} catch (e) {
|
||||||
|
setRestoreError(String(e));
|
||||||
|
setStep("confirm");
|
||||||
|
toast.error("This backup cannot be restored", String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restart = async () => {
|
||||||
|
setRestarting(true);
|
||||||
|
try {
|
||||||
|
await api.restartApp();
|
||||||
|
} catch (e) {
|
||||||
|
setRestarting(false);
|
||||||
|
toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the restore.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelPending = async () => {
|
||||||
|
try {
|
||||||
|
await api.cancelPendingRestore();
|
||||||
|
toast.info("Restore cancelled", "Your data was not changed.");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not cancel the restore", String(e));
|
||||||
|
}
|
||||||
|
setRestorePath(null);
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAuto = async (enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const saved = await api.setAutoBackup(enabled);
|
||||||
|
setStatus((prev) => (prev ? { ...prev, autoBackup: saved } : prev));
|
||||||
|
toast.success(saved ? "Automatic backup on" : "Automatic backup off");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not change automatic backup", String(e));
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFolder = async (path: string) => {
|
||||||
|
try {
|
||||||
|
await api.openFile(path);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not open the folder", String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pending = status?.pendingRestore ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="voiced-stack">
|
||||||
|
{pending ? (
|
||||||
|
<InlineNotification
|
||||||
|
kind="warning"
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title="A restore is waiting for a restart"
|
||||||
|
subtitle={`${pending.backupName} (${pending.fileCount} files, ${formatBytes(pending.totalBytes)}) has been checked. Restart Voiced to switch to it; your current data is kept in a safety copy.`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{pending ? (
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" onClick={() => void restart()} disabled={restarting}>
|
||||||
|
{restarting ? "Restarting…" : "Restart now"}
|
||||||
|
</Button>
|
||||||
|
<Button size="md" kind="tertiary" onClick={() => void cancelPending()} disabled={restarting}>
|
||||||
|
Cancel restore
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Tile>
|
||||||
|
<h2 className="voiced-section-title">Back up</h2>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
Saves one zip with your invoices, clients, payments, settings, logo and signature images, archived PDFs and imported
|
||||||
|
fonts. Keep a copy somewhere other than this computer.
|
||||||
|
</p>
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" onClick={() => void backUpNow()} disabled={backingUp}>
|
||||||
|
{backingUp ? "Backing up…" : "Back up now"}
|
||||||
|
</Button>
|
||||||
|
{backingUp ? <InlineLoading description="Writing the backup" /> : null}
|
||||||
|
</div>
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
<Tile>
|
||||||
|
<h2 className="voiced-section-title">Restore from backup</h2>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
Replaces everything in Voiced with the contents of a backup file. Your current data is moved into a safety copy first,
|
||||||
|
never deleted.
|
||||||
|
</p>
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" kind="danger--tertiary" onClick={() => void chooseRestoreFile()} disabled={restorePath !== null || !!pending}>
|
||||||
|
Restore from backup…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{status?.lastRestore ? (
|
||||||
|
<p className="voiced-muted" role="status">
|
||||||
|
{status.lastRestore.ok
|
||||||
|
? `Last restore: ${status.lastRestore.backupName || "a backup"}, ${formatWhen(status.lastRestore.at)}.`
|
||||||
|
: `The last restore attempt (${formatWhen(status.lastRestore.at)}) did not go through: ${status.lastRestore.message}`}{" "}
|
||||||
|
{status.lastRestore.safetyDir ? (
|
||||||
|
<Button kind="ghost" size="sm" onClick={() => void api.revealInFolder(status.lastRestore!.safetyDir!).catch((e) => toast.error("Could not open the folder", String(e)))}>
|
||||||
|
Show safety copy
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
<Tile>
|
||||||
|
<h2 className="voiced-section-title">Automatic backup</h2>
|
||||||
|
<Toggle
|
||||||
|
id="autoBackup"
|
||||||
|
labelText="Back up automatically every day"
|
||||||
|
labelA="Off"
|
||||||
|
labelB="On"
|
||||||
|
toggled={status?.autoBackup ?? true}
|
||||||
|
disabled={!status}
|
||||||
|
onToggle={(checked: boolean) => void toggleAuto(checked)}
|
||||||
|
/>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
While Voiced is open it writes one backup a day into its data folder and keeps the newest 14. This changes right away; you
|
||||||
|
do not need to click Save settings.
|
||||||
|
</p>
|
||||||
|
<p role="status">
|
||||||
|
{status === null
|
||||||
|
? "Loading…"
|
||||||
|
: status.lastAutoBackup
|
||||||
|
? `Last automatic backup: ${formatWhen(status.lastAutoBackup)} (${status.autoBackupCount} kept).`
|
||||||
|
: "No automatic backup yet."}
|
||||||
|
</p>
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" kind="tertiary" disabled={!status} onClick={() => status && void openFolder(status.autoDir)}>
|
||||||
|
Open backup folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={restorePath !== null}
|
||||||
|
danger={step === "confirm"}
|
||||||
|
modalHeading={step === "ready" ? "Restart to finish restoring" : "Restore from this backup?"}
|
||||||
|
primaryButtonText={
|
||||||
|
step === "confirm" ? "Check and restore" : step === "staging" ? "Checking the backup…" : restarting ? "Restarting…" : "Restart now"
|
||||||
|
}
|
||||||
|
secondaryButtonText={step === "ready" ? "Later" : "Cancel"}
|
||||||
|
primaryButtonDisabled={step === "staging" || restarting}
|
||||||
|
onRequestClose={closeRestore}
|
||||||
|
onRequestSubmit={() => void (step === "confirm" ? stageRestore() : step === "ready" ? restart() : undefined)}
|
||||||
|
onSecondarySubmit={closeRestore}
|
||||||
|
>
|
||||||
|
<div className="voiced-stack">
|
||||||
|
{restoreError ? <InlineNotification kind="error" lowContrast hideCloseButton title="Cannot restore" subtitle={restoreError} /> : null}
|
||||||
|
{step === "ready" && staged ? (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<strong>{staged.backupName}</strong> passed every check: {staged.fileCount} files, {formatBytes(staged.totalBytes)}, made{" "}
|
||||||
|
{formatWhen(staged.backupCreatedAt)}.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Voiced must restart to switch to it. Until then nothing has changed. Choose Later to keep working and restart from this tab
|
||||||
|
when you are ready (anything you add before restarting will not be in the restored data, but stays in the safety copy).
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
File: <strong>{restorePath}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Everything now in Voiced (invoices, clients, payments, settings, images, archived PDFs and fonts) will be replaced by the
|
||||||
|
contents of this backup.
|
||||||
|
</p>
|
||||||
|
<ul className="voiced-list">
|
||||||
|
<li>The file is checked first; a damaged or newer-version backup is refused and nothing changes.</li>
|
||||||
|
<li>Your current data is moved to a safety copy in Voiced’s backups folder before the swap. It is never deleted.</li>
|
||||||
|
<li>Voiced restarts to complete the restore.</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import type {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
import type { LogoAsset } from "./logo";
|
import type { LogoAsset } from "./logo";
|
||||||
import type { Payment, PaymentInput } from "./payments";
|
import type { Payment, PaymentInput } from "./payments";
|
||||||
|
import type { BackupStatus, BackupSummary, PendingRestore } from "./backup";
|
||||||
|
import type { LedgerRow } from "./historyExport";
|
||||||
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
|
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
|
||||||
|
|
||||||
export interface ArchiveStatus {
|
export interface ArchiveStatus {
|
||||||
@@ -48,6 +50,7 @@ export const api = {
|
|||||||
cancelInvoice: (id: number, reason: string) =>
|
cancelInvoice: (id: number, reason: string) =>
|
||||||
invoke<Invoice>("cancel_invoice", { id, reason }),
|
invoke<Invoice>("cancel_invoice", { id, reason }),
|
||||||
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
|
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
|
||||||
|
listInvoiceLedger: () => invoke<LedgerRow[]>("list_invoice_ledger"),
|
||||||
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
|
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
|
||||||
|
|
||||||
recordPayment: (input: PaymentInput) => invoke<Payment>("record_payment", { input }),
|
recordPayment: (input: PaymentInput) => invoke<Payment>("record_payment", { input }),
|
||||||
@@ -102,4 +105,12 @@ export const api = {
|
|||||||
getLastExportDir: () => invoke<string>("get_last_export_dir"),
|
getLastExportDir: () => invoke<string>("get_last_export_dir"),
|
||||||
revealInFolder: (path: string) => invoke<void>("reveal_in_folder", { path }),
|
revealInFolder: (path: string) => invoke<void>("reveal_in_folder", { path }),
|
||||||
openFile: (path: string) => invoke<void>("open_file", { path }),
|
openFile: (path: string) => invoke<void>("open_file", { path }),
|
||||||
|
|
||||||
|
// Backup and restore. A restore is staged now and applied by the next start (see commands/backup.rs).
|
||||||
|
createBackup: (dest: string) => invoke<BackupSummary>("create_backup", { dest }),
|
||||||
|
restoreBackup: (path: string) => invoke<PendingRestore>("restore_backup", { path }),
|
||||||
|
cancelPendingRestore: () => invoke<void>("cancel_pending_restore"),
|
||||||
|
getBackupStatus: () => invoke<BackupStatus>("get_backup_status"),
|
||||||
|
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
|
||||||
|
restartApp: () => invoke<void>("restart_app"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { backupFileName, ensureZipExtension, formatBytes, formatWhen } from "./backup";
|
||||||
|
|
||||||
|
describe("backup helpers", () => {
|
||||||
|
it("names backups by local date and time", () => {
|
||||||
|
expect(backupFileName(new Date(2026, 2, 4, 9, 5))).toBe("voiced-backup-20260304-0905.zip");
|
||||||
|
});
|
||||||
|
it("adds the zip extension only when missing", () => {
|
||||||
|
expect(ensureZipExtension("/a/b")).toBe("/a/b.zip");
|
||||||
|
expect(ensureZipExtension("/a/b.ZIP")).toBe("/a/b.ZIP");
|
||||||
|
expect(ensureZipExtension("/a/b.zip")).toBe("/a/b.zip");
|
||||||
|
});
|
||||||
|
it("formats sizes", () => {
|
||||||
|
expect(formatBytes(512)).toBe("512 B");
|
||||||
|
expect(formatBytes(1536)).toBe("1.5 KB");
|
||||||
|
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
|
||||||
|
expect(formatBytes(300 * 1024 * 1024)).toBe("300 MB");
|
||||||
|
});
|
||||||
|
it("falls back to the raw text for an unparseable time", () => {
|
||||||
|
expect(formatWhen("not a date")).toBe("not a date");
|
||||||
|
expect(formatWhen("2026-03-04T10:00:00Z")).toMatch(/2026/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/** Shapes returned by the backup commands (src-tauri/src/commands/backup.rs). */
|
||||||
|
export interface BackupSummary {
|
||||||
|
path: string;
|
||||||
|
createdAt: string;
|
||||||
|
schemaVersion: number;
|
||||||
|
fileCount: number;
|
||||||
|
totalBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingRestore {
|
||||||
|
backupName: string;
|
||||||
|
backupCreatedAt: string;
|
||||||
|
appVersion: string;
|
||||||
|
schemaVersion: number;
|
||||||
|
fileCount: number;
|
||||||
|
totalBytes: number;
|
||||||
|
stagedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LastRestore {
|
||||||
|
ok: boolean;
|
||||||
|
at: string;
|
||||||
|
backupName: string;
|
||||||
|
message: string;
|
||||||
|
safetyDir: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupStatus {
|
||||||
|
autoBackup: boolean;
|
||||||
|
autoDir: string;
|
||||||
|
lastAutoBackup: string | null;
|
||||||
|
autoBackupCount: number;
|
||||||
|
pendingRestore: PendingRestore | null;
|
||||||
|
lastRestore: LastRestore | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
/** `voiced-backup-20260304-1530.zip` in local time. */
|
||||||
|
export function backupFileName(now: Date = new Date()): string {
|
||||||
|
const day = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
||||||
|
return `voiced-backup-${day}-${pad(now.getHours())}${pad(now.getMinutes())}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The save dialog does not always add the extension (notably on Linux). */
|
||||||
|
export function ensureZipExtension(path: string): string {
|
||||||
|
return /\.zip$/i.test(path) ? path : `${path}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1536 -> "1.5 KB". Sizes of backup contents are display-only, so plain division is fine. */
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const units = ["KB", "MB", "GB", "TB"];
|
||||||
|
let value = bytes / 1024;
|
||||||
|
let i = 0;
|
||||||
|
while (value >= 1024 && i < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "4 Mar 2026, 3:30 pm" for an ISO timestamp; the raw text if it does not parse. */
|
||||||
|
export function formatWhen(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildHistoryRows,
|
||||||
|
csvField,
|
||||||
|
exportHistory,
|
||||||
|
guardFormula,
|
||||||
|
HISTORY_COLUMNS,
|
||||||
|
historyExportName,
|
||||||
|
paiseToDecimal,
|
||||||
|
toCsv,
|
||||||
|
toJson,
|
||||||
|
type HistoryExportDeps,
|
||||||
|
type LedgerRow,
|
||||||
|
} from "./historyExport";
|
||||||
|
import { NO_FILTERS } from "./historyFilter";
|
||||||
|
import type { InvoiceSummary } from "./types";
|
||||||
|
|
||||||
|
const summary = (over: Partial<InvoiceSummary> = {}): InvoiceSummary => ({
|
||||||
|
id: 1,
|
||||||
|
number: "INV/2025-001",
|
||||||
|
invoiceDate: "2025-06-10",
|
||||||
|
dueDate: "2025-07-10",
|
||||||
|
clientName: "Acme, Inc.",
|
||||||
|
total: 0,
|
||||||
|
status: "issued",
|
||||||
|
createdAt: "",
|
||||||
|
poNumber: "PO-1",
|
||||||
|
totalPaise: 1_180_050,
|
||||||
|
paidPaise: 1_000_000,
|
||||||
|
tdsPaise: 50_000,
|
||||||
|
balancePaise: 130_050,
|
||||||
|
paymentStatus: "partially_paid",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ledger = (over: Partial<LedgerRow> = {}): LedgerRow => ({
|
||||||
|
id: 1,
|
||||||
|
clientGstin: "29ABCDE1234F1Z5",
|
||||||
|
placeOfSupplyCode: "29",
|
||||||
|
docType: "tax_invoice",
|
||||||
|
hsnSac: ["9983", "9984", "9983"],
|
||||||
|
taxablePaise: 1_000_000,
|
||||||
|
cgstPaise: 90_025,
|
||||||
|
sgstPaise: 90_025,
|
||||||
|
igstPaise: 0,
|
||||||
|
reverseCharge: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnIndex = (key: string) => HISTORY_COLUMNS.findIndex((c) => c.key === key);
|
||||||
|
|
||||||
|
describe("paiseToDecimal", () => {
|
||||||
|
it("formats from integer paise without float math", () => {
|
||||||
|
expect(paiseToDecimal(0)).toBe("0.00");
|
||||||
|
expect(paiseToDecimal(5)).toBe("0.05");
|
||||||
|
expect(paiseToDecimal(100)).toBe("1.00");
|
||||||
|
expect(paiseToDecimal(123_450)).toBe("1234.50");
|
||||||
|
expect(paiseToDecimal(-5)).toBe("-0.05");
|
||||||
|
expect(paiseToDecimal(-123_456)).toBe("-1234.56");
|
||||||
|
// 1.005 * 100 style float traps cannot happen: 100_100_000_000_005 paise.
|
||||||
|
expect(paiseToDecimal(100_100_000_000_005)).toBe("1001000000000.05");
|
||||||
|
});
|
||||||
|
it("rejects non-integers", () => {
|
||||||
|
expect(() => paiseToDecimal(1.5)).toThrow();
|
||||||
|
expect(() => paiseToDecimal(Number.NaN)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rows", () => {
|
||||||
|
it("builds the CA-friendly columns", () => {
|
||||||
|
const [row] = buildHistoryRows([summary()], [ledger()]);
|
||||||
|
const get = (key: string) => row[columnIndex(key)];
|
||||||
|
expect(HISTORY_COLUMNS.map((c) => c.header)).toEqual([
|
||||||
|
"Invoice No", "Date", "Due Date", "Client", "Client GSTIN", "Place of Supply Code", "Place of Supply",
|
||||||
|
"Document Type", "HSN/SAC", "Taxable Value", "CGST", "SGST", "IGST", "Total Tax", "Invoice Total",
|
||||||
|
"Received", "TDS", "Balance", "Status", "Payment Status", "PO Number", "Cancelled",
|
||||||
|
]);
|
||||||
|
expect(get("invoiceNo")).toBe("INV/2025-001");
|
||||||
|
expect(get("placeOfSupplyCode")).toBe("29");
|
||||||
|
expect(get("placeOfSupply")).toBe("Karnataka");
|
||||||
|
expect(get("documentType")).toBe("Tax Invoice");
|
||||||
|
expect(get("hsnSac")).toBe("9983; 9984");
|
||||||
|
expect(get("taxableValue")).toBe("10000.00");
|
||||||
|
expect(get("cgst")).toBe("900.25");
|
||||||
|
expect(get("igst")).toBe("0.00");
|
||||||
|
expect(get("totalTax")).toBe("1800.50");
|
||||||
|
expect(get("invoiceTotal")).toBe("11800.50");
|
||||||
|
expect(get("received")).toBe("10000.00");
|
||||||
|
expect(get("tds")).toBe("500.00");
|
||||||
|
expect(get("balance")).toBe("1300.50");
|
||||||
|
expect(get("status")).toBe("Issued");
|
||||||
|
expect(get("paymentStatus")).toBe("Partly paid");
|
||||||
|
expect(get("cancelled")).toBe("No");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags cancelled invoices and leaves ledger fields blank when the row is missing", () => {
|
||||||
|
const [row] = buildHistoryRows([summary({ status: "cancelled", paymentStatus: "none" })], []);
|
||||||
|
expect(row[columnIndex("cancelled")]).toBe("Yes");
|
||||||
|
expect(row[columnIndex("status")]).toBe("Cancelled");
|
||||||
|
expect(row[columnIndex("paymentStatus")]).toBe("");
|
||||||
|
expect(row[columnIndex("taxableValue")]).toBe("");
|
||||||
|
expect(row[columnIndex("invoiceTotal")]).toBe("11800.50");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSV", () => {
|
||||||
|
it("quotes per RFC 4180", () => {
|
||||||
|
expect(csvField("plain")).toBe("plain");
|
||||||
|
expect(csvField("a,b")).toBe('"a,b"');
|
||||||
|
expect(csvField('say "hi"')).toBe('"say ""hi"""');
|
||||||
|
expect(csvField("two\nlines")).toBe('"two\nlines"');
|
||||||
|
expect(csvField("cr\rhere")).toBe('"cr\rhere"');
|
||||||
|
expect(csvField(" padded")).toBe('" padded"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards text cells against formula injection but leaves amounts numeric", () => {
|
||||||
|
for (const bad of ["=1+1", "+SUM(A1)", "-2+3", "@cmd", "\tTAB", "\rCR"]) {
|
||||||
|
expect(guardFormula(bad)).toBe(`'${bad}`);
|
||||||
|
}
|
||||||
|
expect(guardFormula("INV/2025-001")).toBe("INV/2025-001");
|
||||||
|
const cells = buildHistoryRows(
|
||||||
|
[summary({ clientName: "=HYPERLINK(\"http://x\")", poNumber: "-5", balancePaise: -2_500, paidPaise: 0 })],
|
||||||
|
[ledger()],
|
||||||
|
);
|
||||||
|
const csv = toCsv(cells);
|
||||||
|
const dataLine = csv.split("\r\n")[1];
|
||||||
|
expect(dataLine).toContain(`"'=HYPERLINK(""http://x"")"`);
|
||||||
|
expect(dataLine).toContain(",'-5,"); // PO number text is guarded
|
||||||
|
expect(dataLine).toContain(",-25.00,"); // the numeric balance is not
|
||||||
|
expect(dataLine).not.toContain("'-25.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has a BOM, CRLF line ends and a trailing newline", () => {
|
||||||
|
const csv = toCsv(buildHistoryRows([summary(), summary({ id: 2, number: "INV/2025-002" })], [ledger(), ledger({ id: 2 })]));
|
||||||
|
expect(csv.startsWith("Invoice No,Date,")).toBe(true);
|
||||||
|
expect(csv.endsWith("\r\n")).toBe(true);
|
||||||
|
expect(csv.split("\r\n")).toHaveLength(4); // header, two rows, empty after the last CRLF
|
||||||
|
expect(csv.replace(/\r\n/g, "").includes("\n")).toBe(false);
|
||||||
|
expect(new TextEncoder().encode(csv).slice(0, 3)).toEqual(new Uint8Array([0xef, 0xbb, 0xbf]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty set is a header-only file", () => {
|
||||||
|
const csv = toCsv(buildHistoryRows([], []));
|
||||||
|
expect(csv.split("\r\n")).toEqual([expect.stringMatching(/^Invoice No,.*Cancelled$/), ""]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("JSON", () => {
|
||||||
|
it("wraps the rows with schema, time and filters", () => {
|
||||||
|
const filters = { ...NO_FILTERS, fy: 2025 as const, search: "acme" };
|
||||||
|
const parsed = JSON.parse(toJson(buildHistoryRows([summary()], [ledger()]), filters, "2026-01-02T03:04:05.000Z"));
|
||||||
|
expect(parsed.schema).toBe("voiced.history.v1");
|
||||||
|
expect(parsed.exported_at).toBe("2026-01-02T03:04:05.000Z");
|
||||||
|
expect(parsed.filters).toEqual(filters);
|
||||||
|
expect(parsed.count).toBe(1);
|
||||||
|
expect(parsed.rows[0]).toMatchObject({ invoiceNo: "INV/2025-001", taxableValue: "10000.00", totalTax: "1800.50", cancelled: "No" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty set has no rows", () => {
|
||||||
|
const parsed = JSON.parse(toJson([], NO_FILTERS, "t"));
|
||||||
|
expect(parsed.rows).toEqual([]);
|
||||||
|
expect(parsed.count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("historyExportName", () => {
|
||||||
|
it("uses the financial year when one is picked", () => {
|
||||||
|
expect(historyExportName({ ...NO_FILTERS, fy: 2025 }, [], "csv")).toBe("voiced-history-FY2025-26.csv");
|
||||||
|
expect(historyExportName({ ...NO_FILTERS, fy: 2025, month: 4 }, [], "json")).toBe("voiced-history-FY2025-26-m04.json");
|
||||||
|
});
|
||||||
|
it("otherwise the date range of the rows", () => {
|
||||||
|
const rows = [summary({ invoiceDate: "2025-06-10" }), summary({ invoiceDate: "2025-04-02" })];
|
||||||
|
expect(historyExportName(NO_FILTERS, rows, "csv")).toBe("voiced-history-2025-04-02_to_2025-06-10.csv");
|
||||||
|
expect(historyExportName(NO_FILTERS, [rows[0]], "csv")).toBe("voiced-history-2025-06-10.csv");
|
||||||
|
expect(historyExportName(NO_FILTERS, [], "csv")).toBe("voiced-history-all.csv");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exportHistory", () => {
|
||||||
|
const make = (chosen: string | null) => {
|
||||||
|
const written: { path: string; text: string }[] = [];
|
||||||
|
const saves: { defaultPath: string; format: string }[] = [];
|
||||||
|
const deps: HistoryExportDeps = {
|
||||||
|
listLedger: async () => [ledger()],
|
||||||
|
getLastExportDir: async () => "/home/a/Exports",
|
||||||
|
save: async (o) => {
|
||||||
|
saves.push(o);
|
||||||
|
return chosen;
|
||||||
|
},
|
||||||
|
writeExportFile: async (path, bytes) => {
|
||||||
|
written.push({ path, text: new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes) });
|
||||||
|
return path;
|
||||||
|
},
|
||||||
|
now: () => new Date("2026-01-02T03:04:05Z"),
|
||||||
|
};
|
||||||
|
return { deps, written, saves };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("writes exactly the rows given, through the save dialog", async () => {
|
||||||
|
const { deps, written, saves } = make("/home/a/Exports/out.csv");
|
||||||
|
const out = await exportHistory(deps, [summary()], { ...NO_FILTERS, fy: 2025 }, "csv");
|
||||||
|
expect(out).toEqual({ path: "/home/a/Exports/out.csv", count: 1 });
|
||||||
|
expect(saves[0]).toEqual({ defaultPath: "/home/a/Exports/voiced-history-FY2025-26.csv", format: "csv" });
|
||||||
|
expect(written).toHaveLength(1);
|
||||||
|
expect(written[0].text.startsWith("Invoice No")).toBe(true);
|
||||||
|
expect(written[0].text.split("\r\n")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes nothing when the dialog is cancelled, and a header-only file for an empty set", async () => {
|
||||||
|
const cancelled = make(null);
|
||||||
|
expect(await exportHistory(cancelled.deps, [summary()], NO_FILTERS, "json")).toBeNull();
|
||||||
|
expect(cancelled.written).toEqual([]);
|
||||||
|
const empty = make("/x/e.json");
|
||||||
|
expect(await exportHistory(empty.deps, [], NO_FILTERS, "json")).toEqual({ path: "/x/e.json", count: 0 });
|
||||||
|
expect(JSON.parse(empty.written[0].text).rows).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { fyLabel } from "./fiscal";
|
||||||
|
import type { HistoryFilters } from "./historyFilter";
|
||||||
|
import { INDIAN_STATES, type InvoiceSummary } from "./types";
|
||||||
|
|
||||||
|
/** Per-invoice fields the History list does not carry (from the `list_invoice_ledger` command). Money is integer paise. */
|
||||||
|
export interface LedgerRow {
|
||||||
|
id: number;
|
||||||
|
clientGstin: string;
|
||||||
|
placeOfSupplyCode: string;
|
||||||
|
docType: string;
|
||||||
|
hsnSac: string[];
|
||||||
|
taxablePaise: number;
|
||||||
|
cgstPaise: number;
|
||||||
|
sgstPaise: number;
|
||||||
|
igstPaise: number;
|
||||||
|
reverseCharge: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HISTORY_SCHEMA = "voiced.history.v1";
|
||||||
|
|
||||||
|
/** 123450 -> "1234.50", -5 -> "-0.05". Integer arithmetic only; throws on anything that is not a safe integer. */
|
||||||
|
export function paiseToDecimal(paise: number): string {
|
||||||
|
if (!Number.isSafeInteger(paise)) throw new Error(`Not an amount in paise: ${paise}`);
|
||||||
|
const abs = Math.abs(paise);
|
||||||
|
const whole = Math.floor(abs / 100);
|
||||||
|
const frac = abs % 100;
|
||||||
|
return `${paise < 0 ? "-" : ""}${whole}.${frac < 10 ? "0" : ""}${frac}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text cells are guarded against spreadsheet formula injection; amount cells are known numbers and stay numeric. */
|
||||||
|
type Kind = "text" | "amount";
|
||||||
|
|
||||||
|
interface Column {
|
||||||
|
key: string;
|
||||||
|
header: string;
|
||||||
|
kind: Kind;
|
||||||
|
value: (s: InvoiceSummary, l: LedgerRow | undefined) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_TYPES: Record<string, string> = { tax_invoice: "Tax Invoice", invoice: "Invoice" };
|
||||||
|
const PAYMENT_STATUS: Record<string, string> = {
|
||||||
|
unpaid: "Unpaid",
|
||||||
|
partially_paid: "Partly paid",
|
||||||
|
paid: "Paid",
|
||||||
|
overdue: "Overdue",
|
||||||
|
none: "",
|
||||||
|
};
|
||||||
|
const STATUS: Record<string, string> = { issued: "Issued", cancelled: "Cancelled", draft: "Draft" };
|
||||||
|
|
||||||
|
/** An amount that comes from the ledger row; blank (not a made-up zero) if the row is missing. */
|
||||||
|
const ledgerAmount = (pick: (l: LedgerRow) => number) => (_s: InvoiceSummary, l: LedgerRow | undefined) =>
|
||||||
|
l ? paiseToDecimal(pick(l)) : "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column order and meaning for both formats. Cancelled invoices keep their figures (the document exists and
|
||||||
|
* keeps its number); filter on the Cancelled column to leave them out of a sum.
|
||||||
|
*/
|
||||||
|
export const HISTORY_COLUMNS: readonly Column[] = [
|
||||||
|
{ key: "invoiceNo", header: "Invoice No", kind: "text", value: (s) => s.number },
|
||||||
|
{ key: "date", header: "Date", kind: "text", value: (s) => s.invoiceDate },
|
||||||
|
{ key: "dueDate", header: "Due Date", kind: "text", value: (s) => s.dueDate },
|
||||||
|
{ key: "client", header: "Client", kind: "text", value: (s) => s.clientName },
|
||||||
|
{ key: "clientGstin", header: "Client GSTIN", kind: "text", value: (_s, l) => l?.clientGstin ?? "" },
|
||||||
|
{ key: "placeOfSupplyCode", header: "Place of Supply Code", kind: "text", value: (_s, l) => l?.placeOfSupplyCode ?? "" },
|
||||||
|
{
|
||||||
|
key: "placeOfSupply",
|
||||||
|
header: "Place of Supply",
|
||||||
|
kind: "text",
|
||||||
|
value: (_s, l) => INDIAN_STATES.find((st) => st.code === l?.placeOfSupplyCode)?.name ?? "",
|
||||||
|
},
|
||||||
|
{ key: "documentType", header: "Document Type", kind: "text", value: (_s, l) => (l ? (DOC_TYPES[l.docType] ?? l.docType) : "") },
|
||||||
|
{ key: "hsnSac", header: "HSN/SAC", kind: "text", value: (_s, l) => (l ? [...new Set(l.hsnSac)].join("; ") : "") },
|
||||||
|
{ key: "taxableValue", header: "Taxable Value", kind: "amount", value: ledgerAmount((l) => l.taxablePaise) },
|
||||||
|
{ key: "cgst", header: "CGST", kind: "amount", value: ledgerAmount((l) => l.cgstPaise) },
|
||||||
|
{ key: "sgst", header: "SGST", kind: "amount", value: ledgerAmount((l) => l.sgstPaise) },
|
||||||
|
{ key: "igst", header: "IGST", kind: "amount", value: ledgerAmount((l) => l.igstPaise) },
|
||||||
|
{ key: "totalTax", header: "Total Tax", kind: "amount", value: ledgerAmount((l) => l.cgstPaise + l.sgstPaise + l.igstPaise) },
|
||||||
|
{ key: "invoiceTotal", header: "Invoice Total", kind: "amount", value: (s) => paiseToDecimal(s.totalPaise) },
|
||||||
|
{ key: "received", header: "Received", kind: "amount", value: (s) => paiseToDecimal(s.paidPaise) },
|
||||||
|
{ key: "tds", header: "TDS", kind: "amount", value: (s) => paiseToDecimal(s.tdsPaise) },
|
||||||
|
{ key: "balance", header: "Balance", kind: "amount", value: (s) => paiseToDecimal(s.balancePaise) },
|
||||||
|
{ key: "status", header: "Status", kind: "text", value: (s) => STATUS[s.status] ?? s.status },
|
||||||
|
{ key: "paymentStatus", header: "Payment Status", kind: "text", value: (s) => PAYMENT_STATUS[s.paymentStatus] ?? s.paymentStatus },
|
||||||
|
{ key: "poNumber", header: "PO Number", kind: "text", value: (s) => s.poNumber },
|
||||||
|
{ key: "cancelled", header: "Cancelled", kind: "text", value: (s) => (s.status === "cancelled" ? "Yes" : "No") },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The cells of every exported row, in column order. `ledger` is looked up by invoice id. */
|
||||||
|
export function buildHistoryRows(summaries: InvoiceSummary[], ledger: LedgerRow[]): string[][] {
|
||||||
|
const byId = new Map(ledger.map((l) => [l.id, l]));
|
||||||
|
return summaries.map((s) => HISTORY_COLUMNS.map((c) => c.value(s, byId.get(s.id))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A text cell that a spreadsheet would read as a formula gets a leading apostrophe. */
|
||||||
|
export function guardFormula(text: string): string {
|
||||||
|
return /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 4180 field: quoted when it holds a quote, comma or line break (or edge spaces), inner quotes doubled. */
|
||||||
|
export function csvField(text: string): string {
|
||||||
|
return /[",\r\n]|^\s|\s$/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTF-8 BOM (so Excel reads it as UTF-8), CRLF line ends, header row always present. */
|
||||||
|
export function toCsv(cells: string[][]): string {
|
||||||
|
const lines = [HISTORY_COLUMNS.map((c) => csvField(c.header))];
|
||||||
|
for (const row of cells) {
|
||||||
|
lines.push(
|
||||||
|
row.map((value, i) => {
|
||||||
|
const column = HISTORY_COLUMNS[i];
|
||||||
|
return csvField(column.kind === "text" ? guardFormula(value) : value);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `${lines.map((l) => l.join(",")).join("\r\n")}\r\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `{ schema, exported_at, filters, count, rows }`. Rows are objects keyed like the CSV columns; amounts are the same
|
||||||
|
* 2-decimal rupee strings as in the CSV (no binary floats anywhere).
|
||||||
|
*/
|
||||||
|
export function toJson(cells: string[][], filters: HistoryFilters, exportedAt: string): string {
|
||||||
|
const rows = cells.map((row) => Object.fromEntries(HISTORY_COLUMNS.map((c, i) => [c.key, row[i]])));
|
||||||
|
return `${JSON.stringify({ schema: HISTORY_SCHEMA, exported_at: exportedAt, filters, count: rows.length, rows }, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeName = (s: string) => s.replace(/[^A-Za-z0-9._-]+/g, "-");
|
||||||
|
|
||||||
|
/** `voiced-history-FY2025-26.csv`, or the date range of the rows when no year is picked, or `all` when empty. */
|
||||||
|
export function historyExportName(
|
||||||
|
filters: HistoryFilters,
|
||||||
|
summaries: InvoiceSummary[],
|
||||||
|
ext: "csv" | "json",
|
||||||
|
): string {
|
||||||
|
let part: string;
|
||||||
|
if (filters.fy !== "all") {
|
||||||
|
part = fyLabel(filters.fy).replace(/^FY /, "FY");
|
||||||
|
if (filters.month !== "all") part += `-m${String(filters.month).padStart(2, "0")}`;
|
||||||
|
} else {
|
||||||
|
const dates = summaries.map((s) => s.invoiceDate).filter(Boolean).sort();
|
||||||
|
if (dates.length === 0) part = "all";
|
||||||
|
else part = dates[0] === dates[dates.length - 1] ? dates[0] : `${dates[0]}_to_${dates[dates.length - 1]}`;
|
||||||
|
}
|
||||||
|
return `voiced-history-${safeName(part)}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HistoryFormat = "csv" | "json";
|
||||||
|
|
||||||
|
export interface HistoryExportDeps {
|
||||||
|
listLedger(): Promise<LedgerRow[]>;
|
||||||
|
getLastExportDir(): Promise<string>;
|
||||||
|
save(opts: { defaultPath: string; format: HistoryFormat }): Promise<string | null>;
|
||||||
|
writeExportFile(path: string, bytes: Uint8Array): Promise<string>;
|
||||||
|
now(): Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPath(dir: string, name: string): string {
|
||||||
|
if (!dir) return name;
|
||||||
|
if (/[\\/]$/.test(dir)) return `${dir}${name}`;
|
||||||
|
return `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exports exactly `summaries` (the filtered, sorted rows) through the save dialog. Returns the path, or null if cancelled. */
|
||||||
|
export async function exportHistory(
|
||||||
|
deps: HistoryExportDeps,
|
||||||
|
summaries: InvoiceSummary[],
|
||||||
|
filters: HistoryFilters,
|
||||||
|
format: HistoryFormat,
|
||||||
|
): Promise<{ path: string; count: number } | null> {
|
||||||
|
const ledger = await deps.listLedger();
|
||||||
|
const cells = buildHistoryRows(summaries, ledger);
|
||||||
|
const text = format === "csv" ? toCsv(cells) : toJson(cells, filters, deps.now().toISOString());
|
||||||
|
let dir = "";
|
||||||
|
try {
|
||||||
|
dir = await deps.getLastExportDir();
|
||||||
|
} catch {
|
||||||
|
// No remembered folder: the dialog opens wherever the system prefers.
|
||||||
|
}
|
||||||
|
const chosen = await deps.save({ defaultPath: joinPath(dir, historyExportName(filters, summaries, format)), format });
|
||||||
|
if (!chosen) return null;
|
||||||
|
const path = (await deps.writeExportFile(chosen, new TextEncoder().encode(text))) || chosen;
|
||||||
|
return { path, count: cells.length };
|
||||||
|
}
|
||||||
@@ -541,6 +541,26 @@ body {
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Settings: Data tab ---------- */
|
||||||
|
.voiced-section-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-list {
|
||||||
|
margin-left: 1.25rem;
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Invoice history and detail ---------- */
|
/* ---------- Invoice history and detail ---------- */
|
||||||
.voiced-num {
|
.voiced-num {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -578,6 +598,14 @@ body {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voiced-history__export {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.voiced-history__row {
|
.voiced-history__row {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
|
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
|
||||||
import { ImagePicker } from "../components/ImagePicker";
|
import { ImagePicker } from "../components/ImagePicker";
|
||||||
import { LogoBranding } from "../components/LogoBranding";
|
import { LogoBranding } from "../components/LogoBranding";
|
||||||
|
import DataBackupPanel from "../components/DataBackupPanel";
|
||||||
import DiagnosticsPanel from "../components/DiagnosticsPanel";
|
import DiagnosticsPanel from "../components/DiagnosticsPanel";
|
||||||
import { FontOverrideTable } from "../components/FontOverrideTable";
|
import { FontOverrideTable } from "../components/FontOverrideTable";
|
||||||
import { useToast } from "../components/ToastProvider";
|
import { useToast } from "../components/ToastProvider";
|
||||||
@@ -513,9 +514,7 @@ export default function AppSettings({
|
|||||||
</section>
|
</section>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<section className="voiced-stack">
|
<DataBackupPanel />
|
||||||
<p>Backup and export arrive in the next step.</p>
|
|
||||||
</section>
|
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Tile,
|
Tile,
|
||||||
} from "@carbon/react";
|
} from "@carbon/react";
|
||||||
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
|
import { exportHistory, type HistoryFormat } from "../lib/historyExport";
|
||||||
import { formatDate } from "../lib/format";
|
import { formatDate } from "../lib/format";
|
||||||
import { formatPaise } from "../lib/money";
|
import { formatPaise } from "../lib/money";
|
||||||
import { statusBadge } from "../lib/payments";
|
import { statusBadge } from "../lib/payments";
|
||||||
@@ -73,6 +75,7 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZES[0]);
|
const [pageSize, setPageSize] = useState(PAGE_SIZES[0]);
|
||||||
const exp = useInvoiceExport({ settings, bank: null });
|
const exp = useInvoiceExport({ settings, bank: null });
|
||||||
|
const [exportingHistory, setExportingHistory] = useState(false);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -123,6 +126,34 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Exactly the rows the table is filtered to (every page, in the current sort order). */
|
||||||
|
const onExportHistory = async (format: HistoryFormat) => {
|
||||||
|
setExportingHistory(true);
|
||||||
|
try {
|
||||||
|
const done = await exportHistory(
|
||||||
|
{
|
||||||
|
listLedger: api.listInvoiceLedger,
|
||||||
|
getLastExportDir: api.getLastExportDir,
|
||||||
|
save: ({ defaultPath, format: fmt }) =>
|
||||||
|
save({
|
||||||
|
defaultPath,
|
||||||
|
filters: [fmt === "csv" ? { name: "CSV (Excel)", extensions: ["csv"] } : { name: "JSON", extensions: ["json"] }],
|
||||||
|
}),
|
||||||
|
writeExportFile: api.writeExportFile,
|
||||||
|
now: () => new Date(),
|
||||||
|
},
|
||||||
|
sorted,
|
||||||
|
filters,
|
||||||
|
format,
|
||||||
|
);
|
||||||
|
if (done) toast.success(`Exported ${done.count} invoices`, done.path);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not export the invoices", String(e));
|
||||||
|
} finally {
|
||||||
|
setExportingHistory(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="voiced-page">
|
<div className="voiced-page">
|
||||||
<h1 className="voiced-page__title">Invoices</h1>
|
<h1 className="voiced-page__title">Invoices</h1>
|
||||||
@@ -216,6 +247,18 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="voiced-history__export">
|
||||||
|
<Button kind="tertiary" size="md" disabled={sorted.length === 0 || exportingHistory} onClick={() => void onExportHistory("csv")}>
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
<Button kind="ghost" size="md" disabled={sorted.length === 0 || exportingHistory} onClick={() => void onExportHistory("json")}>
|
||||||
|
Export JSON
|
||||||
|
</Button>
|
||||||
|
<span className="voiced-muted">
|
||||||
|
{filtersActive ? `The ${sorted.length} filtered invoices` : `All ${sorted.length} invoices`}, for your accountant
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{sorted.length === 0 ? (
|
{sorted.length === 0 ? (
|
||||||
<div className="voiced-empty">No invoices match these filters.</div>
|
<div className="voiced-empty">No invoices match these filters.</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user