Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1031b3ecc | ||
|
|
4960e26d8c | ||
|
|
3cb0ac9236 | ||
|
|
9bdb142090 | ||
|
|
c78bf1a851 | ||
|
|
c3e2e05e89 | ||
|
|
e786580d42 | ||
|
|
6efb498409 | ||
|
|
ae80922d03 |
@@ -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".
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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}'.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -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);
|
||||||
|
|||||||
@@ -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"));
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Clients from "./views/Clients";
|
|||||||
import { ToastProvider, useToast } from "./components/ToastProvider";
|
import { ToastProvider, useToast } from "./components/ToastProvider";
|
||||||
import AppSettings from "./views/AppSettings";
|
import AppSettings from "./views/AppSettings";
|
||||||
import { requestSettingsTab } from "./lib/settingsTab";
|
import { requestSettingsTab } from "./lib/settingsTab";
|
||||||
|
import { applyDocumentTheme, cachedTheme, normalizeTheme } from "./lib/theme";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -46,9 +47,15 @@ function AppInner() {
|
|||||||
void refreshSettings();
|
void refreshSettings();
|
||||||
}, [refreshSettings]);
|
}, [refreshSettings]);
|
||||||
|
|
||||||
|
// Before settings load (and on the error screen) use the cached theme so the window never flashes white.
|
||||||
|
const theme = settings ? normalizeTheme(settings.theme) : cachedTheme();
|
||||||
|
useEffect(() => {
|
||||||
|
applyDocumentTheme(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Theme theme="g10">
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<div className="voiced-empty">
|
<div className="voiced-empty">
|
||||||
<Loading description="Loading Voiced" withOverlay={false} small />
|
<Loading description="Loading Voiced" withOverlay={false} small />
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +65,7 @@ function AppInner() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Theme theme="g10">
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<div className="voiced-page">
|
<div className="voiced-page">
|
||||||
<InlineNotification
|
<InlineNotification
|
||||||
kind="error"
|
kind="error"
|
||||||
@@ -74,11 +81,9 @@ function AppInner() {
|
|||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
const theme = settings.theme === "g100" ? "g100" : "g10";
|
|
||||||
|
|
||||||
if (!settings.onboarded) {
|
if (!settings.onboarded) {
|
||||||
return (
|
return (
|
||||||
<Theme theme={theme}>
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<Onboarding
|
<Onboarding
|
||||||
initial={settings}
|
initial={settings}
|
||||||
onDone={(next) => {
|
onDone={(next) => {
|
||||||
@@ -106,8 +111,8 @@ function AppInner() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Theme theme={theme}>
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}>
|
<AppShell view={view} onNavigate={setView} theme={theme} onToggleTheme={toggleTheme}>
|
||||||
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
|
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
|
||||||
<NewInvoice
|
<NewInvoice
|
||||||
settings={settings}
|
settings={settings}
|
||||||
@@ -116,6 +121,8 @@ function AppInner() {
|
|||||||
onActivate={() => setView("new")}
|
onActivate={() => setView("new")}
|
||||||
openDraftId={openDraftId}
|
openDraftId={openDraftId}
|
||||||
onDraftOpened={() => setOpenDraftId(null)}
|
onDraftOpened={() => setOpenDraftId(null)}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={toggleTheme}
|
||||||
/>
|
/>
|
||||||
<div hidden={view !== "invoices"}>
|
<div hidden={view !== "invoices"}>
|
||||||
<InvoiceHistory
|
<InvoiceHistory
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: A
|
|||||||
))}
|
))}
|
||||||
</HeaderNavigation>
|
</HeaderNavigation>
|
||||||
<HeaderGlobalBar>
|
<HeaderGlobalBar>
|
||||||
<HeaderGlobalAction aria-label="Toggle colour theme" onClick={onToggleTheme}>
|
<HeaderGlobalAction
|
||||||
|
aria-label={theme === "g100" ? "Switch to light theme" : "Switch to dark theme"}
|
||||||
|
tooltipAlignment="end"
|
||||||
|
onClick={onToggleTheme}
|
||||||
|
>
|
||||||
{theme === "g100" ? <Light size={20} /> : <Asleep size={20} />}
|
{theme === "g100" ? <Light size={20} /> : <Asleep size={20} />}
|
||||||
</HeaderGlobalAction>
|
</HeaderGlobalAction>
|
||||||
</HeaderGlobalBar>
|
</HeaderGlobalBar>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useEffect, useState, type RefObject } from "react";
|
||||||
|
import { Column, Grid, Modal, TextInput, Toggle } from "@carbon/react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import type { BankAccount } from "../lib/types";
|
||||||
|
import { ifscError } from "../lib/validators";
|
||||||
|
import { useToast } from "./ToastProvider";
|
||||||
|
|
||||||
|
export const blankBank = (): BankAccount => ({
|
||||||
|
id: null,
|
||||||
|
label: "",
|
||||||
|
bankName: "",
|
||||||
|
accountName: "",
|
||||||
|
accountNo: "",
|
||||||
|
branch: "",
|
||||||
|
ifsc: "",
|
||||||
|
isDefault: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface BankAccountModalProps {
|
||||||
|
open: boolean;
|
||||||
|
/** The account to edit; null opens a blank one. */
|
||||||
|
bank: BankAccount | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (bank: BankAccount) => void;
|
||||||
|
/** Focus returns here when the modal closes. */
|
||||||
|
launcherRef?: RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BankAccountModal({ open, bank, onClose, onSaved, launcherRef }: BankAccountModalProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [form, setForm] = useState<BankAccount>(blankBank());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [touched, setTouched] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setForm(bank ? { ...bank } : blankBank());
|
||||||
|
setBusy(false);
|
||||||
|
setTouched(false);
|
||||||
|
}, [open, bank]);
|
||||||
|
|
||||||
|
const set = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
const bankNameProblem = form.bankName.trim() ? undefined : "Bank name is required";
|
||||||
|
const accountNoProblem = form.accountNo.trim() ? undefined : "Account number is required";
|
||||||
|
const ifscProblem = ifscError(form.ifsc);
|
||||||
|
const blocking = bankNameProblem ?? accountNoProblem ?? ifscProblem;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setTouched(true);
|
||||||
|
if (blocking) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const saved = await api.saveBank({
|
||||||
|
...form,
|
||||||
|
bankName: form.bankName.trim(),
|
||||||
|
accountNo: form.accountNo.trim(),
|
||||||
|
ifsc: form.ifsc.trim().toUpperCase(),
|
||||||
|
});
|
||||||
|
toast.success(form.id === null ? "Bank account added" : "Bank account saved", saved.bankName);
|
||||||
|
onSaved(saved);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not save bank account", String(e));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
modalHeading={bank?.id ? "Edit bank account" : "New bank account"}
|
||||||
|
primaryButtonText={busy ? "Saving…" : "Save account"}
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
primaryButtonDisabled={busy}
|
||||||
|
onRequestClose={onClose}
|
||||||
|
onRequestSubmit={() => void submit()}
|
||||||
|
launcherButtonRef={launcherRef}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Grid narrow>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-label"
|
||||||
|
labelText="Label (optional)"
|
||||||
|
value={form.label}
|
||||||
|
onChange={(e) => set("label", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-bankName"
|
||||||
|
labelText="Bank name"
|
||||||
|
value={form.bankName}
|
||||||
|
onChange={(e) => set("bankName", e.target.value)}
|
||||||
|
invalid={touched && Boolean(bankNameProblem)}
|
||||||
|
invalidText={bankNameProblem}
|
||||||
|
data-modal-primary-focus
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-accountName"
|
||||||
|
labelText="Account name"
|
||||||
|
value={form.accountName}
|
||||||
|
onChange={(e) => set("accountName", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-accountNo"
|
||||||
|
labelText="Account number"
|
||||||
|
value={form.accountNo}
|
||||||
|
onChange={(e) => set("accountNo", e.target.value)}
|
||||||
|
invalid={touched && Boolean(accountNoProblem)}
|
||||||
|
invalidText={accountNoProblem}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-branch"
|
||||||
|
labelText="Branch"
|
||||||
|
value={form.branch}
|
||||||
|
onChange={(e) => set("branch", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-ifsc"
|
||||||
|
labelText="IFSC"
|
||||||
|
value={form.ifsc}
|
||||||
|
onChange={(e) => set("ifsc", e.target.value.toUpperCase())}
|
||||||
|
invalid={Boolean(ifscProblem)}
|
||||||
|
invalidText={ifscProblem}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={8} lg={16}>
|
||||||
|
<Toggle
|
||||||
|
id="bm-default"
|
||||||
|
labelText="Use as default account"
|
||||||
|
toggled={form.isDefault}
|
||||||
|
onToggle={(value) => set("isDefault", value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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}"`;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
|
After Width: | Height: | Size: 30 KiB |
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||