Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6efb498409 | ||
|
|
ae80922d03 | ||
|
|
23dd283a42 |
@@ -1,84 +1,181 @@
|
|||||||
# Voiced
|
# Voiced
|
||||||
|
|
||||||
An offline-first desktop invoicing app for **Test Vendor**, built by **Bongbetic**.
|
An offline-first GST invoicing desktop app for **Test Vendor**, built by **Bongbetic**.
|
||||||
|
|
||||||
Voiced turns a few details into a clean, GST-ready PDF invoice. It keeps a running
|
Voiced turns a few details into a clean, print-ready PDF invoice. It keeps a running invoice-number series in a
|
||||||
invoice-number series in a local database, remembers your vendor and bank details, and
|
local database, remembers your vendor, client and bank details, and works without an account or a network
|
||||||
exports print-ready PDFs — no account, no network required.
|
connection. The only optional network feature is the ERPNext push, which you configure yourself.
|
||||||
|
|
||||||
|
Targets: Void Linux (xbps, WebKitGTK 2.50 or newer) and Windows 10/11 (NSIS installer, WebView2).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Automatic invoice numbering** — a persistent series (e.g. `INV/2026-001`) that keeps
|
- **Invoices.** Automatic numbering in a persistent series (for example `INV/2026-001`) with a one-click new series,
|
||||||
counting up, with a one-click "start a new series" that resets the counter.
|
drafts, issue and cancel. An issued invoice is immutable: it stores a snapshot of the vendor, client, GST figures
|
||||||
- **Guided first-run setup** — your name, address, contact, PAN, GSTIN, logo, signature and
|
and layout. Line items are a fixed amount or rate x quantity (per second, minute, hour, session or unit), with
|
||||||
bank details, pre-filled from your existing invoice and editable at any time.
|
item presets.
|
||||||
- **Two line-item modes** — a fixed amount, or a rate × quantity where the unit can be per
|
- **GST.** All money and tax maths is done in integer paise. CGST+SGST, IGST or UTGST follow from the supplier's
|
||||||
second / minute / hour / session / unit (e.g. 10 sessions × ₹2,500).
|
state and the place of supply, and a mismatch is rejected. An unregistered vendor gets a plain "Invoice" with no
|
||||||
- **GST support** — toggle taxes on/off and split as **CGST + SGST** (within state) or
|
tax. GSTIN, PAN, IFSC, email and phone are validated; amounts are written in Indian words.
|
||||||
**IGST** (inter-state), with live totals and Indian amount-in-words.
|
- **Templates.** 14 templates: Voiced Standard (Classic) and 13 designs measured from reference originals (Linea,
|
||||||
- **Signatures** — attach a PNG/JPEG per invoice, or omit it and the invoice notes that it is
|
Monogram, Serenity, Crimson Grid, Tangerine Ledger, Slate Band, Teal Swoosh, Purple Pop, Citrus Split, Monolith,
|
||||||
computer-generated and needs no signature.
|
Cobalt Stripe, Marble, Highlighter). Your logo fills the template's logo slot; a few templates offer an opt-in
|
||||||
- **PDF export** — a print-ready A4 invoice styled to match the original `format.pdf`.
|
logo position. Specs are in `docs/templates/`.
|
||||||
- **Saved clients**, invoice history with re-export, and light/dark (Carbon g10/g100) themes.
|
- **Page setup.** A4 or Letter, and margin presets (template, narrow 36 pt, normal 54 pt).
|
||||||
- **Input validation** for PAN, IFSC, GSTIN, email and phone.
|
- **Export modes.** *Searchable* PDF (text layer kept) or *flattened* (pages rasterised at 300 DPI and rebuilt as
|
||||||
|
an image-only PDF). Issued PDFs are archived by content hash, and "Original as issued" re-exports the exact
|
||||||
|
archived bytes.
|
||||||
|
- **Fonts.** 11 bundled font families (see "Fonts and licences"). The three commercial template faces (Now, Gotham,
|
||||||
|
Open Sauce One) can be overridden with your own licensed files under Settings, Fonts.
|
||||||
|
- **Clients and presets.** Saved clients with structured addresses, state and GST category; item presets.
|
||||||
|
- **Payments.** Record payments against an issued invoice, including TDS deducted by the client. Outstanding
|
||||||
|
balance is cash plus TDS against the invoice total.
|
||||||
|
- **History.** Filterable invoice list, detail view with payments, and CSV or JSON export of the list.
|
||||||
|
- **Backup and restore.** Manual and automatic daily backups of the database, assets, PDF archive and imported fonts.
|
||||||
|
- **Export for other software.** `voiced.invoice.v1` JSON and line-item CSV for selected invoices
|
||||||
|
(see `docs/voiced-invoice-v1.md`).
|
||||||
|
- **ERPNext integration.** Push an invoice (as draft or submitted) with its PDF attached, and record payments, to an
|
||||||
|
ERPNext v15 site.
|
||||||
|
- Light and dark (Carbon g10/g100) themes.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
| Layer | Choice |
|
| Layer | Choice |
|
||||||
| --------- | ------------------------------------------------------------------- |
|
| ------- | ------------------------------------------------------------------------------------------- |
|
||||||
| Shell | [Tauri v2](https://v2.tauri.app/) (Rust core, WebView2 on Windows) |
|
| Shell | [Tauri v2](https://v2.tauri.app/) (Rust core, WebKitGTK on Linux, WebView2 on Windows) |
|
||||||
| UI | React + TypeScript + [IBM Carbon Design System](https://carbondesignsystem.com/) (`@carbon/react`) |
|
| UI | React 19 + TypeScript + [IBM Carbon](https://carbondesignsystem.com/) (`@carbon/react`) |
|
||||||
| Fonts | IBM Plex, self-hosted (works offline) |
|
| Storage | SQLite via `rusqlite` (bundled), numbered migrations |
|
||||||
| Storage | SQLite via `rusqlite` (bundled) |
|
| PDF | `@react-pdf/renderer` 4.9.0 in a Web Worker, pdf.js for preview and flattening |
|
||||||
| PDF | `@react-pdf/renderer` with bundled IBM Plex |
|
| ERPNext | `reqwest` with rustls (ring provider); all HTTP runs in Rust |
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Node.js 20+** and npm
|
- **Node.js 22** and npm
|
||||||
- **Rust** (stable) via [rustup](https://rustup.rs/)
|
- **Rust** stable, via [rustup](https://rustup.rs/)
|
||||||
- Linux dev only: WebKitGTK 4.1, libsoup3, GTK3, librsvg, `pkg-config`, `clang`/`llvm`/`lld`
|
- Linux only: WebKitGTK 4.1, libsoup3, GTK3, librsvg, `pkg-config`, `clang`/`llvm`/`lld`
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run app:dev # launches the desktop app with hot reload
|
npm run app:dev # tauri dev, served from localhost:1420 (does not exercise the tauri:// protocol)
|
||||||
```
|
```
|
||||||
|
|
||||||
Other scripts:
|
| Command | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm run build` | `tsc --noEmit` and `vite build` (the type-check gate) |
|
||||||
|
| `npm test` | `vitest run`, about 7 minutes; run on a quiet machine because load starves the sweep tests |
|
||||||
|
| `npm run fonts:verify` | Checks the bundled fonts against `public/fonts/manifest.json` (`fonts:manifest` regenerates it) |
|
||||||
|
| `npm run logo:measure` | Regenerates `src/pdf/templates/slots.generated.ts` from the reference PDFs; must be idempotent |
|
||||||
|
| `npm run thumbnails` | Regenerates `public/templates/*.png` |
|
||||||
|
| `npm run templates:compare` | reference vs Voiced side-by-side PNGs under `/tmp` |
|
||||||
|
| `cargo test --manifest-path src-tauri/Cargo.toml` | Rust unit tests |
|
||||||
|
| `npm run app:build` | Production build for the current platform (custom-protocol) |
|
||||||
|
|
||||||
```bash
|
The release procedure, including the manual first-run checklist, is in `docs/RELEASE.md`.
|
||||||
npm run build # type-check + build the web frontend
|
|
||||||
npm run app:build # package the app for the current platform
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building for Windows (cross-compile from Linux/macOS)
|
## Where data lives
|
||||||
|
|
||||||
Tauri only supports the MSVC target. Cross-compiling uses [`cargo-xwin`](https://github.com/rust-cross/cargo-xwin)
|
The Tauri identifier is `com.bongbetic.voiced`. Voiced uses two directories, which differ only on Windows:
|
||||||
and produces an **NSIS installer** plus a portable `.exe`. A `.msi` still requires a real
|
|
||||||
Windows host.
|
| What | Linux | Windows |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `app_data_dir`: database `voiced.db`, `assets/` (logos, signatures), `backups/` (`auto/`, `pre-restore-*`, pre-migration copies) | `~/.local/share/com.bongbetic.voiced/` | `%APPDATA%\com.bongbetic.voiced\` |
|
||||||
|
| `app_local_data_dir`: `archive/<sha256>.pdf` (issued PDFs), `fonts/` (fonts you import) | `~/.local/share/com.bongbetic.voiced/` | `%LOCALAPPDATA%\com.bongbetic.voiced\` |
|
||||||
|
|
||||||
|
On Linux `XDG_DATA_HOME` moves both. On Windows the archive and imported fonts stay out of the roaming profile so a
|
||||||
|
growing archive and licensed font files are not copied between machines. The window size and position are kept by
|
||||||
|
the window-state plugin in the app config directory (`~/.config/com.bongbetic.voiced/` on Linux).
|
||||||
|
|
||||||
|
## Backup and restore
|
||||||
|
|
||||||
|
Settings, Data tab.
|
||||||
|
|
||||||
|
- **Backup now** writes a zip: a consistent snapshot of `voiced.db`, `assets/`, `archive/`, `fonts/` and a
|
||||||
|
`manifest.json` with a sha256 for every file.
|
||||||
|
- **Automatic backup.** Once per calendar day, while the app is open, Voiced writes
|
||||||
|
`backups/auto/voiced-auto-YYYYMMDD.zip` and keeps the newest 14.
|
||||||
|
- **Restore** validates the zip (manifest, hashes, database integrity check) and stages it. Nothing live changes until
|
||||||
|
the next start: use the "Restart" button or close and reopen the app. On that start the current database, assets,
|
||||||
|
archive and fonts are moved to `backups/pre-restore-<timestamp>/` (a safety copy that is never pruned), then the
|
||||||
|
restored files are moved in. If anything fails the app rolls back and starts on the old data. A backup from an
|
||||||
|
older schema is migrated after restoring.
|
||||||
|
- **Backups contain secrets.** The ERPNext API secret is stored in the SQLite database, so every backup (manual,
|
||||||
|
automatic and pre-restore) holds it in plain form, together with your clients, invoices and bank details. Keep
|
||||||
|
backup files on storage you trust, do not email or upload them casually, and rotate the ERPNext API key if a
|
||||||
|
backup is lost.
|
||||||
|
|
||||||
|
## Fonts and licences
|
||||||
|
|
||||||
|
Eleven families are bundled as static TTF files in `public/fonts/<family>/`, each with its `OFL.txt`: DM Sans,
|
||||||
|
IBM Plex Mono, IBM Plex Sans, Inter, Jost, Lustria, Montserrat, Noto Sans Devanagari, Noto Sans Kannada, Open Sans
|
||||||
|
and Poppins. All are under the SIL Open Font License 1.1. `public/fonts/manifest.json` lists every file with its
|
||||||
|
hash and metrics, and `npm run fonts:verify` checks it. PDFs embed subsets, so the app never depends on system
|
||||||
|
fonts.
|
||||||
|
|
||||||
|
The Void package also installs these fonts system-wide under `/usr/share/fonts/voiced/<family>/` and each OFL text
|
||||||
|
as `/usr/share/licenses/voiced/<family-folder>-OFL.txt`.
|
||||||
|
|
||||||
|
**Now, Gotham and Open Sauce One** are not bundled. Voiced draws Now with Jost, Gotham with Montserrat and Open
|
||||||
|
Sauce One with Poppins (Marble, Citrus Split and Purple Pop use these faces). If you hold a licence
|
||||||
|
for the real fonts, import the files under Settings, Fonts (TTF, OTF or WOFF, one file per weight; WOFF2, variable
|
||||||
|
and font-collection files are refused). Importing is your confirmation that your licence covers this use. The files
|
||||||
|
are stored on this computer only, are included in backups, and issued invoices keep the fonts they were issued with.
|
||||||
|
|
||||||
|
## ERPNext integration
|
||||||
|
|
||||||
|
Settings, Integrations. Verified live against ERPNext v15.121.6 (Frappe 15.121), with and without India Compliance
|
||||||
|
15.32. v14 and v16 have not been tested.
|
||||||
|
|
||||||
|
1. **Integration user.** In ERPNext create a dedicated user (type System User) with the roles **Accounts User** and
|
||||||
|
**Sales User**. Open the user, Settings tab, API Access, Generate Keys, and copy the secret once. Accounts User
|
||||||
|
covers invoices, payments and attachments; Sales User covers customers and addresses (Accounts User alone gets
|
||||||
|
HTTP 403 on Customer). Do not use Administrator or System Manager keys. If you change roles, run
|
||||||
|
`bench clear-cache`.
|
||||||
|
2. **Rounding.** In ERPNext System Settings set Rounding Method to **Commercial Rounding**. ERPNext defaults to
|
||||||
|
Banker's Rounding while Voiced rounds half-paise up, so some totals would differ by 1 paise. Voiced detects a
|
||||||
|
mismatch after creating the draft, keeps it as a draft, never submits it, and names this fix.
|
||||||
|
3. **Connect.** Enter the site address, API key and secret and run "Test connection". Use `https://` for real
|
||||||
|
servers; plain `http://` is accepted only for `localhost`, `127.0.0.1`, `*.localhost` and `*.test`. The secret is
|
||||||
|
never shown again, and it is not sent to a different server than the one it was saved for.
|
||||||
|
4. **Company, accounts and units.** Pick the company, the output tax accounts (CGST, SGST, IGST), the bank account for
|
||||||
|
payments and the unit-of-measure mapping from the loaded option lists.
|
||||||
|
5. **Naming.** *Mirror* (default) names the ERPNext document with the Voiced number, which needs a site with API v2
|
||||||
|
naming (Frappe 15.73 or newer). *Series* lets an ERPNext naming series name the document and puts the Voiced
|
||||||
|
number in the remarks.
|
||||||
|
6. **India Compliance.** Detected by the connection test. It requires a 6 or 8 digit HSN/SAC code on each row
|
||||||
|
(checked at submit), limits document names to 16 characters (mirror mode checks this before posting), and Voiced
|
||||||
|
always sends `is_reverse_charge` as 0 with a warning.
|
||||||
|
|
||||||
|
Not covered: overseas and SEZ customers, multi-currency, reverse charge booked on RCM accounts, e-invoice and
|
||||||
|
e-waybill, TLS-terminating proxies beyond plain HTTPS, and Windows. The live test setup is in
|
||||||
|
`scripts/erpnext-e2e/README.md`.
|
||||||
|
|
||||||
|
## Building for Windows (NSIS, cross-compiled from Linux)
|
||||||
|
|
||||||
|
Tauri supports only the MSVC target. [`cargo-xwin`](https://github.com/rust-cross/cargo-xwin) cross-compiles it
|
||||||
|
and Tauri produces an **NSIS installer** (a `.msi` needs a real Windows host).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# one-time setup
|
# one-time setup
|
||||||
rustup target add x86_64-pc-windows-msvc
|
rustup target add x86_64-pc-windows-msvc
|
||||||
cargo install --locked cargo-xwin
|
cargo install --locked cargo-xwin
|
||||||
# plus NSIS (makensis), LLVM/LLD and clang on the host
|
# plus makensis (NSIS), LLVM/LLD and clang on the host
|
||||||
|
|
||||||
npm run app:build:windows
|
npm run app:build:windows # embeds the small WebView2 bootstrapper (needs internet if WebView2 is missing)
|
||||||
|
npm run app:build:windows:offline # embeds the full WebView2 runtime (over 100 MB larger), for offline machines
|
||||||
```
|
```
|
||||||
|
|
||||||
Output lands in `src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/` and the
|
Output: `src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/Voiced_<version>_x64-setup.exe`, and the portable
|
||||||
portable binary in the sibling `release/` folder.
|
binary in the sibling `release/` folder. The installer is a per-user install (`installMode: currentUser`). The
|
||||||
|
frontend, fonts and pdf.js are compiled into the executable. Size budget: about 25 MB for the standard installer.
|
||||||
|
`cargo tree -i aws-lc-sys --target x86_64-pc-windows-msvc` must print nothing (the TLS stack is rustls with the ring
|
||||||
|
provider only, which cross-compiles cleanly).
|
||||||
|
|
||||||
### Notes for Linux hosts without an `nsis` package
|
Hosts without an `nsis` package (for example Void Linux):
|
||||||
|
|
||||||
`makensis` is required for the installer, and Tauri also fetches a small NSIS plugin. On
|
1. Provide `makensis` and put it on `PATH`.
|
||||||
distributions that don't package NSIS (e.g. Void Linux):
|
2. Point it at a full NSIS data directory (3.11 recommended, for `Win/RestartManager.nsh`) with `NSISDIR`.
|
||||||
|
|
||||||
1. Provide `makensis` (e.g. extract it from your distribution's or an upstream package) and
|
|
||||||
put it on `PATH`.
|
|
||||||
2. Point `makensis` at a full NSIS data directory (3.11 recommended, for `Win/RestartManager.nsh`);
|
|
||||||
it looks in the directory compiled into the binary unless `NSISDIR` is set.
|
|
||||||
3. Pre-seed Tauri's plugin cache so it does not have to download it:
|
3. Pre-seed Tauri's plugin cache so it does not have to download it:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -87,13 +184,10 @@ distributions that don't package NSIS (e.g. Void Linux):
|
|||||||
# from https://github.com/tauri-apps/nsis-tauri-utils/releases
|
# from https://github.com/tauri-apps/nsis-tauri-utils/releases
|
||||||
```
|
```
|
||||||
|
|
||||||
With those in place, `npm run app:build:windows` produces the NSIS `-setup.exe` and the
|
The installer has not been run on a real Windows machine.
|
||||||
portable `voiced.exe`.
|
|
||||||
|
|
||||||
## Installing on Void Linux
|
## Installing on Void Linux
|
||||||
|
|
||||||
Two ways to get a native `.xbps` package.
|
|
||||||
|
|
||||||
### Prebuilt binary package (no xbps-src needed)
|
### Prebuilt binary package (no xbps-src needed)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -102,52 +196,91 @@ echo "repository=$(pwd)/build/void/repo" | sudo tee /etc/xbps.d/99-voiced-local.
|
|||||||
sudo xbps-install -Sy voiced
|
sudo xbps-install -Sy voiced
|
||||||
```
|
```
|
||||||
|
|
||||||
Launch **Voiced** from the application menu, or run `voiced`. Remove it with
|
The script runs `npx tauri build --no-bundle --features custom-protocol` (using your existing `node_modules`), stages
|
||||||
`sudo xbps-remove voiced`.
|
the install tree and runs `xbps-create` and `xbps-rindex`. Launch **Voiced** from the application menu or run
|
||||||
|
`voiced`; remove it with `sudo xbps-remove voiced`.
|
||||||
|
|
||||||
The package installs `/usr/bin/voiced`, a desktop entry and hicolor icons, and depends on
|
The package installs `/usr/bin/voiced`, a desktop entry, hicolor icons, the bundled fonts under
|
||||||
`libwebkit2gtk41`.
|
`/usr/share/fonts/voiced/<family>/`, the OFL texts under `/usr/share/licenses/voiced/`, and `INSTALL` and `REMOVE`
|
||||||
|
action scripts that run `fc-cache -f /usr/share/fonts/voiced` after install and removal. The `voiced/` font
|
||||||
**Fonts on Void.** The package also installs the bundled TTF fonts under
|
subdirectory avoids file conflicts with Void's `font-ibm-plex-ttf` and `noto-fonts-ttf`. It depends on
|
||||||
`/usr/share/fonts/voiced/<family>/` and each family's OFL licence under
|
`libwebkit2gtk41>=2.50`, `fontconfig`, `xdg-utils`, `hicolor-icon-theme` and `desktop-file-utils`.
|
||||||
`/usr/share/licenses/voiced/`. The `voiced/` subdirectory avoids file conflicts with Void's own
|
|
||||||
`font-ibm-plex-ttf` and `noto-fonts-ttf`. The `INSTALL` and `REMOVE` scripts in `packaging/void/`
|
|
||||||
run `fc-cache -f /usr/share/fonts/voiced` after install and removal. `xbps-create` packs them from
|
|
||||||
the destdir root (checked with xbps 0.60.7).
|
|
||||||
|
|
||||||
### Building through xbps-src
|
### Building through xbps-src
|
||||||
|
|
||||||
Copy `packaging/void/template` into a `void-packages` checkout and build it the standard way:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p void-packages/srcpkgs/voiced
|
mkdir -p void-packages/srcpkgs/voiced
|
||||||
cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE void-packages/srcpkgs/voiced/
|
cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE void-packages/srcpkgs/voiced/
|
||||||
cd void-packages && ./xbps-src pkg voiced
|
cd void-packages && ./xbps-src pkg voiced
|
||||||
```
|
```
|
||||||
|
|
||||||
See the comments at the top of the template about the private source archive and checksums.
|
`INSTALL` and `REMOVE` must sit next to the template; xbps-src packs them. The source is a tagged archive of the
|
||||||
|
private repository (see the comments at the top of the template; `checksum=SKIP` until a tarball is published).
|
||||||
## Where data lives
|
xbps-src builds in a network-less chroot, so vendor the dependencies first: run `cargo vendor` in `src-tauri/` (with a
|
||||||
|
`.cargo/config.toml` pointing at `vendor/`) and populate the npm cache, and include the results in the source
|
||||||
| What | Location |
|
archive. The template runs `npm ci` (not `--omit=optional`: that removes
|
||||||
| ------------------- | ------------------------------------------------------------- |
|
the native rollup and esbuild binaries the Vite build needs, and `@napi-rs/canvas` is type-checked by `tsc`).
|
||||||
| Database | `<app-data>/voiced.db` — on Windows `%APPDATA%\com.bongbetic.voiced\` |
|
No OpenSSL is needed (rustls), and the
|
||||||
| Logos & signatures | `<app-data>/assets/` |
|
dependencies of the ERPNext and backup code (`reqwest`, `zip`) are pure Rust.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/ React + Carbon frontend
|
src/ React + Carbon frontend
|
||||||
views/ Onboarding · NewInvoice · InvoiceHistory · SeriesSettings · AppSettings
|
views/ Onboarding, NewInvoice, InvoiceHistory, InvoiceDetail, Clients, AppSettings, SeriesSettings
|
||||||
components/ AppShell · InvoicePreview · ImagePicker
|
components/ AppShell, TemplatePicker, PageSetupControls, PdfPreview, LogoBranding,
|
||||||
pdf/ InvoiceDocument (react-pdf template)
|
FontOverrideTable, DataBackupPanel, ErpnextSettingsPanel, RecordPaymentModal, ...
|
||||||
lib/ api · types · invoice · numberToWords · validators · format · pdf
|
hooks/ Export, PDF preview, user fonts, shortcuts
|
||||||
src-tauri/ Rust backend (Tauri commands, SQLite schema, asset store)
|
lib/ Typed Tauri wrapper (api.ts), money, GST helpers, exportFlow, historyExport,
|
||||||
public/brand/ Bongbetic + vendor brand assets
|
invoiceExportV1, ERPNext UI helpers
|
||||||
public/fonts/ IBM Plex (woff/woff2)
|
pdf/ PDF engine
|
||||||
packaging/void/ Void Linux packaging (xbps-src template + build script + desktop entry)
|
model/ buildRenderModel (invoice -> render model)
|
||||||
|
engine/ geometry (A4/Letter, margins), logo slots, layout fingerprint
|
||||||
|
templates/ <family>/ with tokens.ts, plan.ts, Layout.tsx; registry.ts, catalog.ts, contract.ts
|
||||||
|
blocks/ decor/ Shared layout blocks and decor
|
||||||
|
fonts/ Font registration, roles, glyph priming
|
||||||
|
render/ renderCore, Web Worker, latest-wins client
|
||||||
|
flatten.ts assemble.ts Rasterise at 300 DPI and rebuild an image-only PDF
|
||||||
|
src-tauri/src/ Rust backend
|
||||||
|
db.rs Schema as numbered migrations (copies the DB aside before migrating)
|
||||||
|
gst.rs Integer-paise money and GST maths
|
||||||
|
logo.rs Logo trimming, quantising, measuring
|
||||||
|
commands/ Tauri commands: invoice, series, clients, payments, presets, settings, assets,
|
||||||
|
archive, fonts, backup, files, raw (binary IPC), erpnext, logo
|
||||||
|
integrations/erpnext/ ERPNext client, mapping, discovery, push
|
||||||
|
selftest.rs Unattended real-webview self-test
|
||||||
|
public/fonts/ Bundled TTF families, OFL texts, manifest.json (plus legacy IBM Plex web fonts)
|
||||||
|
public/templates/ Template thumbnails
|
||||||
|
docs/ Template specs, voiced.invoice.v1, release checklist
|
||||||
|
scripts/ Font, logo, template, thumbnail and ERPNext end-to-end tooling
|
||||||
|
packaging/void/ xbps-src template, build-xbps.sh, desktop entry, INSTALL and REMOVE scripts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Tests and gates
|
||||||
|
|
||||||
|
Before a release (details in `docs/RELEASE.md`): `npm run build`, `npm test`, `npm run fonts:verify`,
|
||||||
|
`cargo test --manifest-path src-tauri/Cargo.toml`, and the real-webview self-test on a debug or installed build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run tauri -- build --debug --no-bundle --features custom-protocol
|
||||||
|
VOICED_SELFTEST_OUT=/tmp/voiced-e2e/report.json XDG_DATA_HOME=/tmp/voiced-e2e/data \
|
||||||
|
XDG_CONFIG_HOME=/tmp/voiced-e2e/config timeout 180 src-tauri/target/debug/voiced
|
||||||
|
```
|
||||||
|
|
||||||
|
The app is inert unless `VOICED_SELFTEST_OUT` is set; `ok: true` is expected in the report. The same checks run from
|
||||||
|
Settings, Diagnostics.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- Windows and WebView2 have never been run on a real machine; the NSIS installer is cross-compiled only.
|
||||||
|
- The 13 reference-based templates are re-creations of reference designs. the reference design's content licence may restrict this, so
|
||||||
|
ship them only in this vendor's build until it is confirmed.
|
||||||
|
- The SAC code for the vendor's services and the signature rule still need confirmation from a chartered accountant.
|
||||||
|
- Now, Gotham and Open Sauce One are replaced by free look-alikes unless you import licensed files.
|
||||||
|
- ERPNext: only v15 is verified; HTTP is allowed only for local hosts; see "Not covered" above.
|
||||||
|
- Not implemented (deferred): copies and watermarks, PDF/A, custom margins, user background textures, SVG logo
|
||||||
|
import, CESS, composition and SEZ invoices.
|
||||||
|
|
||||||
## Ownership
|
## Ownership
|
||||||
|
|
||||||
© Bongbetic. All rights reserved. This is a private application.
|
© Bongbetic. All rights reserved. This is a private application.
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Release checklist
|
||||||
|
|
||||||
|
Run from the repository root on a quiet machine, on a branch cut from `main`.
|
||||||
|
|
||||||
|
## 1. Version
|
||||||
|
|
||||||
|
Keep these in step: `package.json` and the root entry of `package-lock.json`, `src-tauri/Cargo.toml` and
|
||||||
|
`src-tauri/Cargo.lock` (`cargo update --offline -p voiced`), `src-tauri/tauri.conf.json`, and `version=` plus
|
||||||
|
`revision=` in `packaging/void/template`. The PDF Creator string follows `package.json` (`src/lib/pdf.tsx`); layout
|
||||||
|
fingerprints do not include it, so goldens stay unchanged.
|
||||||
|
|
||||||
|
## 2. Gates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm test # about 7 minutes
|
||||||
|
npm run fonts:verify
|
||||||
|
cargo test --manifest-path src-tauri/Cargo.toml
|
||||||
|
cargo tree -i aws-lc-sys --manifest-path src-tauri/Cargo.toml # must not match
|
||||||
|
cargo tree -i aws-lc-sys --manifest-path src-tauri/Cargo.toml --target x86_64-pc-windows-msvc # must not match
|
||||||
|
```
|
||||||
|
|
||||||
|
Real-webview self-test on the debug build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run tauri -- build --debug --no-bundle --features custom-protocol
|
||||||
|
VOICED_SELFTEST_OUT=/tmp/voiced-e2e/report.json XDG_DATA_HOME=/tmp/voiced-e2e/data \
|
||||||
|
XDG_CONFIG_HOME=/tmp/voiced-e2e/config timeout 180 src-tauri/target/debug/voiced
|
||||||
|
```
|
||||||
|
|
||||||
|
`/tmp/voiced-e2e/report.json` must contain `"ok": true`. Repeat the same check on each installed build with
|
||||||
|
Settings, Diagnostics.
|
||||||
|
|
||||||
|
## 3. Void package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./packaging/void/build-xbps.sh
|
||||||
|
tar --zstd -tf build/void/repo/voiced-<version>_1.x86_64.xbps | less
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect `./usr/bin/voiced`, the desktop file, hicolor icons, `usr/share/fonts/voiced/<family>/*.ttf`,
|
||||||
|
`usr/share/licenses/voiced/*-OFL.txt`, `./INSTALL` and `./REMOVE`. Then install it into a clean container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman run --rm -v "$PWD/build/void/repo:/repo:Z" ghcr.io/void-linux/void-glibc-full sh -c '
|
||||||
|
xbps-install -Syu xbps && xbps-install -y -R /repo voiced &&
|
||||||
|
fc-list | grep -iE "montserrat|poppins|lustria|dm sans|inter|jost|open sans|ibm plex" &&
|
||||||
|
ls /usr/share/licenses/voiced && ldd /usr/bin/voiced | grep "not found"; xbps-remove -y voiced'
|
||||||
|
```
|
||||||
|
|
||||||
|
`ldd` must print no `not found` line, and `xbps-remove` must finish without errors. Optionally build with
|
||||||
|
`xbps-src` in a `void-packages` checkout (see the README).
|
||||||
|
|
||||||
|
## 4. Windows installer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run app:build:windows # standard installer
|
||||||
|
npm run app:build:windows:offline # only for machines without internet
|
||||||
|
```
|
||||||
|
|
||||||
|
Needs `cargo-xwin`, `makensis` and the NSIS plugin cache (README, "Building for Windows"). Check the size of
|
||||||
|
`src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe` against the budget of about 25 MB. Optional:
|
||||||
|
sign it with `osslsigncode`. Never commit build outputs (`build/`, `dist/`, `src-tauri/target/`).
|
||||||
|
|
||||||
|
## 5. Manual first-run checklist (on a real machine, installed build, not `app:dev`)
|
||||||
|
|
||||||
|
Linux and Windows. Windows has never been run on real hardware, so treat it as untested until this passes.
|
||||||
|
|
||||||
|
- **Onboarding and Branding.** Complete first-run setup; upload a wide and a square logo, check the dark-theme
|
||||||
|
wordmark and the signature.
|
||||||
|
- **Template picker.** Open all 14 templates; switch A4 and Letter and the margin presets; check the logo slots.
|
||||||
|
- **Export.** Issue an invoice; export searchable and flattened; change settings and re-export; "Original as issued"
|
||||||
|
must be byte-identical to the archived PDF. On Windows, check file names with `/`, `:` and reserved names.
|
||||||
|
- **Fonts.** Settings, Fonts: import a TTF for Now, Gotham or Open Sauce One (if a licensed file is at hand), confirm
|
||||||
|
a template that uses it changes, remove it again.
|
||||||
|
- **Clients.** Create a client with structured address and GST category; use it on an invoice.
|
||||||
|
- **Settings tabs.** Business, Branding, Bank and payments, Invoice defaults, Numbering, Templates and export, Fonts,
|
||||||
|
Data, Integrations: open each and save a change.
|
||||||
|
- **History, detail and payments.** Filter the list; open an invoice; record a payment with TDS; cancel one; export
|
||||||
|
CSV, JSON and `voiced.invoice.v1`.
|
||||||
|
- **Data.** Backup now, change something, restore the backup, restart, confirm the data returned and that
|
||||||
|
`backups/pre-restore-*` exists. Confirm the archive and fonts directories (Windows: under `%LOCALAPPDATA%`).
|
||||||
|
- **Integrations.** Test connection and push an invoice to a test ERPNext (README, "ERPNext integration").
|
||||||
|
- **Single instance.** Launch a second copy: the first window must come to the front.
|
||||||
|
|
||||||
|
## 6. Reference licence
|
||||||
|
|
||||||
|
The 13 reference-based templates are re-creations of reference designs. the reference design's content licence may restrict that. Until it
|
||||||
|
is confirmed in writing, distribute builds containing those templates only to this vendor. If the answer is no,
|
||||||
|
remove the templates from `src/pdf/templates/registry.ts` and `catalog.ts` (Classic stays) and rebuild. Also still
|
||||||
|
open: a chartered accountant's sign-off on the SAC code and the signature rule.
|
||||||
+11
@@ -6,6 +6,17 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<title>Voiced</title>
|
<title>Voiced</title>
|
||||||
|
<script>
|
||||||
|
// Apply the last-used theme before first paint so a dark theme never flashes white.
|
||||||
|
(function () {
|
||||||
|
var t = "g10";
|
||||||
|
try {
|
||||||
|
t = localStorage.getItem("voiced.theme") === "g100" ? "g100" : "g10";
|
||||||
|
} catch (e) {}
|
||||||
|
document.documentElement.classList.add("cds--" + t);
|
||||||
|
document.documentElement.setAttribute("data-carbon-theme", t);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "voiced",
|
"name": "voiced",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "voiced",
|
"name": "voiced",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@carbon/icons-react": "^11.89.0",
|
"@carbon/icons-react": "^11.89.0",
|
||||||
"@carbon/react": "^1.117.0",
|
"@carbon/react": "^1.117.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "voiced",
|
"name": "voiced",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"description": "Voiced — offline invoicing desktop app by Bongbetic",
|
"description": "Voiced — offline invoicing desktop app by Bongbetic",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "Bongbetic",
|
"author": "Bongbetic",
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ mkdir -p "$REPO"
|
|||||||
mv -f "$OUT"/${PKGVER}.*.xbps "$REPO/"
|
mv -f "$OUT"/${PKGVER}.*.xbps "$REPO/"
|
||||||
|
|
||||||
echo "==> Indexing local repository"
|
echo "==> Indexing local repository"
|
||||||
xbps-rindex -a "$REPO"/*.xbps
|
# -f re-adds a package whose version is already indexed. Without it a rebuild of the
|
||||||
|
# same version keeps the old checksum in the index and xbps-install rejects the new file.
|
||||||
|
xbps-rindex -f -a "$REPO"/*.xbps
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Package: $REPO/${PKGVER}.${ARCH}.xbps"
|
echo "Package: $REPO/${PKGVER}.${ARCH}.xbps"
|
||||||
|
|||||||
+10
-4
@@ -21,11 +21,14 @@
|
|||||||
# not install them. Copy them next to the template (see the commands above).
|
# not install them. Copy them next to the template (see the commands above).
|
||||||
# The stock x11-fonts trigger enabled by font_dirs does not run fc-cache
|
# The stock x11-fonts trigger enabled by font_dirs does not run fc-cache
|
||||||
# unless mkfontdir and mkfontscale are installed, hence these scripts.
|
# unless mkfontdir and mkfontscale are installed, hence these scripts.
|
||||||
# * If the build machine is offline, pre-populate the npm cache and Cargo
|
# * xbps-src builds without network access: vendor the crates (`cargo vendor`
|
||||||
# registry in the masterdir before building.
|
# in src-tauri/ plus a .cargo/config.toml pointing at vendor/) and pre-populate
|
||||||
|
# the npm cache in the source archive or masterdir before building.
|
||||||
|
# * No openssl-devel: the ERPNext client uses rustls (ring); reqwest and zip are
|
||||||
|
# pure Rust and need no extra system libraries.
|
||||||
#
|
#
|
||||||
pkgname=voiced
|
pkgname=voiced
|
||||||
version=0.1.0
|
version=1.0.0
|
||||||
revision=1
|
revision=1
|
||||||
archs="x86_64*"
|
archs="x86_64*"
|
||||||
hostmakedepends="rust nodejs pkg-config"
|
hostmakedepends="rust nodejs pkg-config"
|
||||||
@@ -43,7 +46,10 @@ compression="zstd"
|
|||||||
font_dirs="/usr/share/fonts/voiced"
|
font_dirs="/usr/share/fonts/voiced"
|
||||||
|
|
||||||
do_build() {
|
do_build() {
|
||||||
npm install --include=dev --no-audit --no-fund
|
# Not --omit=optional: that also drops rollup's and esbuild's native binaries (they are
|
||||||
|
# optional dependencies), so vite fails; and tsc in `npm run build` type-checks the
|
||||||
|
# @napi-rs/canvas test helpers, so that package must be installed too.
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
npm run build
|
npm run build
|
||||||
cd src-tauri
|
cd src-tauri
|
||||||
# `custom-protocol` embeds the frontend for production; without it the app
|
# `custom-protocol` embeds the frontend for production; without it the app
|
||||||
|
|||||||
Generated
+1
-1
@@ -4647,7 +4647,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "voiced"
|
name = "voiced"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "voiced"
|
name = "voiced"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
description = "Voiced — offline invoicing desktop app by Bongbetic"
|
description = "Voiced — offline invoicing desktop app by Bongbetic"
|
||||||
authors = ["Bongbetic"]
|
authors = ["Bongbetic"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Voiced",
|
"productName": "Voiced",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"identifier": "com.bongbetic.voiced",
|
"identifier": "com.bongbetic.voiced",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+14
-7
@@ -11,6 +11,7 @@ import Clients from "./views/Clients";
|
|||||||
import { ToastProvider, useToast } from "./components/ToastProvider";
|
import { ToastProvider, useToast } from "./components/ToastProvider";
|
||||||
import AppSettings from "./views/AppSettings";
|
import AppSettings from "./views/AppSettings";
|
||||||
import { requestSettingsTab } from "./lib/settingsTab";
|
import { requestSettingsTab } from "./lib/settingsTab";
|
||||||
|
import { applyDocumentTheme, cachedTheme, normalizeTheme } from "./lib/theme";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -46,9 +47,15 @@ function AppInner() {
|
|||||||
void refreshSettings();
|
void refreshSettings();
|
||||||
}, [refreshSettings]);
|
}, [refreshSettings]);
|
||||||
|
|
||||||
|
// Before settings load (and on the error screen) use the cached theme so the window never flashes white.
|
||||||
|
const theme = settings ? normalizeTheme(settings.theme) : cachedTheme();
|
||||||
|
useEffect(() => {
|
||||||
|
applyDocumentTheme(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Theme theme="g10">
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<div className="voiced-empty">
|
<div className="voiced-empty">
|
||||||
<Loading description="Loading Voiced" withOverlay={false} small />
|
<Loading description="Loading Voiced" withOverlay={false} small />
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +65,7 @@ function AppInner() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Theme theme="g10">
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<div className="voiced-page">
|
<div className="voiced-page">
|
||||||
<InlineNotification
|
<InlineNotification
|
||||||
kind="error"
|
kind="error"
|
||||||
@@ -74,11 +81,9 @@ function AppInner() {
|
|||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
const theme = settings.theme === "g100" ? "g100" : "g10";
|
|
||||||
|
|
||||||
if (!settings.onboarded) {
|
if (!settings.onboarded) {
|
||||||
return (
|
return (
|
||||||
<Theme theme={theme}>
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<Onboarding
|
<Onboarding
|
||||||
initial={settings}
|
initial={settings}
|
||||||
onDone={(next) => {
|
onDone={(next) => {
|
||||||
@@ -106,8 +111,8 @@ function AppInner() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Theme theme={theme}>
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}>
|
<AppShell view={view} onNavigate={setView} theme={theme} onToggleTheme={toggleTheme}>
|
||||||
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
|
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
|
||||||
<NewInvoice
|
<NewInvoice
|
||||||
settings={settings}
|
settings={settings}
|
||||||
@@ -116,6 +121,8 @@ function AppInner() {
|
|||||||
onActivate={() => setView("new")}
|
onActivate={() => setView("new")}
|
||||||
openDraftId={openDraftId}
|
openDraftId={openDraftId}
|
||||||
onDraftOpened={() => setOpenDraftId(null)}
|
onDraftOpened={() => setOpenDraftId(null)}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={toggleTheme}
|
||||||
/>
|
/>
|
||||||
<div hidden={view !== "invoices"}>
|
<div hidden={view !== "invoices"}>
|
||||||
<InvoiceHistory
|
<InvoiceHistory
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: A
|
|||||||
))}
|
))}
|
||||||
</HeaderNavigation>
|
</HeaderNavigation>
|
||||||
<HeaderGlobalBar>
|
<HeaderGlobalBar>
|
||||||
<HeaderGlobalAction aria-label="Toggle colour theme" onClick={onToggleTheme}>
|
<HeaderGlobalAction
|
||||||
|
aria-label={theme === "g100" ? "Switch to light theme" : "Switch to dark theme"}
|
||||||
|
tooltipAlignment="end"
|
||||||
|
onClick={onToggleTheme}
|
||||||
|
>
|
||||||
{theme === "g100" ? <Light size={20} /> : <Asleep size={20} />}
|
{theme === "g100" ? <Light size={20} /> : <Asleep size={20} />}
|
||||||
</HeaderGlobalAction>
|
</HeaderGlobalAction>
|
||||||
</HeaderGlobalBar>
|
</HeaderGlobalBar>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useEffect, useState, type RefObject } from "react";
|
||||||
|
import { Column, Grid, Modal, TextInput, Toggle } from "@carbon/react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import type { BankAccount } from "../lib/types";
|
||||||
|
import { ifscError } from "../lib/validators";
|
||||||
|
import { useToast } from "./ToastProvider";
|
||||||
|
|
||||||
|
export const blankBank = (): BankAccount => ({
|
||||||
|
id: null,
|
||||||
|
label: "",
|
||||||
|
bankName: "",
|
||||||
|
accountName: "",
|
||||||
|
accountNo: "",
|
||||||
|
branch: "",
|
||||||
|
ifsc: "",
|
||||||
|
isDefault: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface BankAccountModalProps {
|
||||||
|
open: boolean;
|
||||||
|
/** The account to edit; null opens a blank one. */
|
||||||
|
bank: BankAccount | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (bank: BankAccount) => void;
|
||||||
|
/** Focus returns here when the modal closes. */
|
||||||
|
launcherRef?: RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BankAccountModal({ open, bank, onClose, onSaved, launcherRef }: BankAccountModalProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [form, setForm] = useState<BankAccount>(blankBank());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [touched, setTouched] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setForm(bank ? { ...bank } : blankBank());
|
||||||
|
setBusy(false);
|
||||||
|
setTouched(false);
|
||||||
|
}, [open, bank]);
|
||||||
|
|
||||||
|
const set = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
const bankNameProblem = form.bankName.trim() ? undefined : "Bank name is required";
|
||||||
|
const accountNoProblem = form.accountNo.trim() ? undefined : "Account number is required";
|
||||||
|
const ifscProblem = ifscError(form.ifsc);
|
||||||
|
const blocking = bankNameProblem ?? accountNoProblem ?? ifscProblem;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setTouched(true);
|
||||||
|
if (blocking) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const saved = await api.saveBank({
|
||||||
|
...form,
|
||||||
|
bankName: form.bankName.trim(),
|
||||||
|
accountNo: form.accountNo.trim(),
|
||||||
|
ifsc: form.ifsc.trim().toUpperCase(),
|
||||||
|
});
|
||||||
|
toast.success(form.id === null ? "Bank account added" : "Bank account saved", saved.bankName);
|
||||||
|
onSaved(saved);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not save bank account", String(e));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
modalHeading={bank?.id ? "Edit bank account" : "New bank account"}
|
||||||
|
primaryButtonText={busy ? "Saving…" : "Save account"}
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
primaryButtonDisabled={busy}
|
||||||
|
onRequestClose={onClose}
|
||||||
|
onRequestSubmit={() => void submit()}
|
||||||
|
launcherButtonRef={launcherRef}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Grid narrow>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-label"
|
||||||
|
labelText="Label (optional)"
|
||||||
|
value={form.label}
|
||||||
|
onChange={(e) => set("label", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-bankName"
|
||||||
|
labelText="Bank name"
|
||||||
|
value={form.bankName}
|
||||||
|
onChange={(e) => set("bankName", e.target.value)}
|
||||||
|
invalid={touched && Boolean(bankNameProblem)}
|
||||||
|
invalidText={bankNameProblem}
|
||||||
|
data-modal-primary-focus
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-accountName"
|
||||||
|
labelText="Account name"
|
||||||
|
value={form.accountName}
|
||||||
|
onChange={(e) => set("accountName", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-accountNo"
|
||||||
|
labelText="Account number"
|
||||||
|
value={form.accountNo}
|
||||||
|
onChange={(e) => set("accountNo", e.target.value)}
|
||||||
|
invalid={touched && Boolean(accountNoProblem)}
|
||||||
|
invalidText={accountNoProblem}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-branch"
|
||||||
|
labelText="Branch"
|
||||||
|
value={form.branch}
|
||||||
|
onChange={(e) => set("branch", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-ifsc"
|
||||||
|
labelText="IFSC"
|
||||||
|
value={form.ifsc}
|
||||||
|
onChange={(e) => set("ifsc", e.target.value.toUpperCase())}
|
||||||
|
invalid={Boolean(ifscProblem)}
|
||||||
|
invalidText={ifscProblem}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={8} lg={16}>
|
||||||
|
<Toggle
|
||||||
|
id="bm-default"
|
||||||
|
labelText="Use as default account"
|
||||||
|
toggled={form.isDefault}
|
||||||
|
onToggle={(value) => set("isDefault", value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export type ThemeName = "g10" | "g100";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "voiced.theme";
|
||||||
|
|
||||||
|
export const normalizeTheme = (value: string | null | undefined): ThemeName => (value === "g100" ? "g100" : "g10");
|
||||||
|
|
||||||
|
/** The theme last applied on this machine; used before settings load so there is no light flash. */
|
||||||
|
export function cachedTheme(): ThemeName {
|
||||||
|
try {
|
||||||
|
return normalizeTheme(localStorage.getItem(STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
return "g10";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts the Carbon theme tokens on <html> as well as on the <Theme> zone, so the page canvas, the
|
||||||
|
* overscroll area and any window area below the content follow the chosen theme.
|
||||||
|
* index.html runs the same class switch before React mounts.
|
||||||
|
*/
|
||||||
|
export function applyDocumentTheme(theme: ThemeName): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.remove("cds--g10", "cds--g100");
|
||||||
|
root.classList.add(`cds--${theme}`);
|
||||||
|
root.dataset.carbonTheme = theme;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
} catch {
|
||||||
|
/* storage may be blocked; the class is already applied */
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-4
@@ -100,18 +100,49 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- App shell ---------- */
|
/* ---------- App shell ---------- */
|
||||||
html,
|
// The Carbon theme tokens are also set on <html> (see lib/theme.ts), so the canvas follows the theme
|
||||||
body,
|
// even where the <Theme> zone does not reach (below short content, overscroll, window resize).
|
||||||
#root {
|
html {
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
|
background-color: var(--cds-background);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.cds--g100 {
|
||||||
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
background-color: var(--cds-background);
|
background-color: var(--cds-background);
|
||||||
color: var(--cds-text-primary);
|
color: var(--cds-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The theme zone fills the window, so the dark theme covers it entirely.
|
||||||
|
.voiced-theme {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
background-color: var(--cds-background);
|
||||||
|
color: var(--cds-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the header actions inside the window edges.
|
||||||
|
.cds--header {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 100vw;
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.voiced-brand {
|
.voiced-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+34
-110
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Column,
|
Column,
|
||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
type Settings,
|
type Settings,
|
||||||
type TaxType,
|
type TaxType,
|
||||||
} from "../lib/types";
|
} from "../lib/types";
|
||||||
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
|
import { emailError, 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 DataBackupPanel from "../components/DataBackupPanel";
|
||||||
@@ -45,19 +45,9 @@ import ErpnextSettingsPanel from "../components/ErpnextSettingsPanel";
|
|||||||
import { FontOverrideTable } from "../components/FontOverrideTable";
|
import { FontOverrideTable } from "../components/FontOverrideTable";
|
||||||
import { useToast } from "../components/ToastProvider";
|
import { useToast } from "../components/ToastProvider";
|
||||||
import { SETTINGS_TABS, loadSettingsTab, saveSettingsTab } from "../lib/settingsTab";
|
import { SETTINGS_TABS, loadSettingsTab, saveSettingsTab } from "../lib/settingsTab";
|
||||||
|
import BankAccountModal from "../components/BankAccountModal";
|
||||||
import SeriesSettings from "./SeriesSettings";
|
import SeriesSettings from "./SeriesSettings";
|
||||||
|
|
||||||
const blankBank = (): BankAccount => ({
|
|
||||||
id: null,
|
|
||||||
label: "",
|
|
||||||
bankName: "",
|
|
||||||
accountName: "",
|
|
||||||
accountNo: "",
|
|
||||||
branch: "",
|
|
||||||
ifsc: "",
|
|
||||||
isDefault: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function AppSettings({
|
export default function AppSettings({
|
||||||
settings,
|
settings,
|
||||||
onSaved,
|
onSaved,
|
||||||
@@ -68,7 +58,9 @@ export default function AppSettings({
|
|||||||
const [form, setForm] = useState<Settings>({ ...settings });
|
const [form, setForm] = useState<Settings>({ ...settings });
|
||||||
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
||||||
const [banks, setBanks] = useState<BankAccount[]>([]);
|
const [banks, setBanks] = useState<BankAccount[]>([]);
|
||||||
|
const [bankModalOpen, setBankModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<BankAccount | null>(null);
|
const [editing, setEditing] = useState<BankAccount | null>(null);
|
||||||
|
const bankLauncher = useRef<HTMLElement | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
// One form state for every tab and one Save button, so edits survive switching tabs.
|
// One form state for every tab and one Save button, so edits survive switching tabs.
|
||||||
@@ -94,9 +86,6 @@ export default function AppSettings({
|
|||||||
const set = <K extends keyof Settings>(key: K, value: Settings[K]) =>
|
const set = <K extends keyof Settings>(key: K, value: Settings[K]) =>
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
const setBank = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
|
|
||||||
setEditing((prev) => (prev ? { ...prev, [key]: value } : prev));
|
|
||||||
|
|
||||||
const registered = form.gstRegistration !== "unregistered";
|
const registered = form.gstRegistration !== "unregistered";
|
||||||
const gstinProblem = vendorGstinError(
|
const gstinProblem = vendorGstinError(
|
||||||
form.gstRegistration,
|
form.gstRegistration,
|
||||||
@@ -123,22 +112,19 @@ export default function AppSettings({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveBank = async () => {
|
const openBankEditor = (bank: BankAccount | null, trigger: HTMLElement) => {
|
||||||
if (!editing) return;
|
bankLauncher.current = trigger;
|
||||||
setBusy(true);
|
setEditing(bank);
|
||||||
try {
|
setBankModalOpen(true);
|
||||||
const saved = await api.saveBank(editing);
|
};
|
||||||
if (saved.isDefault || form.defaultBankId === null) {
|
|
||||||
setForm((prev) => ({ ...prev, defaultBankId: saved.id }));
|
const onBankSaved = (saved: BankAccount) => {
|
||||||
}
|
if (saved.isDefault || form.defaultBankId === null) {
|
||||||
setEditing(null);
|
setForm((prev) => ({ ...prev, defaultBankId: saved.id }));
|
||||||
await loadBanks();
|
|
||||||
toast.success("Bank account saved", "Click Save settings to keep a changed default account.");
|
|
||||||
} catch (e) {
|
|
||||||
toast.error("Could not save bank account", String(e));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
}
|
||||||
|
setBankModalOpen(false);
|
||||||
|
void loadBanks();
|
||||||
|
toast.info("Click Save settings to keep a changed default account.");
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeBank = async (bank: BankAccount) => {
|
const removeBank = async (bank: BankAccount) => {
|
||||||
@@ -306,12 +292,7 @@ export default function AppSettings({
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<section className="voiced-stack" style={{ marginBottom: "2rem" }}>
|
<section className="voiced-stack" style={{ marginBottom: "2rem" }}>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
<h3>Bank accounts</h3>
|
||||||
<h3>Bank accounts</h3>
|
|
||||||
<Button kind="tertiary" size="sm" renderIcon={Add} onClick={() => setEditing(blankBank())}>
|
|
||||||
Add account
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{banks.length > 0 ? (
|
{banks.length > 0 ? (
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
@@ -336,7 +317,7 @@ export default function AppSettings({
|
|||||||
<TableCell>{bank.isDefault ? "Yes" : "No"}</TableCell>
|
<TableCell>{bank.isDefault ? "Yes" : "No"}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="voiced-header-action">
|
<div className="voiced-header-action">
|
||||||
<Button kind="ghost" size="sm" onClick={() => setEditing({ ...bank })}>
|
<Button kind="ghost" size="sm" onClick={(e: MouseEvent<HTMLElement>) => openBankEditor({ ...bank }, e.currentTarget)}>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -358,78 +339,21 @@ export default function AppSettings({
|
|||||||
<div className="voiced-empty">No bank accounts yet.</div>
|
<div className="voiced-empty">No bank accounts yet.</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editing ? (
|
<Button
|
||||||
<div style={{ border: "1px solid var(--cds-border-subtle-01)", padding: "1rem" }}>
|
kind="ghost"
|
||||||
<Grid narrow>
|
size="sm"
|
||||||
<Column sm={4} md={4} lg={6}>
|
renderIcon={Add}
|
||||||
<TextInput
|
onClick={(e: MouseEvent<HTMLElement>) => openBankEditor(null, e.currentTarget)}
|
||||||
id="b-label"
|
>
|
||||||
labelText="Label"
|
Add bank account
|
||||||
value={editing.label}
|
</Button>
|
||||||
onChange={(e) => setBank("label", e.target.value)}
|
<BankAccountModal
|
||||||
/>
|
open={bankModalOpen}
|
||||||
</Column>
|
bank={editing}
|
||||||
<Column sm={4} md={4} lg={6}>
|
launcherRef={bankLauncher}
|
||||||
<TextInput
|
onClose={() => setBankModalOpen(false)}
|
||||||
id="b-bankName"
|
onSaved={onBankSaved}
|
||||||
labelText="Bank name"
|
/>
|
||||||
value={editing.bankName}
|
|
||||||
onChange={(e) => setBank("bankName", e.target.value)}
|
|
||||||
/>
|
|
||||||
</Column>
|
|
||||||
<Column sm={4} md={4} lg={6}>
|
|
||||||
<TextInput
|
|
||||||
id="b-accountName"
|
|
||||||
labelText="Account name"
|
|
||||||
value={editing.accountName}
|
|
||||||
onChange={(e) => setBank("accountName", e.target.value)}
|
|
||||||
/>
|
|
||||||
</Column>
|
|
||||||
<Column sm={4} md={4} lg={6}>
|
|
||||||
<TextInput
|
|
||||||
id="b-accountNo"
|
|
||||||
labelText="Account number"
|
|
||||||
value={editing.accountNo}
|
|
||||||
onChange={(e) => setBank("accountNo", e.target.value)}
|
|
||||||
/>
|
|
||||||
</Column>
|
|
||||||
<Column sm={4} md={4} lg={6}>
|
|
||||||
<TextInput
|
|
||||||
id="b-branch"
|
|
||||||
labelText="Branch"
|
|
||||||
value={editing.branch}
|
|
||||||
onChange={(e) => setBank("branch", e.target.value)}
|
|
||||||
/>
|
|
||||||
</Column>
|
|
||||||
<Column sm={4} md={4} lg={6}>
|
|
||||||
<TextInput
|
|
||||||
id="b-ifsc"
|
|
||||||
labelText="IFSC"
|
|
||||||
value={editing.ifsc}
|
|
||||||
onChange={(e) => setBank("ifsc", e.target.value.toUpperCase())}
|
|
||||||
invalid={Boolean(ifscError(editing.ifsc))}
|
|
||||||
invalidText={ifscError(editing.ifsc)}
|
|
||||||
/>
|
|
||||||
</Column>
|
|
||||||
<Column sm={4} md={8} lg={16}>
|
|
||||||
<Toggle
|
|
||||||
id="b-default"
|
|
||||||
labelText="Use as default account"
|
|
||||||
toggled={editing.isDefault}
|
|
||||||
onToggle={(value) => setBank("isDefault", value)}
|
|
||||||
/>
|
|
||||||
</Column>
|
|
||||||
</Grid>
|
|
||||||
<div className="voiced-header-action" style={{ marginTop: "1rem" }}>
|
|
||||||
<Button size="sm" onClick={saveBank} disabled={busy}>
|
|
||||||
{busy ? "Saving…" : "Save account"}
|
|
||||||
</Button>
|
|
||||||
<Button kind="ghost" size="sm" onClick={() => setEditing(null)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import { withUserFonts } from "../lib/fontImport";
|
|||||||
import { ImagePicker } from "../components/ImagePicker";
|
import { ImagePicker } from "../components/ImagePicker";
|
||||||
import { loadLogoImages, type LogoImages } from "../lib/logo";
|
import { loadLogoImages, type LogoImages } from "../lib/logo";
|
||||||
import ClientModal from "../components/ClientModal";
|
import ClientModal from "../components/ClientModal";
|
||||||
|
import BankAccountModal from "../components/BankAccountModal";
|
||||||
import { useToast } from "../components/ToastProvider";
|
import { useToast } from "../components/ToastProvider";
|
||||||
import { itemFromPreset } from "../lib/presets";
|
import { itemFromPreset } from "../lib/presets";
|
||||||
|
|
||||||
@@ -88,6 +89,9 @@ interface NewInvoiceProps {
|
|||||||
/** A draft to open as soon as the form is shown (set by "Duplicate" on the invoice page). */
|
/** A draft to open as soon as the form is shown (set by "Duplicate" on the invoice page). */
|
||||||
openDraftId?: number | null;
|
openDraftId?: number | null;
|
||||||
onDraftOpened?: () => void;
|
onDraftOpened?: () => void;
|
||||||
|
/** Current theme ("g10" | "g100") and its single toggle handler, owned by App. */
|
||||||
|
theme?: string;
|
||||||
|
onToggleTheme?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTOSAVE_MS = 1000;
|
const AUTOSAVE_MS = 1000;
|
||||||
@@ -98,7 +102,7 @@ const formatStamp = (iso: string) => {
|
|||||||
return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("en-GB")} ${formatTime(d)}`;
|
return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("en-GB")} ${formatTime(d)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NewInvoice({ settings, onSettingsChange, active, onActivate, openDraftId = null, onDraftOpened }: NewInvoiceProps) {
|
export default function NewInvoice({ settings, onSettingsChange, active, onActivate, openDraftId = null, onDraftOpened, theme, onToggleTheme }: NewInvoiceProps) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
|
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
|
||||||
const blankState = (): InvoiceFormState => {
|
const blankState = (): InvoiceFormState => {
|
||||||
@@ -131,6 +135,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
const [presets, setPresets] = useState<ItemPreset[]>([]);
|
const [presets, setPresets] = useState<ItemPreset[]>([]);
|
||||||
const [clientModalOpen, setClientModalOpen] = useState(false);
|
const [clientModalOpen, setClientModalOpen] = useState(false);
|
||||||
const newClientButton = useRef<HTMLButtonElement | null>(null);
|
const newClientButton = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const [bankModalOpen, setBankModalOpen] = useState(false);
|
||||||
|
const newBankButton = useRef<HTMLButtonElement | null>(null);
|
||||||
const [clientId, setClientId] = useState<number | null>(null);
|
const [clientId, setClientId] = useState<number | null>(null);
|
||||||
const [clientName, setClientName] = useState("");
|
const [clientName, setClientName] = useState("");
|
||||||
const [clientAddress, setClientAddress] = useState("");
|
const [clientAddress, setClientAddress] = useState("");
|
||||||
@@ -678,6 +684,18 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
// Yield the shared render worker to the export; the model changes back when it is done and re-renders.
|
// Yield the shared render worker to the export; the model changes back when it is done and re-renders.
|
||||||
const pdf = useInvoicePdf({ model: busy ? null : previewModel, prefs: activePrefs });
|
const pdf = useInvoicePdf({ model: busy ? null : previewModel, prefs: activePrefs });
|
||||||
|
|
||||||
|
const themeToggle = onToggleTheme ? (
|
||||||
|
<Toggle
|
||||||
|
id="themeToggle"
|
||||||
|
size="sm"
|
||||||
|
labelText="Dark mode"
|
||||||
|
labelA="Off"
|
||||||
|
labelB="On"
|
||||||
|
toggled={theme === "g100"}
|
||||||
|
onToggle={() => onToggleTheme()}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="voiced-page" hidden={!active}>
|
<div className="voiced-page" hidden={!active}>
|
||||||
@@ -696,8 +714,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{locked ? null : (
|
{locked ? themeToggle : (
|
||||||
<div className="voiced-header-action" style={{ flexWrap: "wrap" }}>
|
<div className="voiced-header-action" style={{ flexWrap: "wrap" }}>
|
||||||
|
{themeToggle}
|
||||||
{draftSaving ? (
|
{draftSaving ? (
|
||||||
<InlineLoading status="active" description="Saving draft…" />
|
<InlineLoading status="active" description="Saving draft…" />
|
||||||
) : savedAt && !unsaved ? (
|
) : savedAt && !unsaved ? (
|
||||||
@@ -1094,6 +1113,16 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
<Button
|
||||||
|
ref={newBankButton}
|
||||||
|
kind="ghost"
|
||||||
|
size="sm"
|
||||||
|
renderIcon={Add}
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => setBankModalOpen(true)}
|
||||||
|
>
|
||||||
|
Create bank account
|
||||||
|
</Button>
|
||||||
|
|
||||||
<ImagePicker
|
<ImagePicker
|
||||||
kind="signature"
|
kind="signature"
|
||||||
@@ -1206,6 +1235,17 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<BankAccountModal
|
||||||
|
open={bankModalOpen}
|
||||||
|
bank={null}
|
||||||
|
launcherRef={newBankButton}
|
||||||
|
onClose={() => setBankModalOpen(false)}
|
||||||
|
onSaved={(saved) => {
|
||||||
|
setBankModalOpen(false);
|
||||||
|
setBankId(saved.id);
|
||||||
|
api.listBanks().then(setBanks).catch(() => {});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ClientModal
|
<ClientModal
|
||||||
open={clientModalOpen}
|
open={clientModalOpen}
|
||||||
client={null}
|
client={null}
|
||||||
|
|||||||
Reference in New Issue
Block a user