diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index a443c21..9d3936a 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "29d49a8d8cf10c3415c7d487dfb7419e69ea581f035ec1b3d82788f2ea25f25e", + "compatibility_surface_sha256": "ac5d44785fa061e2694384d5ad5fe8f6a001ceb3226119404a00cba6cc7c8684", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index a97b36e..f6fb0d8 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -219,7 +219,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "d12c341e5f5fecd6e41436293699bd8f318f0ae2127ccd7ede29e0af2db9d19e" + "sha256": "052b45f94751a01a1b7960cd861d1c122b49f0b80088b0434fc33e958396992d" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -303,7 +303,7 @@ }, { "path": "src-tauri/src/lib.rs", - "sha256": "62c950aacaff7b1b431b65f32e14ead421d87a0bd3cd44a580912adfb5bf3730" + "sha256": "5582994d94163235a885bce0d44eadc11b6d8eb9a36898610e94c2b1141efbae" }, { "path": "src-tauri/src/sync/coordinator.rs", @@ -359,7 +359,7 @@ }, { "path": "src/OutstandingsScreen.tsx", - "sha256": "5fbb3bc7a47487cf5a5d9f5bc527c5a7e01940589fd519c4a07c1e6c6f094cf3" + "sha256": "6835d23a31eb92e1037e8f1e9c268ea86c59554a6b7060009e4dfa086bfa03d6" }, { "path": "src/TallyReadinessFlow.tsx", @@ -379,7 +379,7 @@ }, { "path": "src/styles.css", - "sha256": "b488d5305557b4c356e4d2e16971d387724e9be17ede1d8c05213f64c9661146" + "sha256": "e567fc5c8afebc047a8f8d59c7c7399dfefcb5e018aa337004fabf1ad8f03e4f" }, { "path": "src/tally-company-selection.ts", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "29d49a8d8cf10c3415c7d487dfb7419e69ea581f035ec1b3d82788f2ea25f25e" + "manifest_sha256": "ac5d44785fa061e2694384d5ad5fe8f6a001ceb3226119404a00cba6cc7c8684" } diff --git a/scripts/client-grouping.test.mjs b/scripts/client-grouping.test.mjs new file mode 100644 index 0000000..6805f40 --- /dev/null +++ b/scripts/client-grouping.test.mjs @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { applyClientGroupLabel, groupClientRows, rollbackFailedClientGroupLabel, sumExactDecimals } from "../src/client-grouping.ts"; + +test("a failed optimistic label save restores only the value that actually failed", () => { + const persisted = { "synthetic-company-guid": "Original" }; + const optimistic = applyClientGroupLabel(persisted, "synthetic-company-guid", "North"); + assert.deepEqual( + rollbackFailedClientGroupLabel(optimistic, "synthetic-company-guid", "North", persisted), + persisted, + ); + + const newerEdit = applyClientGroupLabel(optimistic, "synthetic-company-guid", "Newer edit"); + assert.deepEqual( + rollbackFailedClientGroupLabel(newerEdit, "synthetic-company-guid", "North", persisted), + newerEdit, + "a late failure must not erase typing that happened after the failed attempt", + ); + assert.deepEqual( + rollbackFailedClientGroupLabel({ "synthetic-company-guid": "North" }, "synthetic-company-guid", "North", {}), + {}, + "a failed first save returns the company to ungrouped", + ); +}); + +test("applying a group label preserves every company figure byte-for-byte", () => { + const row = { + companyGuid: "synthetic-company-guid", + company: "Synthetic Components Ltd", + exactAmounts: { receivable: "482001.25", overdue: "120400.5", unallocated: "7580.75" }, + oldest: 97, + complete: true, + }; + const before = Buffer.from(JSON.stringify(row)); + + const grouped = groupClientRows([row], { "synthetic-company-guid": "North practice" }); + const groupedRow = grouped.groups[0].rows[0]; + + assert.deepEqual(Buffer.from(JSON.stringify(groupedRow)), before); + assert.deepEqual(grouped.groups[0].totals, { + receivable: "482001.25", + overdue: "120400.5", + unallocated: "7580.75", + }); + assert.equal(grouped.ungroupedRows.length, 0); +}); + +test("ungrouped companies remain separate and receive no synthetic total", () => { + const rows = [ + { companyGuid: "synthetic-a", exactAmounts: { receivable: "10", overdue: "2", unallocated: "1" } }, + { companyGuid: "synthetic-b", exactAmounts: { receivable: "20", overdue: "3", unallocated: "4" } }, + ]; + + const grouped = groupClientRows(rows, {}); + + assert.deepEqual(grouped.groups, []); + assert.equal(grouped.ungroupedRows.length, 2); +}); + +test("group totals preserve decimal precision without IEEE-754 rounding", () => { + assert.equal(sumExactDecimals(["9007199254740993", "0.01", "-1"]), "9007199254740992.01"); + assert.equal(sumExactDecimals(["42.00", "0.10"]), "42.1"); + assert.equal(sumExactDecimals(["10", "not-an-amount"]), undefined); +}); + +test("client amount views fail visibly instead of coercing or flipping amounts", async () => { + const [allClients, outstandings] = await Promise.all([ + readFile(new URL("../src/AllClientsScreen.tsx", import.meta.url), "utf8"), + readFile(new URL("../src/OutstandingsScreen.tsx", import.meta.url), "utf8"), + ]); + + assert.match(allClients, /Amount unavailable/); + assert.doesNotMatch(allClients, /Math\.abs/); + assert.match(outstandings, /Bridge could not read an outstandings amount/); + assert.doesNotMatch(outstandings, /Math\.abs/); +}); + +test("all-client responses carry the pinned GUID back to the open action", async () => { + const [allClients, commands] = await Promise.all([ + readFile(new URL("../src/AllClientsScreen.tsx", import.meta.url), "utf8"), + readFile(new URL("../src-tauri/src/commands.rs", import.meta.url), "utf8"), + ]); + + assert.match(commands, /pub company_guid: String/); + assert.match(commands, /company_guid: entry\.expected_company_guid/); + assert.match(allClients, /companyGuid: entry\.company_guid/); + assert.doesNotMatch(allClients, /companies\.find\(\(company\) => company\.name === entry\.company\)/); +}); diff --git a/src-tauri/src/client_groups.rs b/src-tauri/src/client_groups.rs new file mode 100644 index 0000000..f84588c --- /dev/null +++ b/src-tauri/src/client_groups.rs @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Operator-owned company filing labels. +//! +//! These labels deliberately live in the ordinary application configuration +//! directory, not in the encrypted Tally mirror. They are an operator's filing +//! choice rather than accounting data, and reading them must never resolve the +//! mirror key or prompt the operating-system keychain. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::io::Write; +use std::path::{Path, PathBuf}; + +const FILE_NAME: &str = "client-group-labels-v1.json"; +const SCHEMA_VERSION: u8 = 1; + +pub type ClientGroupLabels = BTreeMap; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ClientGroupLabelsFile { + version: u8, + labels: ClientGroupLabels, +} + +#[derive(Debug, thiserror::Error)] +pub enum ClientGroupLabelsError { + #[error("client group label file could not be read")] + Read(#[source] std::io::Error), + #[error("client group label file is empty")] + EmptyFile, + #[error("client group label file is corrupt")] + CorruptFile(#[source] serde_json::Error), + #[error("client group label schema version {found} is unsupported; expected {supported}")] + UnsupportedVersion { found: u8, supported: u8 }, + #[error("client group label file could not be written")] + Write(#[source] std::io::Error), +} + +pub fn load(directory: &Path) -> ClientGroupLabels { + try_load(directory).unwrap_or_default() +} + +fn try_load(directory: &Path) -> Result { + let contents = match std::fs::read_to_string(directory.join(FILE_NAME)) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(ClientGroupLabels::new()) + } + Err(error) => return Err(ClientGroupLabelsError::Read(error)), + }; + if contents.trim().is_empty() { + return Err(ClientGroupLabelsError::EmptyFile); + } + + let file = serde_json::from_str::(&contents) + .map_err(ClientGroupLabelsError::CorruptFile)?; + if file.version != SCHEMA_VERSION { + return Err(ClientGroupLabelsError::UnsupportedVersion { + found: file.version, + supported: SCHEMA_VERSION, + }); + } + + Ok(normalize(file.labels)) +} + +pub fn save_label( + directory: &Path, + company_guid: &str, + label: &str, +) -> Result<(), ClientGroupLabelsError> { + let mut labels = try_load(directory)?; + let company_guid = company_guid.trim(); + let label = label.trim(); + if label.is_empty() { + labels.remove(company_guid); + } else { + labels.insert(company_guid.to_string(), label.to_string()); + } + + std::fs::create_dir_all(directory).map_err(ClientGroupLabelsError::Write)?; + let path = directory.join(FILE_NAME); + let contents = serde_json::to_vec_pretty(&ClientGroupLabelsFile { + version: SCHEMA_VERSION, + labels, + }) + .expect("client group label schema always serializes"); + write_file_atomically(&path, &contents).map_err(ClientGroupLabelsError::Write)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(ClientGroupLabelsError::Write)?; + } + Ok(()) +} + +/// Replacing the label file is a single rename after a fully written sibling +/// exists. A process interruption can therefore retain the previous labels or +/// the complete next labels, never a truncated JSON file that loads as none. +fn write_file_atomically(path: &Path, contents: &[u8]) -> Result<(), std::io::Error> { + let parent = path + .parent() + .ok_or_else(|| std::io::Error::other("missing_label_parent"))?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| std::io::Error::other("invalid_label_file_name"))?; + for sequence in 1..=10_000_u32 { + let temporary = temporary_label_path(parent, file_name, sequence); + let mut file = match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + }; + if let Err(error) = file.write_all(contents).and_then(|_| file.sync_all()) { + drop(file); + let _ = std::fs::remove_file(&temporary); + return Err(error); + } + drop(file); + if let Err(error) = std::fs::rename(&temporary, path) { + let _ = std::fs::remove_file(&temporary); + return Err(error); + } + return Ok(()); + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "label_temporary_name_exhausted", + )) +} + +fn temporary_label_path(parent: &Path, file_name: &str, sequence: u32) -> PathBuf { + parent.join(format!( + ".{file_name}.{}.{}.tmp", + std::process::id(), + sequence + )) +} + +fn normalize(labels: ClientGroupLabels) -> ClientGroupLabels { + labels + .into_iter() + .filter_map(|(company_guid, label)| { + let company_guid = company_guid.trim(); + let label = label.trim(); + (!company_guid.is_empty() && !label.is_empty()) + .then(|| (company_guid.to_string(), label.to_string())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_empty_and_corrupt_files_mean_no_groups() { + let directory = tempfile::tempdir().expect("temporary config directory"); + assert!(load(directory.path()).is_empty()); + + std::fs::write(directory.path().join(FILE_NAME), " \n").expect("write empty file"); + assert!(load(directory.path()).is_empty()); + + std::fs::write(directory.path().join(FILE_NAME), "not json").expect("write corrupt file"); + assert!(load(directory.path()).is_empty()); + } + + #[test] + fn labels_survive_a_reload_and_blank_labels_remove_the_group() { + let directory = tempfile::tempdir().expect("temporary config directory"); + save_label(directory.path(), "synthetic-company-guid", "North practice") + .expect("save label"); + assert_eq!( + load(directory.path()).get("synthetic-company-guid"), + Some(&"North practice".to_string()) + ); + + save_label(directory.path(), "synthetic-company-guid", " ").expect("remove label"); + assert!(load(directory.path()).is_empty()); + } + + #[test] + fn replacement_writes_leave_only_a_complete_current_label_file() { + let directory = tempfile::tempdir().expect("temporary config directory"); + save_label(directory.path(), "synthetic-company-guid", "North practice") + .expect("first label"); + save_label(directory.path(), "synthetic-company-guid", "South practice") + .expect("replacement label"); + + assert_eq!( + load(directory.path()).get("synthetic-company-guid"), + Some(&"South practice".to_string()) + ); + assert!(directory + .path() + .read_dir() + .expect("directory entries") + .all(|entry| !entry + .expect("directory entry") + .file_name() + .to_string_lossy() + .ends_with(".tmp"))); + } + + #[test] + fn save_refuses_to_overwrite_a_corrupt_existing_label_file() { + let directory = tempfile::tempdir().expect("temporary config directory"); + let path = directory.path().join(FILE_NAME); + let original = b"{not valid json"; + std::fs::write(&path, original).expect("write corrupt label bytes"); + + assert!( + load(directory.path()).is_empty(), + "display remains available" + ); + assert!(matches!( + save_label(directory.path(), "new-synthetic-guid", "New practice"), + Err(ClientGroupLabelsError::CorruptFile(_)) + )); + assert_eq!( + std::fs::read(path).expect("read preserved label bytes"), + original + ); + } + + #[test] + fn save_refuses_to_overwrite_an_unsupported_label_schema() { + let directory = tempfile::tempdir().expect("temporary config directory"); + let path = directory.path().join(FILE_NAME); + let original = br#"{"version":2,"labels":{"existing-guid":"Existing practice"}}"#; + std::fs::write(&path, original).expect("write future label schema"); + + assert!( + load(directory.path()).is_empty(), + "display remains available" + ); + assert!(matches!( + save_label(directory.path(), "new-synthetic-guid", "New practice"), + Err(ClientGroupLabelsError::UnsupportedVersion { + found: 2, + supported: SCHEMA_VERSION + }) + )); + assert_eq!( + std::fs::read(path).expect("read preserved label bytes"), + original + ); + } + + #[test] + fn save_refuses_empty_or_unreadable_existing_label_paths() { + let empty_directory = tempfile::tempdir().expect("temporary config directory"); + let empty_path = empty_directory.path().join(FILE_NAME); + std::fs::write(&empty_path, b" \n").expect("write empty label file"); + assert!(load(empty_directory.path()).is_empty()); + assert!(matches!( + save_label(empty_directory.path(), "new-synthetic-guid", "New practice"), + Err(ClientGroupLabelsError::EmptyFile) + )); + assert_eq!(std::fs::read(empty_path).expect("read empty bytes"), b" \n"); + + let unreadable_directory = tempfile::tempdir().expect("temporary config directory"); + let unreadable_path = unreadable_directory.path().join(FILE_NAME); + std::fs::create_dir(&unreadable_path).expect("create unreadable label path"); + assert!(load(unreadable_directory.path()).is_empty()); + assert!(matches!( + save_label( + unreadable_directory.path(), + "new-synthetic-guid", + "New practice" + ), + Err(ClientGroupLabelsError::Read(_)) + )); + assert!(unreadable_path.is_dir()); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6447c90..5c2bf43 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,3 +1,4 @@ +use crate::client_groups; use crate::db::tally_incremental::IncrementalFoundationEvidence; use crate::db::tally_mirror::{ company_profile_correlation_key, selected_read_scope_commitment_sha256, CapabilityItemInput, @@ -40,7 +41,7 @@ use bridge_tally_core::{ }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use tauri::State; +use tauri::{AppHandle, Manager, State}; use zeroize::Zeroizing; const MAX_DSC_PIN_BYTES: usize = 128; @@ -2107,6 +2108,42 @@ pub async fn fetch_tally_outstandings( .map_err(tally_runtime_command_error) } +/// Reads operator-owned filing labels from ordinary application configuration. +/// +/// The helper deliberately degrades to no labels for a missing, empty, corrupt, +/// or unavailable file. It receives no mirror state, so this command cannot +/// initialise SQLCipher or resolve a keychain key. +#[tauri::command] +pub fn load_client_group_labels(app: AppHandle) -> client_groups::ClientGroupLabels { + let Ok(directory) = app.path().app_config_dir() else { + return client_groups::ClientGroupLabels::new(); + }; + client_groups::load(&directory) +} + +#[derive(Debug, Deserialize)] +pub struct SaveClientGroupLabelRequest { + pub company_guid: String, + pub label: String, +} + +/// Saves one operator-owned filing label without accessing the Tally mirror. +#[tauri::command] +pub fn save_client_group_label( + app: AppHandle, + request: SaveClientGroupLabelRequest, +) -> Result<(), String> { + if request.company_guid.trim().is_empty() { + return Err("Bridge could not identify the company for this group label.".to_string()); + } + let directory = app + .path() + .app_config_dir() + .map_err(|_| "Bridge could not locate its local group-label configuration.".to_string())?; + client_groups::save_label(&directory, &request.company_guid, &request.label) + .map_err(|_| "Bridge could not save this group label.".to_string()) +} + #[derive(Debug, Deserialize)] pub struct AllCompaniesOutstandingsRequest { pub config: TallyConfig, @@ -2123,6 +2160,7 @@ pub struct AllCompaniesEntry { #[derive(Debug, Serialize)] pub struct CompanyOutstandingsEntry { pub company: String, + pub company_guid: String, pub result: OutstandingsLoadResult, } @@ -2199,6 +2237,7 @@ pub async fn fetch_tally_outstandings_all_companies( }; entries.push(CompanyOutstandingsEntry { company: entry.company, + company_guid: entry.expected_company_guid, result: company_sweep_result(result), }); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad69e4e..1a4282f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ pub mod axal; +pub mod client_groups; pub mod commands; pub mod db; pub mod documents; @@ -94,6 +95,8 @@ pub fn run() { commands::preview_bulk_party_statements, commands::export_bulk_party_statements, commands::fetch_tally_outstandings_all_companies, + commands::load_client_group_labels, + commands::save_client_group_label, commands::detect_tally_base_currency, commands::tally_persisted_company_profiles, commands::tally_mirror_explorer_page, @@ -230,3 +233,23 @@ mod security_config_tests { assert!(csp.contains("default-src 'none'")); } } + +#[cfg(test)] +mod client_group_label_mount_tests { + #[test] + fn group_label_load_path_is_mirror_and_keychain_free() { + let commands = include_str!("commands.rs"); + let start = commands + .find("pub fn load_client_group_labels") + .expect("group-label load command"); + let end = commands[start..] + .find("pub struct AllCompaniesOutstandingsRequest") + .map(|offset| start + offset) + .expect("end of group-label commands"); + let group_label_commands = &commands[start..end]; + + assert!(group_label_commands.contains("app_config_dir")); + assert!(!group_label_commands.contains("LazyTallyMirror")); + assert!(!group_label_commands.contains("keyring")); + } +} diff --git a/src/AllClientsScreen.tsx b/src/AllClientsScreen.tsx index f0e4653..638790b 100644 --- a/src/AllClientsScreen.tsx +++ b/src/AllClientsScreen.tsx @@ -3,6 +3,7 @@ import React from "react"; import { ChevronRight, RefreshCw } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; +import { applyClientGroupLabel, ClientGroupLabels, groupClientRows, rollbackFailedClientGroupLabel } from "./client-grouping"; import { outstandingsPartialState } from "./outstandings-copy"; type CompanyRef = { name: string; guid: string }; @@ -28,11 +29,12 @@ type LoadResult = | { state: "complete"; report: Report; unallocated_total?: string } | { state: "partial"; reason_code: string }; -type Entry = { company: string; result: LoadResult }; +type Entry = { company: string; company_guid: string; result: LoadResult }; function amountOf(value: string | undefined) { - const parsed = Number.parseFloat(value ?? "0"); - return Number.isFinite(parsed) ? Math.abs(parsed) : 0; + if (!value || !/^-?\d+(?:\.\d+)?$/.test(value)) return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; } function formatMoney(value: string) { @@ -51,6 +53,7 @@ function formatMoney(value: string) { /// screen, and in the export. function formatCompact(value: string | undefined) { const amount = amountOf(value); + if (amount === null) return "Amount unavailable"; if (amount === 0) return "—"; if (amount >= 10_000_000) return `₹${(amount / 10_000_000).toFixed(2)} cr`; if (amount >= 100_000) return `₹${(amount / 100_000).toFixed(2)} L`; @@ -74,8 +77,33 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P const [entries, setEntries] = React.useState(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); + const [groupLabels, setGroupLabels] = React.useState({}); + const [groupLabelError, setGroupLabelError] = React.useState(null); + const persistedGroupLabels = React.useRef({}); const requestVersion = React.useRef(0); + React.useEffect(() => { + let active = true; + void invoke("load_client_group_labels") + .then((labels) => { + if (active) { + persistedGroupLabels.current = labels; + setGroupLabels(labels); + } + }) + // A label is optional. If its local config cannot be read, continue as + // ungrouped instead of turning the report into a failed screen. + .catch(() => { + if (active) { + persistedGroupLabels.current = {}; + setGroupLabels({}); + } + }); + return () => { + active = false; + }; + }, []); + const load = React.useCallback(async () => { if (companies.length === 0) return; const version = requestVersion.current + 1; @@ -124,18 +152,26 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P : null; return { company: entry.company, + companyGuid: entry.company_guid, complete, reasonCode: entry.result.state === "partial" ? entry.result.reason_code : null, - receivable: complete ? amountOf(complete.report.receivable_total) : 0, - overdue: complete ? amountOf(complete.report.ageing.days_90_plus) : 0, - unallocated: complete ? amountOf(complete.unallocated_total) : 0, + receivable: complete ? amountOf(complete.report.receivable_total) : null, + overdue: complete ? amountOf(complete.report.ageing.days_90_plus) : null, + unallocated: complete ? amountOf(complete.unallocated_total) : null, + exactAmounts: { + receivable: complete ? complete.report.receivable_total : undefined, + overdue: complete ? complete.report.ageing.days_90_plus : undefined, + unallocated: complete ? complete.unallocated_total : undefined, + }, // How much of this book's exposure Tally cannot age. It is the // single best signal of whether the other numbers can be trusted, // and it varies enormously between books. - unallocatedShare: complete && complete.unallocated_total !== undefined + unallocatedShare: complete + && amountOf(complete.unallocated_total) !== null + && amountOf(complete.report.receivable_total) !== null ? Math.round( - amountOf(complete.unallocated_total) - / Math.max(1, amountOf(complete.unallocated_total) + amountOf(complete.report.receivable_total)) + amountOf(complete.unallocated_total)! + / Math.max(1, amountOf(complete.unallocated_total)! + amountOf(complete.report.receivable_total)!) * 100, ) : null, @@ -152,21 +188,96 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P const r = right.oldest ?? -1; return (l - r) * direction; } - return (left[sort.key] - right[sort.key]) * direction; + const leftAmount = left[sort.key]; + const rightAmount = right[sort.key]; + if (leftAmount === null && rightAmount === null) return 0; + if (leftAmount === null) return 1; + if (rightAmount === null) return -1; + return (leftAmount - rightAmount) * direction; }); }, [entries, sort]); - const totals = React.useMemo(() => rows.reduce( - (sum, row) => ({ - receivable: sum.receivable + row.receivable, - overdue: sum.overdue + row.overdue, - unallocated: sum.unallocated + row.unallocated, - }), - { receivable: 0, overdue: 0, unallocated: 0 }, - ), [rows]); + const groupedRows = React.useMemo( + () => groupClientRows(rows, groupLabels), + [rows, groupLabels], + ); const readable = rows.filter((row) => row.complete).length; - const largestExposure = Math.max(...rows.map((row) => row.receivable + row.unallocated), 0); + const largestExposure = Math.max( + ...rows.map((row) => row.receivable === null || row.unallocated === null ? 0 : row.receivable + row.unallocated), + 0, + ); + + const updateGroupLabel = React.useCallback((companyGuid: string, label: string) => { + setGroupLabels((current) => applyClientGroupLabel(current, companyGuid, label)); + }, []); + + const saveGroupLabel = React.useCallback((companyGuid: string, label: string) => { + const attemptedLabel = label.trim(); + setGroupLabelError(null); + void invoke("save_client_group_label", { + request: { company_guid: companyGuid, label: attemptedLabel }, + }) + .then(() => { + persistedGroupLabels.current = applyClientGroupLabel( + persistedGroupLabels.current, + companyGuid, + attemptedLabel, + ); + }) + .catch(() => { + setGroupLabels((current) => rollbackFailedClientGroupLabel( + current, + companyGuid, + attemptedLabel, + persistedGroupLabels.current, + )); + setGroupLabelError("Bridge could not save this group label. The previous label was restored; your figures are unchanged."); + }); + }, []); + + const renderRow = (row: (typeof rows)[number]) => { + const partial = row.reasonCode ? outstandingsPartialState(row.reasonCode) : null; + return ( + + ); + }; return (
@@ -210,11 +321,27 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P {entries && rows.length > 0 && ( <> -
-
Receivable{formatCompact(String(totals.receivable))}
-
Overdue 90+{formatCompact(String(totals.overdue))}
-
Unallocated{formatCompact(String(totals.unallocated))}
-
+
+
+

Client groups

+

Optional filing labels. Ungrouped clients stay separate.

+
+
+ {companies.map((company) => ( + + ))} +
+ {groupLabelError &&

{groupLabelError}

} +
@@ -234,59 +361,27 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P onClick={() => setSort((current) => current.key === key ? { key, desc: !current.desc } - // Money and age default to worst-first; a name defaults - // to A-Z, because "descending name" is never what is - // wanted on first click. : { key, desc: key !== "client" })} > {label} ))}
- {rows.map((row) => { - const partial = row.reasonCode ? outstandingsPartialState(row.reasonCode) : null; - return ( - - ); - })} + {formatCompact(group.totals.unallocated)} + +
+ {group.rows.map(renderRow)} + + ))} + {groupedRows.ungroupedRows.map(renderRow)} )} diff --git a/src/OutstandingsScreen.tsx b/src/OutstandingsScreen.tsx index 1ed4e16..69d344d 100644 --- a/src/OutstandingsScreen.tsx +++ b/src/OutstandingsScreen.tsx @@ -797,8 +797,14 @@ function comparePartiesBy(sort: PartySort) { } function amountOf(value: string) { - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? Math.abs(parsed) : 0; + if (!/^-?\d+(?:\.\d+)?$/.test(value)) { + throw new Error("Bridge could not read an outstandings amount."); + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error("Bridge could not read an outstandings amount."); + } + return parsed; } /// Ageing rows carry their own bar width. Widths are relative to the LARGEST diff --git a/src/client-grouping.ts b/src/client-grouping.ts new file mode 100644 index 0000000..110e4e9 --- /dev/null +++ b/src/client-grouping.ts @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 + +export type ClientGroupLabels = Record; + +export function applyClientGroupLabel( + labels: ClientGroupLabels, + companyGuid: string, + label: string, +): ClientGroupLabels { + const next = { ...labels }; + const normalized = label.trim(); + if (normalized) next[companyGuid] = normalized; + else delete next[companyGuid]; + return next; +} + +export function rollbackFailedClientGroupLabel( + current: ClientGroupLabels, + companyGuid: string, + attemptedLabel: string, + persisted: ClientGroupLabels, +): ClientGroupLabels { + if ((current[companyGuid] ?? "").trim() !== attemptedLabel.trim()) { + return current; + } + return applyClientGroupLabel(current, companyGuid, persisted[companyGuid] ?? ""); +} + +export type GroupableClientRow = { + companyGuid: string; + exactAmounts: { + receivable: string | undefined; + overdue: string | undefined; + unallocated: string | undefined; + }; +}; + +export type ClientGroup = { + label: string; + rows: Row[]; + totals: { receivable: string | undefined; overdue: string | undefined; unallocated: string | undefined }; +}; + +function totalRows(rows: readonly GroupableClientRow[]) { + return { + receivable: sumExactDecimals(rows.map((row) => row.exactAmounts.receivable)), + overdue: sumExactDecimals(rows.map((row) => row.exactAmounts.overdue)), + unallocated: sumExactDecimals(rows.map((row) => row.exactAmounts.unallocated)), + }; +} + +type ExactParts = { negative: boolean; whole: string; fraction: string }; + +function parseExactDecimal(value: string | undefined): ExactParts | undefined { + const match = value?.match(/^(-?)(\d+)(?:\.(\d+))?$/); + if (!match) return undefined; + return { negative: match[1] === "-", whole: match[2], fraction: match[3] ?? "" }; +} + +/// Adds source decimal strings with `BigInt`, never through IEEE-754. If any +/// row is not a valid exact decimal, the group total is unavailable rather than +/// rounded into a believable figure. +export function sumExactDecimals(values: readonly (string | undefined)[]): string | undefined { + const parsed = values.map(parseExactDecimal); + if (parsed.some((value) => value === undefined)) return undefined; + const exact = parsed as ExactParts[]; + const scale = Math.max(...exact.map((value) => value.fraction.length), 0); + const total = exact.reduce((sum, value) => { + const digits = `${value.whole}${value.fraction.padEnd(scale, "0")}`; + const scaled = BigInt(digits) * (value.negative ? -1n : 1n); + return sum + scaled; + }, 0n); + const negative = total < 0n; + const unsigned = (negative ? -total : total).toString().padStart(scale + 1, "0"); + const whole = scale === 0 ? unsigned : unsigned.slice(0, -scale); + const fraction = scale === 0 ? "" : unsigned.slice(-scale).replace(/0+$/, ""); + if (whole === "0" && !fraction) return "0"; + return `${negative ? "-" : ""}${whole}${fraction ? `.${fraction}` : ""}`; +} + +/// Groups only labeled rows. Ungrouped companies stay as individual rows so +/// the screen never presents a synthetic catch-all total. +export function groupClientRows( + rows: readonly Row[], + labels: ClientGroupLabels, +): { groups: ClientGroup[]; ungroupedRows: Row[] } { + const grouped = new Map(); + const ungroupedRows: Row[] = []; + + for (const row of rows) { + const label = labels[row.companyGuid]?.trim(); + if (!label) { + ungroupedRows.push(row); + continue; + } + const group = grouped.get(label) ?? []; + group.push(row); + grouped.set(label, group); + } + + const groups = [...grouped.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([label, groupRows]) => ({ label, rows: groupRows, totals: totalRows(groupRows) })); + + return { groups, ungroupedRows }; +} diff --git a/src/styles.css b/src/styles.css index ac9b9e2..ecd0518 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2255,33 +2255,53 @@ button.outstandings-party { background: #fff; } -.clients-totals { +.client-group-labels { display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + grid-template-columns: minmax(180px, 0.7fr) minmax(0, 2fr); + gap: 20px 30px; + padding: 20px 30px; border-bottom: 1px solid #e6ebe7; + background: #fbfcfb; } -.clients-totals div { - padding: 20px 30px; +.client-group-labels h3, +.client-group-labels p { + margin: 0; } -.clients-totals div + div { - border-left: 1px solid #e6ebe7; +.client-group-labels h3 { + color: #16241c; + font-size: 14px; } -.clients-totals span { - display: block; +.client-group-labels p, +.client-group-label-grid label > span { color: #61756a; - font-size: 13px; + font-size: 12px; +} + +.client-group-labels p { + margin-top: 4px; +} + +.client-group-label-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; } -.clients-totals strong { +.client-group-label-grid label > span { display: block; - margin-top: 8px; - font-size: 26px; - letter-spacing: -0.02em; - color: #16241c; - font-variant-numeric: tabular-nums; + margin-bottom: 4px; +} + +.client-group-label-grid input { + width: 100%; +} + +.client-group-label-error { + grid-column: 1 / -1; + color: #a13f32 !important; } .clients-table { @@ -2313,6 +2333,20 @@ button.outstandings-party { cursor: default; } +.clients-group-total { + min-height: 48px; + margin-top: 10px; + padding: 6px 10px; + border-radius: 7px; + background: #eef5f0; + color: #1f4f36; + cursor: default; +} + +.clients-group-total .clients-name em { + color: #587968; +} + .clients-row:not(.is-head):hover { background: #f7faf8; } @@ -2412,3 +2446,19 @@ button.outstandings-party { .clients-row:hover .clients-age svg { color: #1f6a4a; } + +@media (max-width: 520px) { + .client-group-labels { + grid-template-columns: 1fr; + padding: 20px; + } + + .client-group-label-grid { + grid-template-columns: 1fr; + } + + .clients-table { + padding-right: 20px; + padding-left: 20px; + } +}