Author SHA1 Message Date
xavierk e1031b3ecc Remove the remaining references to the source templates from the README and release notes
Windows build / build (push) Canceled after 0s
2026-10-05 12:37:42 +05:30
xavierk 4960e26d8c Add the Apache-2.0 licence, NOTICE and contributing guide 2026-10-05 12:21:37 +05:30
xavierk 3cb0ac9236 Refresh the README for 1.0.2: setup wizard, install steps, Windows status
Windows build / build (push) Canceled after 0s
2026-10-05 12:07:36 +05:30
xavierk 9bdb142090 Release 1.0.2 2026-10-05 10:19:05 +05:30
xavierk c78bf1a851 Replace the sample data with synthetic fixtures and regenerate goldens and thumbnails
The template harness, tests and thumbnails now use a neutral vendor, bank account and
a generated sample wordmark (scripts/logo/make-sample-logo.mjs). Layout goldens and the
14 picker thumbnails are regenerated. Fixture strings keep their original lengths where
layout tests depend on them.
2026-10-05 10:18:53 +05:30
xavierk c3e2e05e89 Start the setup wizard blank and add a Run setup again button
The first-run seed no longer inserts the original vendor's name, address, contact
details, PAN or bank account; the wizard now starts empty and requires a name and
state. Settings > Data gets a Run setup again button that clears the onboarded flag.
Re-running the wizard keeps the active invoice series unless the prefix or padding
changes. The sample logo moves out of public/ so it no longer ships in the app.
2026-10-05 09:39:02 +05:30
xavierk e786580d42 Release 1.0.1: rename Create bank account button to Add bank account 2026-10-05 08:38:41 +05:30
xavierk 6efb498409 Force re-indexing in build-xbps.sh so a rebuilt package replaces its old index entry
xbps-rindex -a skips a package whose version is already indexed, so rebuilding the same version left the old checksum in the repository index and xbps-install rejected the new file with 'checksum does not match repository index'.
2026-10-05 07:02:24 +05:30
xavierk ae80922d03 Fix dark theme fill, theme toggle visibility and bank account creation
- The Carbon theme class is now also set on html, with a themed background and a full-height root, so the dark theme fills the whole window instead of stopping at the content height. An inline script in index.html applies the saved theme before first paint.
- The header theme button gets a tooltip, inline padding and a max width so it is no longer clipped at the window edge. New Invoice also gets a labelled Dark mode toggle wired to the same handler.
- A shared BankAccountModal replaces the inline editor in Settings and adds a Create bank account button under the bank selector on New Invoice, which selects the new account on save. Settings keeps one Add bank account button under the table. Bank name and account number are now required.
2026-10-05 05:14:48 +05:30
62 changed files with 783 additions and 251 deletions
+21
View File
@@ -0,0 +1,21 @@
# Contributing to Voiced
Contributions are welcome: bug reports, fixes, templates, translations and documentation.
1. Open an issue first for anything larger than a small fix, so the work is not duplicated.
2. Branch from `main`. Keep a change focused on one thing.
3. Run the gates from `docs/RELEASE.md` that your change touches: `npm run build`, `npm test`, `npm run fonts:verify`
and `cargo test --manifest-path src-tauri/Cargo.toml`. Changes to the PDF engine or a template also need the
real-webview self-test (see the README, "Tests and gates").
4. Do not add personal data, real GSTINs, bank details or client names to code, tests, fixtures or screenshots. Use
synthetic values.
5. Open a pull request describing what changed and why.
By submitting a contribution you agree that it is licensed under the Apache License 2.0 (see `LICENSE`), the same
licence as the project, as described in section 5 of that licence.
## Using Voiced in your own project
You may fork, modify and redistribute Voiced under the Apache License 2.0. You must keep `LICENSE` and `NOTICE`,
keep the copyright notices, and mark files you changed. Please also credit the original: "Based on Voiced by
Bongbetic".
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+11
View File
@@ -0,0 +1,11 @@
Voiced
Copyright 2026 Bongbetic
This product includes software developed by Bongbetic (https://git.bongbetic.com/xavierk/Voiced).
If you redistribute Voiced, or a work derived from it (including a fork), the Apache License 2.0 requires you to keep
this NOTICE file, the LICENSE file and the copyright notices in the source files, and to state any changes you made.
Please also keep the credit "Based on Voiced by Bongbetic" in your README and in your application's About or
documentation.
Bundled fonts in public/fonts/ are covered by their own SIL Open Font License 1.1 texts (OFL.txt in each folder).
+41 -13
View File
@@ -1,6 +1,6 @@
# Voiced # Voiced
An offline-first GST invoicing desktop app for **Test Vendor**, built by **Bongbetic**. An offline-first GST invoicing desktop app built by **Bongbetic**.
Voiced turns a few details into a clean, print-ready PDF invoice. It keeps a running invoice-number series in a Voiced turns a few details into a clean, print-ready PDF invoice. It keeps a running invoice-number series in a
local database, remembers your vendor, client and bank details, and works without an account or a network local database, remembers your vendor, client and bank details, and works without an account or a network
@@ -8,8 +8,15 @@ connection. The only optional network feature is the ERPNext push, which you con
Targets: Void Linux (xbps, WebKitGTK 2.50 or newer) and Windows 10/11 (NSIS installer, WebView2). Targets: Void Linux (xbps, WebKitGTK 2.50 or newer) and Windows 10/11 (NSIS installer, WebView2).
Current release: **1.0.2**. Installers and the Void package are attached to the release on the project's Gitea
page (see "Installing").
## Features ## Features
- **First-run setup.** A four-step wizard (business, branding, bank, numbering) starts blank: no vendor, bank or tax
details are pre-filled, and a business name and state are required. **Settings, Data, Run setup again** reopens it
with your current details; invoices, clients and backups are kept, and numbering carries on unless you change the
series prefix or digits.
- **Invoices.** Automatic numbering in a persistent series (for example `INV/2026-001`) with a one-click new series, - **Invoices.** Automatic numbering in a persistent series (for example `INV/2026-001`) with a one-click new series,
drafts, issue and cancel. An issued invoice is immutable: it stores a snapshot of the vendor, client, GST figures drafts, issue and cancel. An issued invoice is immutable: it stores a snapshot of the vendor, client, GST figures
and layout. Line items are a fixed amount or rate x quantity (per second, minute, hour, session or unit), with and layout. Line items are a fixed amount or rate x quantity (per second, minute, hour, session or unit), with
@@ -17,7 +24,7 @@ Targets: Void Linux (xbps, WebKitGTK 2.50 or newer) and Windows 10/11 (NSIS inst
- **GST.** All money and tax maths is done in integer paise. CGST+SGST, IGST or UTGST follow from the supplier's - **GST.** All money and tax maths is done in integer paise. CGST+SGST, IGST or UTGST follow from the supplier's
state and the place of supply, and a mismatch is rejected. An unregistered vendor gets a plain "Invoice" with no state and the place of supply, and a mismatch is rejected. An unregistered vendor gets a plain "Invoice" with no
tax. GSTIN, PAN, IFSC, email and phone are validated; amounts are written in Indian words. tax. GSTIN, PAN, IFSC, email and phone are validated; amounts are written in Indian words.
- **Templates.** 14 templates: Voiced Standard (Classic) and 13 designs measured from reference originals (Linea, - **Templates.** 14 templates: Voiced Standard (Classic) and 13 further designs (Linea,
Monogram, Serenity, Crimson Grid, Tangerine Ledger, Slate Band, Teal Swoosh, Purple Pop, Citrus Split, Monolith, Monogram, Serenity, Crimson Grid, Tangerine Ledger, Slate Band, Teal Swoosh, Purple Pop, Citrus Split, Monolith,
Cobalt Stripe, Marble, Highlighter). Your logo fills the template's logo slot; a few templates offer an opt-in Cobalt Stripe, Marble, Highlighter). Your logo fills the template's logo slot; a few templates offer an opt-in
logo position. Specs are in `docs/templates/`. logo position. Specs are in `docs/templates/`.
@@ -66,14 +73,28 @@ npm run app:dev # tauri dev, served from localhost:1420 (does not exercis
| `npm run build` | `tsc --noEmit` and `vite build` (the type-check gate) | | `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 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 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 logo:measure` | Regenerates `src/pdf/templates/slots.generated.ts` from the reference template PDFs (not in the repo); must be idempotent |
| `npm run thumbnails` | Regenerates `public/templates/*.png` | | `npm run thumbnails` | Regenerates `public/templates/*.png` from the synthetic sample invoice (neutral vendor and bank, generated wordmark) |
| `npm run templates:compare` | reference vs Voiced side-by-side PNGs under `/tmp` | | `node scripts/logo/make-sample-logo.mjs` | Regenerates the synthetic sample logo in `src/pdf/testing/fixtures/` |
| `npm run templates:compare` | Reference vs Voiced side-by-side PNGs under `/tmp` (needs the reference PDFs, not in the repo) |
| `cargo test --manifest-path src-tauri/Cargo.toml` | Rust unit tests | | `cargo test --manifest-path src-tauri/Cargo.toml` | Rust unit tests |
| `npm run app:build` | Production build for the current platform (custom-protocol) | | `npm run app:build` | Production build for the current platform (custom-protocol) |
The release procedure, including the manual first-run checklist, is in `docs/RELEASE.md`. The release procedure, including the manual first-run checklist, is in `docs/RELEASE.md`.
## Installing
Download the files for your platform from the release page of the project on `git.bongbetic.com` (Releases, then
`v1.0.2`).
- **Windows 10/11:** run `Voiced_1.0.2_x64-setup.exe`. It is a per-user install and is not code-signed, so Windows
SmartScreen may warn: choose "More info", then "Run anyway".
- **Void Linux:** install `voiced-1.0.2_1.x86_64.xbps` from a local repository (see "Installing on Void Linux").
On first launch the setup wizard asks for your business details, logo, bank account and invoice numbering. Nothing is
pre-filled. Uninstalling does not delete your data (see "Where data lives"); to start from scratch use
Settings, Data, Run setup again, or remove the data directory after taking a backup.
## Where data lives ## Where data lives
The Tauri identifier is `com.bongbetic.voiced`. Voiced uses two directories, which differ only on Windows: The Tauri identifier is `com.bongbetic.voiced`. Voiced uses two directories, which differ only on Windows:
@@ -184,7 +205,8 @@ Hosts without an `nsis` package (for example Void Linux):
# from https://github.com/tauri-apps/nsis-tauri-utils/releases # from https://github.com/tauri-apps/nsis-tauri-utils/releases
``` ```
The installer has not been run on a real Windows machine. The installer has been installed and run once in a Windows 11 virtual machine; it has not been tried on physical
hardware.
## Installing on Void Linux ## Installing on Void Linux
@@ -215,7 +237,7 @@ cd void-packages && ./xbps-src pkg voiced
``` ```
`INSTALL` and `REMOVE` must sit next to the template; xbps-src packs them. The source is a tagged archive of the `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). repository (see the comments at the top of the template; `checksum=SKIP` until a tarball is published).
xbps-src builds in a network-less chroot, so vendor the dependencies first: run `cargo vendor` in `src-tauri/` (with a 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 `.cargo/config.toml` pointing at `vendor/`) and populate the npm cache, and include the results in the source
archive. The template runs `npm ci` (not `--omit=optional`: that removes archive. The template runs `npm ci` (not `--omit=optional`: that removes
@@ -241,6 +263,7 @@ src/ React + Carbon frontend
fonts/ Font registration, roles, glyph priming fonts/ Font registration, roles, glyph priming
render/ renderCore, Web Worker, latest-wins client render/ renderCore, Web Worker, latest-wins client
flatten.ts assemble.ts Rasterise at 300 DPI and rebuild an image-only PDF flatten.ts assemble.ts Rasterise at 300 DPI and rebuild an image-only PDF
testing/ Goldens (layout fingerprints), self-test fixtures, synthetic sample logo
src-tauri/src/ Rust backend src-tauri/src/ Rust backend
db.rs Schema as numbered migrations (copies the DB aside before migrating) db.rs Schema as numbered migrations (copies the DB aside before migrating)
gst.rs Integer-paise money and GST maths gst.rs Integer-paise money and GST maths
@@ -250,7 +273,7 @@ src-tauri/src/ Rust backend
integrations/erpnext/ ERPNext client, mapping, discovery, push integrations/erpnext/ ERPNext client, mapping, discovery, push
selftest.rs Unattended real-webview self-test selftest.rs Unattended real-webview self-test
public/fonts/ Bundled TTF families, OFL texts, manifest.json (plus legacy IBM Plex web fonts) public/fonts/ Bundled TTF families, OFL texts, manifest.json (plus legacy IBM Plex web fonts)
public/templates/ Template thumbnails public/templates/ Template thumbnails (rendered from synthetic sample data)
docs/ Template specs, voiced.invoice.v1, release checklist docs/ Template specs, voiced.invoice.v1, release checklist
scripts/ Font, logo, template, thumbnail and ERPNext end-to-end tooling 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 packaging/void/ xbps-src template, build-xbps.sh, desktop entry, INSTALL and REMOVE scripts
@@ -272,15 +295,20 @@ Settings, Diagnostics.
## Known limitations ## Known limitations
- Windows and WebView2 have never been run on a real machine; the NSIS installer is cross-compiled only. - Windows and WebView2 have only been exercised in a Windows 11 virtual machine, not on physical hardware; the NSIS
- The 13 reference-based templates are re-creations of reference designs. the reference design's content licence may restrict this, so installer is cross-compiled and unsigned.
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. - 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. - 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. - 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 - Not implemented (deferred): copies and watermarks, PDF/A, custom margins, user background textures, SVG logo
import, CESS, composition and SEZ invoices. import, CESS, composition and SEZ invoices.
## Ownership ## Licence and credit
© Bongbetic. All rights reserved. This is a private application. Voiced is released under the [Apache License 2.0](LICENSE). Copyright 2026 Bongbetic.
You are free to use, modify, fork and redistribute it. If you do, you must keep the `LICENSE` and `NOTICE` files and
the copyright notices, and state your changes; please also credit the original: **Based on Voiced by Bongbetic**.
Bundled fonts keep their own SIL Open Font License texts (see "Fonts and licences").
Contributions are welcome, see [CONTRIBUTING.md](CONTRIBUTING.md). Contributions are licensed under the same terms.
+2 -5
View File
@@ -83,9 +83,6 @@ Linux and Windows. Windows has never been run on real hardware, so treat it as u
- **Integrations.** Test connection and push an invoice to a test ERPNext (README, "ERPNext integration"). - **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. - **Single instance.** Launch a second copy: the first window must come to the front.
## 6. Reference licence ## 6. Open items
The 13 reference-based templates are re-creations of reference designs. the reference design's content licence may restrict that. Until it A chartered accountant's sign-off on the SAC code and the signature rule is still open.
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.
+1 -1
View File
@@ -74,7 +74,7 @@ Everything can be built with View backgroundColor and borders. Svg is only neede
## Mapping to Voiced fields ## Mapping to Voiced fields
- Logo slot: vendor logo (Image maxHeight 46, maxWidth ~110, objectFit contain). If the logo is a wordmark (e.g. public/brand/sample-wordmark-logo.png), hide the text lockup. - Logo slot: vendor logo (Image maxHeight 46, maxWidth ~110, objectFit contain). If the logo is a wordmark (e.g. src/pdf/testing/fixtures/sample-wordmark-logo.png), hide the text lockup.
- Brand lockup: vendorName (SemiBold 15.4 uppercase, wraps, right-aligned). - Brand lockup: vendorName (SemiBold 15.4 uppercase, wraps, right-aligned).
- 'INVOICE': show 'TAX INVOICE' when taxType != none, else 'INVOICE'. - 'INVOICE': show 'TAX INVOICE' when taxType != none, else 'INVOICE'.
- Meta lines: 'Invoice No: {number}', 'Invoice Date: {date}', NEW 'Due Date: {dueDate}' (3rd line, same 18.68 pitch), optional 'PO No: {poNumber}'. - Meta lines: 'Invoice No: {number}', 'Invoice Date: {date}', NEW 'Due Date: {dueDate}' (3rd line, same 18.68 pitch), optional 'PO No: {poNumber}'.
+11
View File
@@ -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>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "voiced", "name": "voiced",
"version": "1.0.0", "version": "1.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "voiced", "name": "voiced",
"version": "1.0.0", "version": "1.0.2",
"dependencies": { "dependencies": {
"@carbon/icons-react": "^11.89.0", "@carbon/icons-react": "^11.89.0",
"@carbon/react": "^1.117.0", "@carbon/react": "^1.117.0",
+2 -1
View File
@@ -1,7 +1,8 @@
{ {
"name": "voiced", "name": "voiced",
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.2",
"license": "Apache-2.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",
+4 -2
View File
@@ -89,7 +89,7 @@ mkdir -p "$REPO"
-n "$PKGVER" \ -n "$PKGVER" \
-s "Offline invoicing app by Bongbetic" \ -s "Offline invoicing app by Bongbetic" \
-S "Voiced is an offline-first invoice generator with automatic numbering, GST support, optional signatures and print-ready PDF export." \ -S "Voiced is an offline-first invoice generator with automatic numbering, GST support, optional signatures and print-ready PDF export." \
-l "proprietary" \ -l "Apache-2.0" \
-H "https://git.bongbetic.com/xavierk/Voiced" \ -H "https://git.bongbetic.com/xavierk/Voiced" \
-m "Xavier Karma <xavier@bongbetic.com>" \ -m "Xavier Karma <xavier@bongbetic.com>" \
-D "libwebkit2gtk41>=2.50_1 fontconfig>=0 xdg-utils>=0 hicolor-icon-theme>=0 desktop-file-utils>=0" \ -D "libwebkit2gtk41>=2.50_1 fontconfig>=0 xdg-utils>=0 hicolor-icon-theme>=0 desktop-file-utils>=0" \
@@ -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"
+2 -2
View File
@@ -28,7 +28,7 @@
# pure Rust and need no extra system libraries. # pure Rust and need no extra system libraries.
# #
pkgname=voiced pkgname=voiced
version=1.0.0 version=1.0.2
revision=1 revision=1
archs="x86_64*" archs="x86_64*"
hostmakedepends="rust nodejs pkg-config" hostmakedepends="rust nodejs pkg-config"
@@ -36,7 +36,7 @@ makedepends="libwebkit2gtk41-devel libsoup3-devel gtk+3-devel librsvg-devel"
depends="libwebkit2gtk41>=2.50 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils" depends="libwebkit2gtk41>=2.50 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils"
short_desc="Offline invoicing app by Bongbetic" short_desc="Offline invoicing app by Bongbetic"
maintainer="Xavier Karma <xavier@bongbetic.com>" maintainer="Xavier Karma <xavier@bongbetic.com>"
license="custom:proprietary" license="Apache-2.0"
homepage="https://git.bongbetic.com/xavierk/Voiced" homepage="https://git.bongbetic.com/xavierk/Voiced"
distfiles="https://git.bongbetic.com/xavierk/Voiced/archive/v${version}.tar.gz" distfiles="https://git.bongbetic.com/xavierk/Voiced/archive/v${version}.tar.gz"
checksum=SKIP checksum=SKIP
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+1 -1
View File
@@ -116,7 +116,7 @@ async function main() {
const { computeLogoPlacement, slotFor } = await loadPlacement(); const { computeLogoPlacement, slotFor } = await loadPlacement();
mkdirSync(OUT_DIR, { recursive: true }); mkdirSync(OUT_DIR, { recursive: true });
const sampleRaw = await loadImage(readFileSync(path.join(ROOT, "public/brand/sample-wordmark-logo.png"))); const sampleRaw = await loadImage(readFileSync(path.join(ROOT, "src/pdf/testing/fixtures/sample-wordmark-logo.png")));
const sampleCanvas = createCanvas(sampleRaw.width, sampleRaw.height); const sampleCanvas = createCanvas(sampleRaw.width, sampleRaw.height);
sampleCanvas.getContext("2d").drawImage(sampleRaw, 0, 0); sampleCanvas.getContext("2d").drawImage(sampleRaw, 0, 0);
const syn = syntheticLogos(createCanvas); const syn = syntheticLogos(createCanvas);
+20
View File
@@ -0,0 +1,20 @@
// Renders the synthetic wordmark used as the sample logo in tests, thumbnails and scripts
// (src/pdf/testing/fixtures/sample-wordmark-logo.png). Run: node scripts/logo/make-sample-logo.mjs
import { writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { GlobalFonts, createCanvas } from "@napi-rs/canvas";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const W = 2172;
const H = 724;
const size = Number(process.argv[2] ?? 300);
GlobalFonts.registerFromPath(path.join(ROOT, process.argv[3] ?? "public/fonts/montserrat/Montserrat-ExtraBold.ttf"), "SampleMark");
const canvas = createCanvas(W, H);
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#1f3a6e";
ctx.font = `${size}px SampleMark`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(process.argv[4] ?? "STUDIO", W / 2, H / 2);
writeFileSync(path.join(ROOT, "src/pdf/testing/fixtures/sample-wordmark-logo.png"), canvas.toBuffer("image/png"));
+1 -1
View File
@@ -4647,7 +4647,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "voiced" name = "voiced"
version = "1.0.0" version = "1.0.2"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
+2 -1
View File
@@ -1,6 +1,7 @@
[package] [package]
name = "voiced" name = "voiced"
version = "1.0.0" version = "1.0.2"
license = "Apache-2.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 -1
View File
@@ -940,7 +940,7 @@ mod tests {
let bank_id: i64 = conn.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0)).unwrap(); let bank_id: i64 = conn.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0)).unwrap();
let inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap(); let inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap();
let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).unwrap(); let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).unwrap();
assert_eq!(bank["bankName"], "State Bank of India"); assert_eq!(bank["bankName"], "Test Bank");
let err = issue(&mut conn, input(json!({"bankAccountId": 9999}))).unwrap_err(); let err = issue(&mut conn, input(json!({"bankAccountId": 9999}))).unwrap_err();
assert!(err.contains("bank account"), "{err}"); assert!(err.contains("bank account"), "{err}");
} }
+25 -35
View File
@@ -436,8 +436,8 @@ fn prune_backups(backup_dir: &Path, keep: usize) -> std::io::Result<()> {
} }
/// Insert the first-run defaults the very first time the app is opened. /// Insert the first-run defaults the very first time the app is opened.
/// Vendor details are pre-filled from the owner's existing invoice. The vendor /// The defaults are neutral: no vendor, bank or tax identity is pre-filled, so the setup wizard
/// has no GSTIN yet, so the defaults describe an unregistered supplier (no tax). /// starts blank. The vendor is an unregistered supplier (no tax) until the wizard says otherwise.
fn seed(conn: &Connection) -> rusqlite::Result<()> { fn seed(conn: &Connection) -> rusqlite::Result<()> {
let settings_count: i64 = let settings_count: i64 =
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?; conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?;
@@ -447,38 +447,8 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
(id, vendor_name, vendor_address, vendor_email, vendor_phone, (id, vendor_name, vendor_address, vendor_email, vendor_phone,
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate, vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
default_tax_type, payment_terms_days, currency, onboarded, theme) default_tax_type, payment_terms_days, currency, onboarded, theme)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'none', 30, 'INR', 0, 'g10')"#, VALUES (1, '', '', '', '', '', '', '', 18, 'none', 30, 'INR', 0, 'g10')"#,
params![ [],
"Test Vendor",
"1 Test Road, Bangalore - 560001",
"vendor@example.com",
"9000000000",
"ABCDE1234F",
"",
],
)?;
}
let bank_count: i64 =
conn.query_row("SELECT COUNT(*) FROM bank_accounts", [], |r| r.get(0))?;
if bank_count == 0 {
conn.execute(
r#"INSERT INTO bank_accounts
(label, bank_name, account_name, account_no, branch, ifsc, is_default)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)"#,
params![
"Primary",
"State Bank of India",
"Test Vendor",
"000000000000",
"000001",
"TEST0000001",
],
)?;
let bank_id = conn.last_insert_rowid();
conn.execute(
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
params![bank_id],
)?; )?;
} }
@@ -497,6 +467,25 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
Ok(()) Ok(())
} }
/// Sample vendor and bank for command-logic unit tests (production seeds none). Fake values only.
#[cfg(test)]
pub(crate) fn seed_test_fixture(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
r#"UPDATE app_settings SET vendor_name = 'Test Vendor', vendor_address = '1 Test Road, Bangalore - 560001',
vendor_email = 'vendor@example.com', vendor_phone = '9000000000', vendor_pan = 'ABCDE1234F',
vendor_state_code = '29' WHERE id = 1"#,
[],
)?;
conn.execute(
r#"INSERT INTO bank_accounts (label, bank_name, account_name, account_no, branch, ifsc, is_default)
VALUES ('Primary', 'Test Bank', 'Test Vendor', '000000000000', '000001', 'TEST0000001', 1)"#,
[],
)?;
let bank_id = conn.last_insert_rowid();
conn.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![bank_id])?;
Ok(())
}
/// A migrated, seeded in-memory database for unit tests of the command logic. /// A migrated, seeded in-memory database for unit tests of the command logic.
#[cfg(test)] #[cfg(test)]
pub fn open_in_memory() -> Result<Connection, DbError> { pub fn open_in_memory() -> Result<Connection, DbError> {
@@ -504,6 +493,7 @@ pub fn open_in_memory() -> Result<Connection, DbError> {
conn.pragma_update(None, "foreign_keys", "ON")?; conn.pragma_update(None, "foreign_keys", "ON")?;
migrations().to_latest(&mut conn)?; migrations().to_latest(&mut conn)?;
seed(&conn)?; seed(&conn)?;
seed_test_fixture(&conn)?;
Ok(conn) Ok(conn)
} }
@@ -726,7 +716,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.unwrap(); .unwrap();
assert_eq!(tax_type, "none"); assert_eq!(tax_type, "none");
assert_eq!(gst_reg, "unregistered"); assert_eq!(gst_reg, "unregistered");
assert_eq!(name, "Test Vendor"); assert_eq!(name, "");
assert!(backups_in(&backups).is_empty()); assert!(backups_in(&backups).is_empty());
} }
+1 -1
View File
@@ -18,7 +18,7 @@ pub const STATES: &[(&str, &str)] = &[
("09", "Uttar Pradesh"), ("09", "Uttar Pradesh"),
("10", "Bihar"), ("10", "Bihar"),
("11", "Sikkim"), ("11", "Sikkim"),
("12", "Sampleachal Pradesh"), ("12", "Arunachal Pradesh"),
("13", "Nagaland"), ("13", "Nagaland"),
("14", "Manipur"), ("14", "Manipur"),
("15", "Mizoram"), ("15", "Mizoram"),
+1 -1
View File
@@ -404,7 +404,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let raw = mock.requests.lock().unwrap()[0].clone(); let raw = mock.requests.lock().unwrap()[0].clone();
assert!(raw.starts_with("GET /api/resource/Sales%20Invoice/AP%2F2026-001?limit_page_length=0 "), "{raw}"); assert!(raw.starts_with("GET /api/resource/Sales%20Invoice/INV%2F2026-001?limit_page_length=0 "), "{raw}");
} }
#[tokio::test] #[tokio::test]
@@ -820,7 +820,7 @@ fn sync_status_list_and_open_url() {
let s = sync_status(&conn, inv.id).unwrap(); let s = sync_status(&conn, inv.id).unwrap();
assert_eq!((s.status.as_str(), s.remote_name.as_str(), s.remote_docstatus, s.attached), ("synced", "INV/2026-001", 1, true)); assert_eq!((s.status.as_str(), s.remote_name.as_str(), s.remote_docstatus, s.attached), ("synced", "INV/2026-001", 1, true));
assert_eq!(sync_statuses(&conn).unwrap(), vec![s]); assert_eq!(sync_statuses(&conn).unwrap(), vec![s]);
assert_eq!(open_url(&conn, inv.id).unwrap(), "http://127.0.0.1:8000/app/sales-invoice/AP%2F2026-001"); assert_eq!(open_url(&conn, inv.id).unwrap(), "http://127.0.0.1:8000/app/sales-invoice/INV%2F2026-001");
// Names with spaces and other characters stay one path segment. // Names with spaces and other characters stay one path segment.
conn.execute("UPDATE erpnext_sync SET remote_name = 'SINV 26/1?x' WHERE invoice_id = ?1", params![inv.id]).unwrap(); conn.execute("UPDATE erpnext_sync SET remote_name = 'SINV 26/1?x' WHERE invoice_id = ?1", params![inv.id]).unwrap();
assert_eq!(open_url(&conn, inv.id).unwrap(), "http://127.0.0.1:8000/app/sales-invoice/SINV%2026%2F1%3Fx"); assert_eq!(open_url(&conn, inv.id).unwrap(), "http://127.0.0.1:8000/app/sales-invoice/SINV%2026%2F1%3Fx");
+4 -4
View File
@@ -213,7 +213,7 @@ fn clean_alpha(img: &mut RgbaImage) {
} }
/// Keep 64 levels per channel (steps of about 4/255, invisible in print). Anti-aliasing and encoder /// Keep 64 levels per channel (steps of about 4/255, invisible in print). Anti-aliasing and encoder
/// noise of +-1 otherwise makes flat colours incompressible: the Test Vendor logo drops from 149 KB to 75 KB. /// noise of +-1 otherwise makes flat colours incompressible: a sample wordmark logo drops from 149 KB to 75 KB.
fn quantize_colour(img: &mut RgbaImage) { fn quantize_colour(img: &mut RgbaImage) {
for p in img.pixels_mut() { for p in img.pixels_mut() {
for c in 0..3 { for c in 0..3 {
@@ -535,13 +535,13 @@ mod tests {
#[test] #[test]
fn sample_logo_is_trimmed_and_small() { fn sample_logo_is_trimmed_and_small() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/brand/sample-wordmark-logo.png"); let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/pdf/testing/fixtures/sample-wordmark-logo.png");
let bytes = std::fs::read(path).unwrap(); let bytes = std::fs::read(path).unwrap();
let out = process_logo(&bytes).unwrap(); let out = process_logo(&bytes).unwrap();
let m = &out.metrics; let m = &out.metrics;
assert_eq!((m.source_width, m.source_height), (2172, 724)); assert_eq!((m.source_width, m.source_height), (2172, 724));
assert!((4.5..=4.7).contains(&m.aspect), "aspect {}", m.aspect); assert!((5.3..=5.6).contains(&m.aspect), "aspect {}", m.aspect);
assert!((0.40..=0.46).contains(&m.ink_density), "density {}", m.ink_density); assert!((0.50..=0.60).contains(&m.ink_density), "density {}", m.ink_density);
assert_eq!(m.kind, LogoKind::Wordmark); assert_eq!(m.kind, LogoKind::Wordmark);
assert!(out.print_png.len() <= 120 * 1024, "print is {} bytes", out.print_png.len()); assert!(out.print_png.len() <= 120 * 1024, "print is {} bytes", out.print_png.len());
assert!(out.knockout_png.is_some()); assert!(out.knockout_png.is_some());
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Voiced", "productName": "Voiced",
"version": "1.0.0", "version": "1.0.2",
"identifier": "com.bongbetic.voiced", "identifier": "com.bongbetic.voiced",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+14 -7
View File
@@ -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
+5 -1
View File
@@ -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>
+148
View File
@@ -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>
);
}
+38 -3
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react"; import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
import { open, save } from "@tauri-apps/plugin-dialog"; import { confirm, open, save } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api"; import { api } from "../lib/api";
import type { Settings } from "../lib/types";
import { import {
backupFileName, backupFileName,
ensureZipExtension, ensureZipExtension,
@@ -20,8 +21,15 @@ type RestoreStep = "confirm" | "staging" | "ready";
const joinPath = (dir: string, name: string) => const joinPath = (dir: string, name: string) =>
!dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`; !dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
/** Settings > Data: manual backup, restore (staged, applied after a restart) and the automatic daily backup. */ interface DataBackupPanelProps {
export default function DataBackupPanel() { /** The saved settings; "Run setup again" writes them back with the wizard flag cleared. */
settings: Settings;
/** Called after the flag is cleared, so the app reloads settings and shows the wizard. */
onRerunSetup: () => void;
}
/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup and re-running setup. */
export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPanelProps) {
const toast = useToast(); const toast = useToast();
const [status, setStatus] = useState<BackupStatus | null>(null); const [status, setStatus] = useState<BackupStatus | null>(null);
const [backingUp, setBackingUp] = useState(false); const [backingUp, setBackingUp] = useState(false);
@@ -32,6 +40,20 @@ export default function DataBackupPanel() {
const [restarting, setRestarting] = useState(false); const [restarting, setRestarting] = useState(false);
useReturnFocus(restorePath !== null); useReturnFocus(restorePath !== null);
const rerunSetup = async () => {
const ok = await confirm(
"Open the setup wizard again? Your invoices, clients and backups are kept, and the wizard starts with your current details.",
{ title: "Run setup again", kind: "warning" },
);
if (!ok) return;
try {
await api.saveSettings({ ...settings, onboarded: false });
onRerunSetup();
} catch (e) {
toast.error("Could not start setup", String(e));
}
};
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
setStatus(await api.getBackupStatus()); setStatus(await api.getBackupStatus());
@@ -229,6 +251,19 @@ export default function DataBackupPanel() {
</div> </div>
</Tile> </Tile>
<Tile>
<h2 className="voiced-section-title">Setup wizard</h2>
<p className="voiced-muted">
Run the first-time setup again to change your business details, logo, bank account and numbering in one go. Existing
invoices, clients and backups are not touched, and invoice numbering carries on unless you change the prefix.
</p>
<div className="voiced-actions">
<Button size="md" kind="tertiary" onClick={() => void rerunSetup()}>
Run setup again
</Button>
</div>
</Tile>
<Modal <Modal
open={restorePath !== null} open={restorePath !== null}
danger={step === "confirm"} danger={step === "confirm"}
+26 -4
View File
@@ -7,7 +7,8 @@ import type { BankAccount, Invoice, InvoiceInput, Settings } from "./types";
/** /**
* The end-to-end invoice flow through the real IPC, run against the fresh database the self-test * The end-to-end invoice flow through the real IPC, run against the fresh database the self-test
* launches with (it seeds the unregistered vendor, a default bank and an active series). * launches with. The fresh database is neutral (no vendor, no bank), so the first step provisions a
* fake vendor and bank the way the setup wizard would.
*/ */
const today = () => { const today = () => {
@@ -28,11 +29,32 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
let bank!: BankAccount; let bank!: BankAccount;
let invoice: Invoice | null = null; let invoice: Invoice | null = null;
const loaded = await log.run("e2e: get settings and default bank", async () => { const loaded = await log.run("e2e: provision vendor and bank, get settings", async () => {
s = await api.getSettings(); s = await api.getSettings();
const banks = await api.listBanks(); let banks = await api.listBanks();
if (banks.length === 0) {
const saved = await api.saveBank({
id: null,
label: "Primary",
bankName: "Self-test Bank",
accountName: "Self-test Vendor",
accountNo: "000000000000",
branch: "000001",
ifsc: "TEST0000001",
isDefault: true,
});
s = await api.saveSettings({
...s,
vendorName: s.vendorName || "Self-test Vendor",
vendorAddress: s.vendorAddress || "1 Test Road, Bengaluru - 560001",
vendorStateCode: s.vendorStateCode || "29",
defaultBankId: saved.id,
onboarded: true,
});
banks = await api.listBanks();
}
const found = banks.find((b) => b.id === s.defaultBankId) ?? banks[0]; const found = banks.find((b) => b.id === s.defaultBankId) ?? banks[0];
if (!found) throw new Error("the fresh database has no bank account"); if (!found) throw new Error("no bank account after provisioning");
bank = found; bank = found;
return `${s.vendorName}, ${s.gstRegistration}, state ${s.vendorStateCode}, bank "${bank.label}"`; return `${s.vendorName}, ${s.gstRegistration}, state ${s.vendorStateCode}, bank "${bank.label}"`;
}); });
+31
View File
@@ -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 */
}
}
+1 -1
View File
@@ -273,7 +273,7 @@ export const INDIAN_STATES: { code: string; name: string }[] = [
{ code: "09", name: "Uttar Pradesh" }, { code: "09", name: "Uttar Pradesh" },
{ code: "10", name: "Bihar" }, { code: "10", name: "Bihar" },
{ code: "11", name: "Sikkim" }, { code: "11", name: "Sikkim" },
{ code: "12", name: "Sampleachal Pradesh" }, { code: "12", name: "Arunachal Pradesh" },
{ code: "13", name: "Nagaland" }, { code: "13", name: "Nagaland" },
{ code: "14", name: "Manipur" }, { code: "14", name: "Manipur" },
{ code: "15", name: "Mizoram" }, { code: "15", name: "Mizoram" },
+2 -2
View File
@@ -210,10 +210,10 @@ describe("Row", () => {
}); });
describe("FitText and IdText", () => { describe("FitText and IdText", () => {
const EMAIL60 = "Sample.longsurname.voiceover.studio.department@examplemedia.co.in".slice(0, 60); const EMAIL60 = "anon.longsurname.voiceover.studio.department@examplemedia.co.in".slice(0, 60);
it("shrinks a medium e-mail to fit and keeps it on one line", async () => { it("shrinks a medium e-mail to fit and keeps it on one line", async () => {
const email = "vendor.name@examplestudios.co.in"; const email = "anon.longsurname.voiceover@examplestudios.co.in";
let info!: FitInfo; let info!: FitInfo;
const pdf = await renderPdfText( const pdf = await renderPdfText(
frame( frame(
+9 -9
View File
@@ -15,7 +15,7 @@ const logo = (over: Partial<SlotLogoInput>): SlotLogoInput => ({
hasKnockout: true, hasKnockout: true,
...over, ...over,
}); });
const Sample_P = logo({ aspect: 4.62, density: 0.437, kind: "wordmark", includesName: true }); const SAMPLE_LOGO = logo({ aspect: 4.62, density: 0.437, kind: "wordmark", includesName: true });
/** The box a logo is fitted to: the allowance for a text-only slot, else the lockup (or mark) box. */ /** The box a logo is fitted to: the allowance for a text-only slot, else the lockup (or mark) box. */
const lockupTarget = (s: TemplateSlot): Box => (s.textOnly ? s.allowance : s.lockup!); const lockupTarget = (s: TemplateSlot): Box => (s.textOnly ? s.allowance : s.lockup!);
@@ -98,9 +98,9 @@ describe("computeLogoPlacement identity: the placeholder itself comes back as it
}); });
describe("computeLogoPlacement text-only allowances", () => { describe("computeLogoPlacement text-only allowances", () => {
it("Test Vendor in Monolith is 28 pt tall, about 129 wide, and keeps the right edge", () => { it("The sample logo in Monolith is 28 pt tall, about 129 wide, and keeps the right edge", () => {
const s = SLOTS.monolith!; const s = SLOTS.monolith!;
const p = computeLogoPlacement({ logo: Sample_P, slot: s }); const p = computeLogoPlacement({ logo: SAMPLE_LOGO, slot: s });
expect(p.h).toBeCloseTo(28, 6); expect(p.h).toBeCloseTo(28, 6);
expect(p.w).toBeGreaterThanOrEqual(128); expect(p.w).toBeGreaterThanOrEqual(128);
expect(p.w).toBeLessThanOrEqual(130); expect(p.w).toBeLessThanOrEqual(130);
@@ -117,9 +117,9 @@ describe("computeLogoPlacement text-only allowances", () => {
expect(p.x + p.w).toBeCloseTo(560, 6); expect(p.x + p.w).toBeCloseTo(560, 6);
}); });
it("Test Vendor in Tangerine fills 36 pt of height (about 166 wide) at the left edge", () => { it("The sample logo in Tangerine fills 36 pt of height (about 166 wide) at the left edge", () => {
const s = SLOTS["tangerine-ledger"]!; const s = SLOTS["tangerine-ledger"]!;
const p = computeLogoPlacement({ logo: Sample_P, slot: s }); const p = computeLogoPlacement({ logo: SAMPLE_LOGO, slot: s });
expect(p.h).toBeCloseTo(36, 6); expect(p.h).toBeCloseTo(36, 6);
expect(p.w).toBeCloseTo(36 * 4.62, 6); expect(p.w).toBeCloseTo(36 * 4.62, 6);
expect(p.x).toBeCloseTo(60.5, 6); expect(p.x).toBeCloseTo(60.5, 6);
@@ -142,13 +142,13 @@ describe("computeLogoPlacement text-only allowances", () => {
describe("computeLogoPlacement on every slot", () => { describe("computeLogoPlacement on every slot", () => {
const all: [TemplateId, TemplateSlot][] = [...measured, ...optIn]; const all: [TemplateId, TemplateSlot][] = [...measured, ...optIn];
it("Test Vendor never exceeds its box and is never heavier than the placeholder ink", () => { it("The sample logo never exceeds its box and is never heavier than the placeholder ink", () => {
for (const [id, s] of all) { for (const [id, s] of all) {
const p = computeLogoPlacement({ logo: Sample_P, slot: s }); const p = computeLogoPlacement({ logo: SAMPLE_LOGO, slot: s });
expect(p.slotKind).toBe("lockup"); expect(p.slotKind).toBe("lockup");
expect(inside(p.box, lockupTarget(s)), id).toBe(true); expect(inside(p.box, lockupTarget(s)), id).toBe(true);
if (!s.optIn && !s.textOnly) { if (!s.optIn && !s.textOnly) {
const ink = p.w * p.h * Sample_P.density; const ink = p.w * p.h * SAMPLE_LOGO.density;
const placeholderInk = s.lockup!.w * s.lockup!.h * s.lockupDensity; const placeholderInk = s.lockup!.w * s.lockup!.h * s.lockupDensity;
expect(ink, `${id}: ${ink.toFixed(1)} vs ${placeholderInk.toFixed(1)}`).toBeLessThanOrEqual(placeholderInk * 1.05); expect(ink, `${id}: ${ink.toFixed(1)} vs ${placeholderInk.toFixed(1)}`).toBeLessThanOrEqual(placeholderInk * 1.05);
} }
@@ -265,7 +265,7 @@ describe("computeLogoPlacement page scale and available room", () => {
it("shrinks to the room the layout has", () => { it("shrinks to the room the layout has", () => {
const s = SLOTS["teal-swoosh"]!; const s = SLOTS["teal-swoosh"]!;
const p = computeLogoPlacement({ logo: Sample_P, slot: s, available: { w: 80, h: 100 } }); const p = computeLogoPlacement({ logo: SAMPLE_LOGO, slot: s, available: { w: 80, h: 100 } });
expect(p.w).toBeLessThanOrEqual(80 + 1e-9); expect(p.w).toBeLessThanOrEqual(80 + 1e-9);
expect(p.x).toBeCloseTo(s.lockup!.x, 6); expect(p.x).toBeCloseTo(s.lockup!.x, 6);
}); });
+1 -1
View File
@@ -5,7 +5,7 @@ const BOX = { w: 170, h: 54 };
const base = { box: BOX, availableWidth: 299, minTextWidth: 160 }; const base = { box: BOX, availableWidth: 299, minTextWidth: 160 };
describe("computeClassicLogoPlacement", () => { describe("computeClassicLogoPlacement", () => {
it("the Test Vendor trimmed 4.6:1 logo is width-limited and stacks above the text", () => { it("the sample trimmed 4.6:1 logo is width-limited and stacks above the text", () => {
const p = computeClassicLogoPlacement({ ...base, aspect: 4.6 }); const p = computeClassicLogoPlacement({ ...base, aspect: 4.6 });
expect(p.w).toBeCloseTo(170, 6); expect(p.w).toBeCloseTo(170, 6);
expect(p.h).toBeCloseTo(170 / 4.6, 6); expect(p.h).toBeCloseTo(170 / 4.6, 6);
+2 -2
View File
@@ -12,7 +12,7 @@ const SAMPLES: string[] = [
"Voice-over session 12 - narration for module 3", "Voice-over session 12 - narration for module 3",
"Total ₹1,23,456.78", "Total ₹1,23,456.78",
"AVATAR WAVE To Ty Yo", "AVATAR WAVE To Ty Yo",
"vendor.name@examplestudios.co.in", "anon.longsurname.voiceover@examplestudios.co.in",
"29ABCDE1234F1Z5", "29ABCDE1234F1Z5",
"office fi fl ffi final", "office fi fl ffi final",
"BILLED TO", "BILLED TO",
@@ -130,7 +130,7 @@ describe("measurer helpers", () => {
}); });
it("round-trips chunkIdentifier output", () => { it("round-trips chunkIdentifier output", () => {
const id = "vendor.name@examplestudios.co.in"; const id = "anon.longsurname.voiceover@examplestudios.co.in";
expect(chunkIdentifier(id, 12).join("")).toBe(id); expect(chunkIdentifier(id, 12).join("")).toBe(id);
}); });
}); });
+1 -1
View File
@@ -173,7 +173,7 @@ describe("Slate Band: dark band and logo", () => {
for (const [i, page] of r.pdf.pages.entries()) { for (const [i, page] of r.pdf.pages.entries()) {
const text = page.items.map((t) => t.str).join(" "); const text = page.items.map((t) => t.str).join(" ");
expect(text, `page ${i + 1} phone`).toContain("9000000000"); expect(text, `page ${i + 1} phone`).toContain("9000000000");
expect(text, `page ${i + 1} e-mail`).toContain("vendor@example.com"); expect(text, `page ${i + 1} e-mail`).toContain("vendor.test@example.in");
} }
}); });
}); });
+1 -1
View File
@@ -316,7 +316,7 @@ describe("closing and footer", () => {
expect(closing.box.y + closing.box.h).toBeCloseTo(frame.page.height - frame.pad.bottom, 0); expect(closing.box.y + closing.box.h).toBeCloseTo(frame.page.height - frame.pad.bottom, 0);
const text = r.pdf.pages[last].items.map((i) => i.str).join(" "); const text = r.pdf.pages[last].items.map((i) => i.str).join(" ");
expect(text).toContain("9000000000"); expect(text).toContain("9000000000");
expect(text).toContain("vendor@example.com"); expect(text).toContain("vendor.test@example.in");
}); });
it("a pasted signature image replaces the script: right-aligned, at most 36 pt tall, with the signatory name and designation under it", async () => { it("a pasted signature image replaces the script: right-aligned, at most 36 pt tall, with the signatory name and designation under it", async () => {
+6 -6
View File
@@ -31,8 +31,8 @@ const FIXTURE_CLIENT_GSTIN = "29ABCDE1234F1Z5";
const baseVendor: Settings = { const baseVendor: Settings = {
vendorName: "Test Vendor", vendorName: "Test Vendor",
vendorAddress: "1 Test Road, Bangalore - 560001", vendorAddress: "27 Example Apartments, Sample Layout, Test Nagar, Bangalore - 560001",
vendorEmail: "vendor@example.com", vendorEmail: "vendor.test@example.in",
vendorPhone: "9000000000", vendorPhone: "9000000000",
vendorPan: "ABCDE1234F", vendorPan: "ABCDE1234F",
vendorGstin: "", vendorGstin: "",
@@ -165,8 +165,8 @@ export function fixtureLogo(kind: LogoKind): LogoInput | null {
let hit = cache.get(kind); let hit = cache.get(kind);
if (!hit) { if (!hit) {
if (kind === "real") { if (kind === "real") {
// 2172 x 724: the untrimmed Test Vendor logo, a 3:1 aspect. // 2172 x 724: the untrimmed sample logo, a 3:1 aspect.
const png = readFileSync(path.resolve(process.cwd(), "public/brand/sample-wordmark-logo.png")); const png = readFileSync(path.resolve(process.cwd(), "src/pdf/testing/fixtures/sample-wordmark-logo.png"));
const [width, height] = [png.readUInt32BE(16), png.readUInt32BE(20)]; const [width, height] = [png.readUInt32BE(16), png.readUInt32BE(20)];
hit = { dataUri: dataUri(png), width, height, aspect: width / height, density: 0.21, kind: "wordmark", includesName: true }; hit = { dataUri: dataUri(png), width, height, aspect: width / height, density: 0.21, kind: "wordmark", includesName: true };
} else if (kind === "knockout") { } else if (kind === "knockout") {
@@ -183,7 +183,7 @@ export function fixtureLogo(kind: LogoKind): LogoInput | null {
includesName: true, includesName: true,
}; };
} else if (kind === "wide") { } else if (kind === "wide") {
// The trimmed Test Vendor ink box is 4.6:1. // The trimmed sample ink box is 4.6:1.
hit = { dataUri: dataUri(solidPng(460, 100)), width: 460, height: 100, aspect: 4.6, density: 1, kind: "wordmark", includesName: true }; hit = { dataUri: dataUri(solidPng(460, 100)), width: 460, height: 100, aspect: 4.6, density: 1, kind: "wordmark", includesName: true };
} else { } else {
hit = { dataUri: dataUri(solidPng(120, 120)), width: 120, height: 120, aspect: 1, density: 1, kind: "mark", includesName: false }; hit = { dataUri: dataUri(solidPng(120, 120)), width: 120, height: 120, aspect: 1, density: 1, kind: "mark", includesName: false };
@@ -281,7 +281,7 @@ export const FIXTURES: FixtureSpec[] = [
{ {
name: "id-tokens", name: "id-tokens",
vendor: { vendor: {
vendorEmail: "Sample.longsurname.voiceover.studio.bangalore@examplestudios-india.co.in", vendorEmail: "anon.longsurname.voiceover.studio.bangalore@examplestudios-india.co.in",
vendorPhone: "+91 97382 84459 / +91 80 4123 9988 (studio)", vendorPhone: "+91 97382 84459 / +91 80 4123 9988 (studio)",
vendorName: "Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt", vendorName: "Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt",
}, },
@@ -321,7 +321,7 @@ describe("totals and closing", () => {
const last = r.result.pages - 1; const last = r.result.pages - 1;
const closing = byId(r, CLOSING_ID, last); const closing = byId(r, CLOSING_ID, last);
const lines = textLines(closing).map((l) => l.text); const lines = textLines(closing).map((l) => l.text);
expect(lines).toEqual(expect.arrayContaining(["THANK YOU", "9000000000", "vendor@example.com", "Payment due within 30 days."])); expect(lines).toEqual(expect.arrayContaining(["THANK YOU", "9000000000", "vendor.test@example.in", "Payment due within 30 days."]));
expect(onPage(r, last).filter((n) => n.type === "svg" && n.box.y >= closing.box.y)).toHaveLength(3); expect(onPage(r, last).filter((n) => n.type === "svg" && n.box.y >= closing.box.y)).toHaveLength(3);
const sign = byId(r, SLOT_ID.signatory, last); const sign = byId(r, SLOT_ID.signatory, last);
expect(sign.box.w).toBeCloseTo(V.closing.ruledW, 0); expect(sign.box.w).toBeCloseTo(V.closing.ruledW, 0);
+3 -3
View File
@@ -119,14 +119,14 @@ describe("what the header does with a logo", () => {
expect(img.box.w).toBeLessThanOrEqual(lockup.w + 0.01); expect(img.box.w).toBeLessThanOrEqual(lockup.w + 0.01);
expect(img.box.x + img.box.w).toBeLessThanOrEqual(frameForPrefs(wide.prefs).page.width - frameForPrefs(wide.prefs).pad.right + 0.5); expect(img.box.x + img.box.w).toBeLessThanOrEqual(frameForPrefs(wide.prefs).page.width - frameForPrefs(wide.prefs).pad.right + 0.5);
// The business name is not repeated as text in the header (the party heading still prints it once). // The business name is not repeated as text in the header (the party heading still prints it once).
expect(textOf(wide)[0].match(/Sample P/g)?.length).toBe(1); expect(textOf(wide)[0].match(/TEST VENDOR/g)?.length).toBe(1);
const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "crimson-grid" })); const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "crimson-grid" }));
const [mark] = imageBoxes(square); const [mark] = imageBoxes(square);
const markBox = SLOTS["crimson-grid"]!.mark!; const markBox = SLOTS["crimson-grid"]!.mark!;
expect(mark.box.h).toBeLessThanOrEqual(markBox.h + 0.01); expect(mark.box.h).toBeLessThanOrEqual(markBox.h + 0.01);
expect(mark.box.w).toBeLessThanOrEqual(markBox.w + 0.01); expect(mark.box.w).toBeLessThanOrEqual(markBox.w + 0.01);
expect(textOf(square)[0].match(/Sample P/g)?.length).toBe(2); expect(textOf(square)[0].match(/TEST VENDOR/g)?.length).toBe(2);
}); });
it("Tangerine: the logo stays in its 220 x 36 allowance at the left", async () => { it("Tangerine: the logo stays in its 220 x 36 allowance at the left", async () => {
@@ -148,7 +148,7 @@ describe("what the header does with a logo", () => {
const r = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: t.id })); const r = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: t.id }));
expect(imageBoxes(r), t.id).toHaveLength(0); expect(imageBoxes(r), t.id).toHaveLength(0);
// The brand text in the header, the party heading and the footer: three prints of the name, one of them at the top. // The brand text in the header, the party heading and the footer: three prints of the name, one of them at the top.
const names = r.pdf.pages[0].items.filter((i) => /^Sample p$/i.test(i.str.trim())); const names = r.pdf.pages[0].items.filter((i) => /^test vendor$/i.test(i.str.trim()));
expect(names.length, t.id).toBe(3); expect(names.length, t.id).toBe(3);
expect(names.filter((i) => i.bottom < 150).length, t.id).toBe(1); expect(names.filter((i) => i.bottom < 150).length, t.id).toBe(1);
expect(r.pdf.raw, t.id).toContain("Poppins"); expect(r.pdf.raw, t.id).toContain("Poppins");
+4 -4
View File
@@ -157,7 +157,7 @@ describe("header", () => {
it("the vendor name stands in the empty left corner, level with the title, in Lustria caps", async () => { it("the vendor name stands in the empty left corner, level with the title, in Lustria caps", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" })); const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" }));
const name = items(r, "Sample P")[0]; const name = items(r, "TEST VENDOR")[0];
expect(name.bottom).toBeCloseTo(124.9, 0); expect(name.bottom).toBeCloseTo(124.9, 0);
expect(name.x0).toBeCloseTo(38.5, 0); expect(name.x0).toBeCloseTo(38.5, 0);
}); });
@@ -218,7 +218,7 @@ describe("logo", () => {
it("without the opt-in no logo is drawn, even when the invoice has one", async () => { it("without the opt-in no logo is drawn, even when the invoice has one", async () => {
const r = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor("A4", { templateId: "marble" })); const r = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor("A4", { templateId: "marble" }));
expect(imageBoxes(r)).toHaveLength(0); expect(imageBoxes(r)).toHaveLength(0);
expect(items(r, "Sample P")).toHaveLength(1); expect(items(r, "TEST VENDOR")).toHaveLength(1);
}); });
it("every opted-in logo fits the 150 x 50 slot, starts at the text inset and rests on its bottom edge", async () => { it("every opted-in logo fits the 150 x 50 slot, starts at the text inset and rests on its bottom edge", async () => {
@@ -250,7 +250,7 @@ describe("logo", () => {
}); });
it("a wordmark replaces the vendor name text; a mark keeps it under the mark", async () => { it("a wordmark replaces the vendor name text; a mark keeps it under the mark", async () => {
const header = (r: R) => r.pdf.pages[0].items.filter((i) => squash(i.str) === "SampleP" && i.bottom < 200); const header = (r: R) => r.pdf.pages[0].items.filter((i) => squash(i.str) === "TESTVENDOR" && i.bottom < 200);
const wide = await renderFixture({ name: "o", items: 2, logo: "real" }, optIn()); const wide = await renderFixture({ name: "o", items: 2, logo: "real" }, optIn());
expect(header(wide)).toHaveLength(0); expect(header(wide)).toHaveLength(0);
const square = await renderFixture({ name: "o", items: 2, logo: "square" }, optIn()); const square = await renderFixture({ name: "o", items: 2, logo: "square" }, optIn());
@@ -416,7 +416,7 @@ describe("long text", () => {
it("the foot band's text stays inside the band whatever the address, and its two columns never overlap", async () => { it("the foot band's text stays inside the band whatever the address, and its two columns never overlap", async () => {
for (const size of ["A4", "LETTER"] as const) { for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture({ name: "long", items: 3, vendor: { vendorAddress: Array.from({ length: 8 }, (_, i) => `Address line ${i + 1} of a very long Indian address with a locality name`).join("\n"), vendorEmail: "Sample.longsurname.voiceover.studio.bangalore@examplestudios-india.co.in", vendorName: "Supercalifragilisticexpialidocious-Pneumonoultramicroscopic-1234567890" } }, prefsFor(size, { templateId: "marble" })); const r = await renderFixture({ name: "long", items: 3, vendor: { vendorAddress: Array.from({ length: 8 }, (_, i) => `Address line ${i + 1} of a very long Indian address with a locality name`).join("\n"), vendorEmail: "anon.longsurname.voiceover.studio.bangalore@examplestudios-india.co.in", vendorName: "Supercalifragilisticexpialidocious-Pneumonoultramicroscopic-1234567890" } }, prefsFor(size, { templateId: "marble" }));
const frame = frameForPrefs(r.prefs); const frame = frameForPrefs(r.prefs);
const decorText = onPage(r, 0).filter((n) => n.decor && n.type === "text" && n.box.y > frame.page.height - V.bands.bottom); const decorText = onPage(r, 0).filter((n) => n.decor && n.type === "text" && n.box.y > frame.page.height - V.bands.bottom);
const lines = decorText.flatMap((n) => textLines(n)); const lines = decorText.flatMap((n) => textLines(n));
+3 -3
View File
@@ -122,8 +122,8 @@ describe("page geometry", () => {
describe("helpers", () => { describe("helpers", () => {
it("initialsOf takes the first and last word", () => { it("initialsOf takes the first and last word", () => {
expect(initialsOf("Test Vendor")).toBe("AP"); expect(initialsOf("Test Vendor")).toBe("TV");
expect(initialsOf("Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt")).toBe("AP"); expect(initialsOf("Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt")).toBe("VP");
expect(initialsOf("Bongbetic")).toBe("B"); expect(initialsOf("Bongbetic")).toBe("B");
expect(initialsOf(" ")).toBe(""); expect(initialsOf(" ")).toBe("");
expect(initialsOf("& Co")).toBe("C"); expect(initialsOf("& Co")).toBe("C");
@@ -151,7 +151,7 @@ describe("what the layout does with a logo", () => {
const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: t.id })); const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: t.id }));
expect(imageCount(none), t.id).toBe(0); expect(imageCount(none), t.id).toBe(0);
const text = none.pdf.pages[0].items.map((i) => i.str).join(" "); const text = none.pdf.pages[0].items.map((i) => i.str).join(" ");
if (t.id === "monogram") expect(text).toContain("AP"); if (t.id === "monogram") expect(text).toContain("TV");
// No reference ring or stamp is ever drawn. // No reference ring or stamp is ever drawn.
expect(nodes(none.result.core.layout[0].nodes).filter((n) => n.type === "svg" && !n.decor), t.id).toHaveLength(0); expect(nodes(none.result.core.layout[0].nodes).filter((n) => n.type === "svg" && !n.decor), t.id).toHaveLength(0);
} }
@@ -340,7 +340,7 @@ describe("Citrus Split", () => {
for (const i of icons) expect(i.box.x + i.box.w).toBeLessThanOrEqual(frame.page.width - frame.pad.right + 0.5); for (const i of icons) expect(i.box.x + i.box.w).toBeLessThanOrEqual(frame.page.width - frame.pad.right + 0.5);
const text = textsOf(r, r.result.pages - 1); const text = textsOf(r, r.result.pages - 1);
expect(text).toContain("9000000000"); expect(text).toContain("9000000000");
expect(text).toContain("vendor@example.com"); expect(text).toContain("vendor.test@example.in");
}); });
it("the logo stays inside its measured lockup box; a wordmark replaces the name, a mark keeps it", async () => { it("the logo stays inside its measured lockup box; a wordmark replaces the name, a mark keeps it", async () => {
+1 -1
View File
@@ -6,7 +6,7 @@ import { SLOTS } from "./slots.generated";
* is a free area named in docs/templates/NN-*.md and is an opt-in position, off by default (the plan * is a free area named in docs/templates/NN-*.md and is an opt-in position, off by default (the plan
* keeps Linea logo-free so it still matches format.pdf). Hand-written; not produced by logo:measure. * keeps Linea logo-free so it still matches format.pdf). Hand-written; not produced by logo:measure.
* *
* With no placeholder ink to match, the density is a typical logo's (Test Vendor is 0.437), so the weight * With no placeholder ink to match, the density is a typical logo's (the sample logo is 0.437), so the weight
* factor in computeLogoPlacement only shrinks logos that are denser than that. * factor in computeLogoPlacement only shrinks logos that are denser than that.
*/ */
const TYPICAL_DENSITY = 0.4; const TYPICAL_DENSITY = 0.4;
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+4 -4
View File
@@ -1,23 +1,23 @@
{ {
"classic-a4-tax-3": { "classic-a4-tax-3": {
"pages": 1, "pages": 1,
"fingerprint": "35b03b20aeec3dcbb808300b6e8f0b1867dfd69d5fd6f69aaee891c354955092", "fingerprint": "87b12a5c1b1a9b45ac6a8fd5862c8e7b5de8d203731fe4e485872dea5bbb07cd",
"invoiceNumber": "ST/2026-001", "invoiceNumber": "ST/2026-001",
"totalText": "₹8,625.80", "totalText": "₹8,625.80",
"pageLabel": "Page 1 of 1", "pageLabel": "Page 1 of 1",
"textHashes": [ "textHashes": [
"855dd2d104e80ff599210d69f643be0ccb281e6583cbcea2dafa5fdcb09f03fc" "ddda2419b84480c00d8ed76088898277711f448ef11c2383a48494ecd0e4c524"
], ],
"firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A GSTIN: 29AAAAA0000A1Z5 TAX INVOICE Invoice No. ST/2026-001 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 GSTIN: 29ABCDE1234F1Z5 Place of Supply: 29 – Karnataka S . N O D E S C R I P TI O N H S N /S AC U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 998397 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 998397 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 998397 ₹1,200.00 per hour 3 ₹3,600.00 Subtotal ₹7,310.00 Taxable value ₹7,310.00 CGST (9%) ₹657.90 SGST (9%) ₹657.90 Total ₹8,625.80 Amount in words: Indian Rupees Eight Thousand Six Hundred Twenty Five and Eighty Paise Only Tax is payable on reverse charge: No PAY T O Selftest Studio State Bank of India Account No.: 00000000000 Branch: 000000 IFSC: SBIN0000000 Selftest Studio Proprietor Computer-generated invoice; no signature is required. Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 1" "firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A GSTIN: 29AAAAA0000A1Z5 TAX INVOICE Invoice No. ST/2026-001 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 GSTIN: 29ABCDE1234F1Z5 Place of Supply: 29 – Karnataka S . N O D E S C R I P TI O N H S N /S AC U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 998397 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 998397 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 998397 ₹1,200.00 per hour 3 ₹3,600.00 Subtotal ₹7,310.00 Taxable value ₹7,310.00 CGST (9%) ₹657.90 SGST (9%) ₹657.90 Total ₹8,625.80 Amount in words: Indian Rupees Eight Thousand Six Hundred Twenty Five and Eighty Paise Only Tax is payable on reverse charge: No PAY T O Selftest Studio State Bank of India Account No.: 00000000000 Branch: 000000 IFSC: SBIN0000000 Selftest Studio Proprietor Computer-generated invoice; no signature is required. Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 1"
}, },
"classic-letter-unreg-60": { "classic-letter-unreg-60": {
"pages": 3, "pages": 3,
"fingerprint": "78d60e1b72dac0e40fea96cbf34c35cfeb637dbe0bc9754dcecba72231197e75", "fingerprint": "6999be0875f78e2c87aee1fd0ffa1dfe651faa7a7c0a9b4c2f78c1bdff53c7d4",
"invoiceNumber": "ST/2026-002", "invoiceNumber": "ST/2026-002",
"totalText": "₹1,56,000.00", "totalText": "₹1,56,000.00",
"pageLabel": "Page 1 of 3", "pageLabel": "Page 1 of 3",
"textHashes": [ "textHashes": [
"1728b53a177e385584d2dc33b2e71607f860886632f32a5911364f86f345c950", "4a0cf8a7c1565037edd4d9941251e030fd9e6fca25845b971465e5cf9d916936",
"870e9a70c0546550d002e5eb5728f342b1d235094c6b645ddda68a6306b33f7b", "870e9a70c0546550d002e5eb5728f342b1d235094c6b645ddda68a6306b33f7b",
"fca30345bc73289cdcad200f888f917ff9a5386cdd64e41ea7dccc64f2b7e5e0" "fca30345bc73289cdcad200f888f917ff9a5386cdd64e41ea7dccc64f2b7e5e0"
], ],
+35 -4
View File
@@ -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;
+31 -107
View File
@@ -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); };
const onBankSaved = (saved: BankAccount) => {
if (saved.isDefault || form.defaultBankId === null) { if (saved.isDefault || form.defaultBankId === null) {
setForm((prev) => ({ ...prev, defaultBankId: saved.id })); setForm((prev) => ({ ...prev, defaultBankId: saved.id }));
} }
setEditing(null); setBankModalOpen(false);
await loadBanks(); void loadBanks();
toast.success("Bank account saved", "Click Save settings to keep a changed default account."); toast.info("Click Save settings to keep a changed default account.");
} catch (e) {
toast.error("Could not save bank account", String(e));
} finally {
setBusy(false);
}
}; };
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}
onChange={(e) => setBank("label", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="b-bankName"
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>
<Button kind="ghost" size="sm" onClick={() => setEditing(null)}> <BankAccountModal
Cancel open={bankModalOpen}
</Button> bank={editing}
</div> launcherRef={bankLauncher}
</div> onClose={() => setBankModalOpen(false)}
) : null} onSaved={onBankSaved}
/>
</section> </section>
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
@@ -516,7 +440,7 @@ export default function AppSettings({
</section> </section>
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<DataBackupPanel /> <DataBackupPanel settings={settings} onRerunSetup={onSaved} />
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<ErpnextSettingsPanel active={tab === SETTINGS_TABS.findIndex((t) => t.id === "integrations")} /> <ErpnextSettingsPanel active={tab === SETTINGS_TABS.findIndex((t) => t.id === "integrations")} />
+42 -2
View File
@@ -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)}
>
Add 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}
+12 -6
View File
@@ -46,6 +46,8 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
const [bank, setBank] = useState<BankAccount>(bankers()); const [bank, setBank] = useState<BankAccount>(bankers());
const [prefix, setPrefix] = useState(`INV/${new Date().getFullYear()}-`); const [prefix, setPrefix] = useState(`INV/${new Date().getFullYear()}-`);
const [padding, setPadding] = useState(3); const [padding, setPadding] = useState(3);
// The series that is active when the wizard opens. A re-run keeps it (and its counter) unless changed.
const [loadedSeries, setLoadedSeries] = useState<{ prefix: string; padding: number } | null>(null);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
@@ -58,6 +60,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
if (series) { if (series) {
setPrefix(series.prefix); setPrefix(series.prefix);
setPadding(series.padding); setPadding(series.padding);
setLoadedSeries({ prefix: series.prefix, padding: series.padding });
} }
} catch { } catch {
/* first run – defaults are fine */ /* first run – defaults are fine */
@@ -89,7 +92,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
phoneError(form.vendorPhone) || phoneError(form.vendorPhone) ||
gstinProblem, gstinProblem,
), ),
[form.vendorPan, form.vendorEmail, form.vendorPhone, gstinProblem], [form.vendorName, form.vendorStateCode, form.vendorPan, form.vendorEmail, form.vendorPhone, gstinProblem],
); );
const finish = async () => { const finish = async () => {
@@ -97,10 +100,13 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
setError(null); setError(null);
try { try {
const savedBank = await api.saveBank({ ...bank, isDefault: true }); const savedBank = await api.saveBank({ ...bank, isDefault: true });
await api.startNewSeries(prefix.trim() || `INV/${new Date().getFullYear()}-`, padding); const wantedPrefix = prefix.trim() || `INV/${new Date().getFullYear()}-`;
if (!loadedSeries || loadedSeries.prefix !== wantedPrefix || loadedSeries.padding !== padding) {
await api.startNewSeries(wantedPrefix, padding);
}
const next: Settings = { const next: Settings = {
...form, ...form,
vendorName: form.vendorName.trim() || "Vendor", vendorName: form.vendorName.trim(),
defaultBankId: savedBank.id, defaultBankId: savedBank.id,
onboarded: true, onboarded: true,
}; };
@@ -120,8 +126,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
<Grid narrow> <Grid narrow>
<Column sm={4} md={8} lg={12}> <Column sm={4} md={8} lg={12}>
<p className="voiced-muted" style={{ marginBottom: "1rem" }}> <p className="voiced-muted" style={{ marginBottom: "1rem" }}>
These details are printed on every invoice. They are pre-filled from your existing invoice — These details are printed on every invoice. You can change them later in Settings.
adjust anything that has changed.
</p> </p>
</Column> </Column>
<Column sm={4} md={8} lg={12}> <Column sm={4} md={8} lg={12}>
@@ -207,6 +212,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
value={form.vendorStateCode} value={form.vendorStateCode}
onChange={(e) => set("vendorStateCode", e.target.value)} onChange={(e) => set("vendorStateCode", e.target.value)}
> >
<SelectItem value="" text="Choose your state" disabled hidden />
{INDIAN_STATES.map((s) => ( {INDIAN_STATES.map((s) => (
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} /> <SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
))} ))}
@@ -392,7 +398,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
{saving ? "Saving…" : "Finish setup"} {saving ? "Saving…" : "Finish setup"}
</Button> </Button>
) : ( ) : (
<Button onClick={() => setStep((s) => s + 1)}>Next</Button> <Button disabled={step === 0 && businessInvalid} onClick={() => setStep((s) => s + 1)}>Next</Button>
)} )}
</div> </div>
</div> </div>