Initial commit: Voiced invoice desktop app

Offline-first invoicing app for Arun P, built by Bongbetic.

- Tauri v2 (Rust) + React/TypeScript + IBM Carbon Design System UI
- SQLite (rusqlite) storage with a persistent invoice-number series
- Guided first-run setup, pre-filled vendor/bank details
- Fixed-amount or rate x quantity line items (per sec/min/hour/session/unit)
- GST as CGST+SGST or IGST, Indian amount-in-words
- Optional signature, with a computer-generated note as fallback
- Print-ready PDF export via @react-pdf/renderer
- Windows cross-compile via cargo-xwin (NSIS installer)

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
2026-10-03 23:07:51 +05:30
co-authored by CommandCodeBot
commit 3af9f3f66f
125 changed files with 13660 additions and 0 deletions
View File
+36
View File
@@ -0,0 +1,36 @@
# Fallback CI: build the Windows installer on a Windows runner.
# Requires a Gitea Actions runner labelled `windows` on git.bongbetic.com.
# The primary build path is `npm run app:build:windows` (cross-compile via cargo-xwin).
name: Windows build
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
build:
runs-on: windows
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Build the app
run: npm run app:build
- name: Upload installer
uses: actions/upload-artifact@v4
with:
name: voiced-windows
path: src-tauri/target/release/bundle/nsis/*.exe
+27
View File
@@ -0,0 +1,27 @@
# Dependencies
node_modules/
# Build output
dist/
dist-ssr/
# Rust / Tauri
src-tauri/target/
src-tauri/gen/
xwin/
*.pdb
# Local env
*.local
.env
.env.*
# Database (runtime data lives in the OS app-data dir, never here)
*.db
*.db-journal
*.sqlite
# Editor / OS
.DS_Store
.vscode/
.idea/
Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

+95
View File
@@ -0,0 +1,95 @@
# Voiced
An offline-first desktop invoicing app for **Arun P**, built by **Bongbetic**.
Voiced turns a few details into a clean, GST-ready PDF invoice. It keeps a running
invoice-number series in a local database, remembers your vendor and bank details, and
exports print-ready PDFs — no account, no network required.
## Features
- **Automatic invoice numbering** — a persistent series (e.g. `AP/2026-001`) that keeps
counting up, with a one-click "start a new series" that resets the counter.
- **Guided first-run setup** — your name, address, contact, PAN, GSTIN, logo, signature and
bank details, pre-filled from your existing invoice and editable at any time.
- **Two line-item modes** — a fixed amount, or a rate × quantity where the unit can be per
second / minute / hour / session / unit (e.g. 10 sessions × ₹2,500).
- **GST support** — toggle taxes on/off and split as **CGST + SGST** (within state) or
**IGST** (inter-state), with live totals and Indian amount-in-words.
- **Signatures** — attach a PNG/JPEG per invoice, or omit it and the invoice notes that it is
computer-generated and needs no signature.
- **PDF export** — a print-ready A4 invoice styled to match the original `format.pdf`.
- **Saved clients**, invoice history with re-export, and light/dark (Carbon g10/g100) themes.
- **Input validation** for PAN, IFSC, GSTIN, email and phone.
## Tech stack
| Layer | Choice |
| --------- | ------------------------------------------------------------------- |
| Shell | [Tauri v2](https://v2.tauri.app/) (Rust core, WebView2 on Windows) |
| UI | React + TypeScript + [IBM Carbon Design System](https://carbondesignsystem.com/) (`@carbon/react`) |
| Fonts | IBM Plex, self-hosted (works offline) |
| Storage | SQLite via `rusqlite` (bundled) |
| PDF | `@react-pdf/renderer` with bundled IBM Plex |
## Prerequisites
- **Node.js 20+** and npm
- **Rust** (stable) via [rustup](https://rustup.rs/)
- Linux dev only: WebKitGTK 4.1, libsoup3, GTK3, librsvg, `pkg-config`, `clang`/`llvm`/`lld`
## Development
```bash
npm install
npm run app:dev # launches the desktop app with hot reload
```
Other scripts:
```bash
npm run build # type-check + build the web frontend
npm run app:build # package the app for the current platform
```
## Building for Windows (cross-compile from Linux/macOS)
Tauri only supports the MSVC target. Cross-compiling uses [`cargo-xwin`](https://github.com/rust-cross/cargo-xwin)
and produces an **NSIS installer** plus a portable `.exe`. A `.msi` still requires a real
Windows host.
```bash
# one-time setup
rustup target add x86_64-pc-windows-msvc
cargo install --locked cargo-xwin
# plus NSIS (makensis), LLVM/LLD and clang on the host
npm run app:build:windows
```
Output lands in `src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/` and the
portable binary in the sibling `release/` folder.
## Where data lives
| What | Location |
| ------------------- | ------------------------------------------------------------- |
| Database | `<app-data>/voiced.db` — on Windows `%APPDATA%\com.bongbetic.voiced\` |
| Logos & signatures | `<app-data>/assets/` |
## Project layout
```
src/ React + Carbon frontend
views/ Onboarding · NewInvoice · InvoiceHistory · SeriesSettings · AppSettings
components/ AppShell · InvoicePreview · ImagePicker
pdf/ InvoiceDocument (react-pdf template)
lib/ api · types · invoice · numberToWords · validators · format · pdf
src-tauri/ Rust backend (Tauri commands, SQLite schema, asset store)
public/brand/ Bongbetic + vendor brand assets
public/fonts/ IBM Plex (woff/woff2)
```
## Ownership
© Bongbetic. All rights reserved. This is a private application.
BIN
View File
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/brand/favicon-32.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>Voiced</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3638
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "voiced",
"private": true,
"version": "0.1.0",
"description": "Voiced — offline invoicing desktop app by Bongbetic",
"type": "module",
"author": "Bongbetic",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri",
"app:dev": "tauri dev",
"app:build": "tauri build",
"app:build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc"
},
"dependencies": {
"@carbon/icons-react": "^11.89.0",
"@carbon/react": "^1.117.0",
"@carbon/styles": "^1.116.0",
"@ibm/plex": "^6.4.1",
"@react-pdf/renderer": "^4.9.0",
"@tauri-apps/api": "^2.12.1",
"@tauri-apps/plugin-dialog": "^2.8.1",
"@tauri-apps/plugin-opener": "^2.7.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-is": "^19.3.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.12.1",
"@types/node": "^26.6.4",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0",
"sass": "^1.105.1",
"typescript": "^5.9.3",
"vite": "^7.3.6"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="383.692000pt" height="497.251424pt" viewBox="0 0 383.692000 497.251424"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.16, written by Peter Selinger 2001-2019
</metadata>
<g transform="translate(2.765143,475.709540) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M805 4456 c-17 -13 -60 -15 -267 -16 l-248 0 -15 -24 c-14 -21 -16
-60 -14 -270 1 -163 6 -251 13 -262 10 -12 52 -16 258 -20 292 -6 293 -6 283
66 -9 62 1 67 120 64 50 -1 117 -2 151 -3 107 -2 96 119 95 -1067 -2 -998 -1
-1045 18 -1150 22 -124 38 -174 77 -241 55 -95 160 -217 212 -247 11 -6 44
-25 74 -43 77 -46 219 -83 317 -83 95 0 243 35 310 73 65 37 185 149 226 212
162 244 128 577 -82 790 -99 100 -229 157 -362 159 -67 1 -75 -1 -92 -24 -17
-23 -54 -172 -66 -266 -11 -84 32 -144 102 -144 38 0 100 -35 130 -74 57 -74
56 -163 -2 -227 -70 -77 -187 -84 -288 -17 -55 37 -87 81 -115 162 -19 56 -20
83 -20 471 0 332 3 415 14 430 12 17 31 18 309 17 265 -2 305 -4 379 -23 176
-44 411 -202 498 -333 14 -21 37 -52 51 -69 41 -51 114 -195 143 -282 91 -280
68 -628 -57 -864 -89 -167 -237 -343 -350 -415 -92 -59 -220 -126 -256 -136
-20 -5 -55 -16 -78 -24 -122 -45 -380 -60 -545 -31 -306 54 -544 214 -721 484
-68 102 -90 147 -121 240 -21 63 -42 144 -47 180 -4 36 -9 394 -9 794 -1 707
-2 729 -21 748 -17 17 -34 19 -212 19 -175 0 -196 -2 -210 -18 -16 -17 -17
-84 -17 -756 0 -697 1 -744 20 -859 12 -67 37 -169 57 -227 72 -212 256 -521
363 -610 24 -19 70 -60 102 -90 120 -110 283 -206 502 -294 193 -78 608 -108
831 -60 39 8 91 19 117 24 61 12 243 85 275 110 14 11 33 20 43 20 9 0 35 13
57 29 22 16 53 36 69 45 58 33 133 93 237 189 86 80 123 125 204 245 206 306
274 537 274 927 0 186 -3 214 -42 375 -39 161 -142 376 -243 508 -39 51 -84
110 -99 130 -45 59 -155 160 -239 219 -42 30 -80 63 -83 74 -5 14 8 36 49 83
71 81 110 153 143 268 24 84 25 101 21 227 -6 158 -20 211 -84 337 -57 110
-116 193 -186 261 -62 60 -233 176 -284 193 -20 7 -48 18 -62 26 -15 8 -34 14
-43 14 -9 0 -36 7 -60 15 -129 46 -152 47 -834 52 -576 4 -652 3 -670 -11z
m1445 -484 c114 -41 154 -65 225 -136 93 -91 117 -143 119 -251 2 -121 -28
-188 -121 -274 -61 -57 -82 -70 -150 -91 -68 -22 -96 -25 -223 -25 -126 0
-155 3 -219 24 -48 16 -90 38 -120 64 -54 46 -116 133 -132 185 -6 20 -15 138
-20 262 -7 197 -6 228 8 249 l15 24 282 -6 c246 -5 288 -8 336 -25z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5133
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "voiced"
version = "0.1.0"
description = "Voiced — offline invoicing desktop app by Bongbetic"
authors = ["Bongbetic"]
edition = "2021"
rust-version = "1.77"
# The `_lib` suffix keeps the library name distinct from the binary name on Windows.
[lib]
name = "voiced_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.40", features = ["bundled"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
thiserror = "2"
base64 = "0.22"
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
panic = "abort"
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capabilities for the Voiced main window.",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"opener:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+116
View File
@@ -0,0 +1,116 @@
use crate::AppState;
use base64::{engine::general_purpose::STANDARD, Engine};
use std::path::{Path, PathBuf};
use tauri::State;
fn safe_kind(kind: &str) -> String {
kind.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.collect::<String>()
.to_lowercase()
}
fn sanitize_name(name: &str) -> String {
name.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
.collect()
}
fn extension_of(path: &str) -> String {
Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_else(|| "png".to_string())
}
fn mime_for(ext: &str) -> &'static str {
match ext {
"jpg" | "jpeg" => "image/jpeg",
"svg" => "image/svg+xml",
"webp" => "image/webp",
"gif" => "image/gif",
_ => "image/png",
}
}
fn assets_dir(state: &State<AppState>) -> Result<PathBuf, String> {
let dir = state.data_dir.join("assets");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
fn write_asset(state: &State<AppState>, kind: &str, ext: &str, bytes: &[u8]) -> Result<String, String> {
let dir = assets_dir(state)?;
let file = format!(
"{}-{}.{}",
safe_kind(kind),
uuid::Uuid::new_v4().simple(),
ext
);
let path = dir.join(file);
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
Ok(path.to_string_lossy().to_string())
}
/// Copy a user-picked file into the app's asset store and return the stored path.
#[tauri::command]
pub fn import_asset(
state: State<AppState>,
source_path: String,
kind: String,
) -> Result<String, String> {
let bytes = std::fs::read(&source_path).map_err(|e| e.to_string())?;
let ext = extension_of(&source_path);
write_asset(&state, &kind, &ext, &bytes)
}
/// Used when the frontend already holds the bytes (base64) instead of a path.
#[tauri::command]
pub fn save_asset_bytes(
state: State<AppState>,
kind: String,
file_name: String,
data_base64: String,
) -> Result<String, String> {
let bytes = STANDARD
.decode(data_base64.as_bytes())
.map_err(|e| e.to_string())?;
let ext = extension_of(&sanitize_name(&file_name));
write_asset(&state, &kind, &ext, &bytes)
}
/// Read a stored asset back as a data URI so it can be embedded in the PDF.
#[tauri::command]
pub fn read_asset_data_uri(path: String) -> Result<String, String> {
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
let ext = extension_of(&path);
Ok(format!(
"data:{};base64,{}",
mime_for(&ext),
STANDARD.encode(bytes)
))
}
#[tauri::command]
pub fn remove_asset(state: State<AppState>, path: String) -> Result<(), String> {
let dir = assets_dir(&state)?;
let target = PathBuf::from(&path);
// Only allow deleting files inside the managed assets directory.
if target.starts_with(&dir) && target.exists() {
std::fs::remove_file(&target).map_err(|e| e.to_string())?;
}
Ok(())
}
/// Write a base64 payload (e.g. a generated PDF) to a user-chosen path.
#[tauri::command]
pub fn save_binary_file(path: String, data_base64: String) -> Result<(), String> {
let bytes = STANDARD
.decode(data_base64.as_bytes())
.map_err(|e| e.to_string())?;
if let Some(parent) = Path::new(&path).parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&path, bytes).map_err(|e| e.to_string())
}
+78
View File
@@ -0,0 +1,78 @@
use crate::models::Client;
use crate::AppState;
use rusqlite::{params, Row};
use tauri::State;
fn map_client(row: &Row) -> rusqlite::Result<Client> {
Ok(Client {
id: Some(row.get(0)?),
name: row.get(1)?,
address: row.get(2)?,
gstin: row.get(3)?,
state_code: row.get(4)?,
po_number: row.get(5)?,
created_at: row.get(6)?,
})
}
#[tauri::command]
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, name, address, gstin, state_code, po_number, created_at
FROM clients ORDER BY name COLLATE NOCASE ASC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], map_client)
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
#[tauri::command]
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let id = match client.id {
Some(id) => {
conn.execute(
"UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5 WHERE id=?6",
params![client.name, client.address, client.gstin, client.state_code, client.po_number, id],
)
.map_err(|e| e.to_string())?;
id
}
None => {
conn.execute(
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
client.name,
client.address,
client.gstin,
client.state_code,
client.po_number,
chrono::Utc::now().to_rfc3339()
],
)
.map_err(|e| e.to_string())?;
conn.last_insert_rowid()
}
};
conn.query_row(
"SELECT id, name, address, gstin, state_code, po_number, created_at FROM clients WHERE id = ?1",
params![id],
map_client,
)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM clients WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
+255
View File
@@ -0,0 +1,255 @@
use crate::db::format_number;
use crate::models::{Invoice, InvoiceInput, InvoiceItem, InvoiceSummary};
use crate::AppState;
use rusqlite::{params, Connection, OptionalExtension, Row};
use tauri::State;
const INVOICE_COLS: &str = "id, number, series_id, invoice_date, due_date, client_id, client_name,
client_address, client_gstin, po_number, place_of_supply_state_code, subtotal, discount,
tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at";
fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
Ok(Invoice {
id: row.get(0)?,
number: row.get(1)?,
series_id: row.get(2)?,
invoice_date: row.get(3)?,
due_date: row.get(4)?,
client_id: row.get(5)?,
client_name: row.get(6)?,
client_address: row.get(7)?,
client_gstin: row.get(8)?,
po_number: row.get(9)?,
place_of_supply_state_code: row.get(10)?,
subtotal: row.get(11)?,
discount: row.get(12)?,
tax_type: row.get(13)?,
tax_rate: row.get(14)?,
cgst_amount: row.get(15)?,
sgst_amount: row.get(16)?,
igst_amount: row.get(17)?,
total: row.get(18)?,
amount_in_words: row.get(19)?,
bank_account_id: row.get(20)?,
bank_snapshot: row.get(21)?,
signature_path: row.get(22)?,
notes: row.get(23)?,
status: row.get(24)?,
created_at: row.get(25)?,
updated_at: row.get(26)?,
items: Vec::new(),
})
}
fn fetch_items(conn: &Connection, invoice_id: i64) -> rusqlite::Result<Vec<InvoiceItem>> {
let mut stmt = conn.prepare(
"SELECT id, description, mode, rate, unit, quantity, amount, sort_order
FROM invoice_items WHERE invoice_id = ?1 ORDER BY sort_order ASC, id ASC",
)?;
let rows = stmt.query_map(params![invoice_id], |row| {
Ok(InvoiceItem {
id: Some(row.get(0)?),
description: row.get(1)?,
mode: row.get(2)?,
rate: row.get(3)?,
unit: row.get(4)?,
quantity: row.get(5)?,
amount: row.get(6)?,
sort_order: row.get(7)?,
})
})?;
rows.collect()
}
fn fetch_invoice(conn: &Connection, id: i64) -> rusqlite::Result<Invoice> {
let mut invoice = conn.query_row(
&format!("SELECT {} FROM invoices WHERE id = ?1", INVOICE_COLS),
params![id],
map_invoice,
)?;
invoice.items = fetch_items(conn, id)?;
Ok(invoice)
}
#[tauri::command]
pub fn peek_next_invoice_number(state: State<AppState>) -> Result<String, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let series: Option<(String, i64, i64)> = conn
.query_row(
"SELECT prefix, padding, next_number FROM invoice_series
WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()
.map_err(|e| e.to_string())?;
match series {
Some((prefix, padding, next)) => Ok(format_number(&prefix, padding, next)),
None => Err("No active invoice series. Create one under Series.".into()),
}
}
#[tauri::command]
pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Invoice, String> {
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
let tx = conn.transaction().map_err(|e| e.to_string())?;
let series = tx
.query_row(
"SELECT id, prefix, padding, next_number FROM invoice_series
WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
[],
|r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, i64>(3)?,
))
},
)
.optional()
.map_err(|e| e.to_string())?
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?;
let (series_id, prefix, padding, next_number) = series;
let number = format_number(&prefix, padding, next_number);
// Persist the client first when the user asked to save a new one.
let client_id = match input.client_id {
Some(id) => Some(id),
None if input.save_client && !input.client_name.trim().is_empty() => {
tx.execute(
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
input.client_name,
input.client_address,
input.client_gstin,
input.place_of_supply_state_code,
input.po_number,
chrono::Utc::now().to_rfc3339()
],
)
.map_err(|e| e.to_string())?;
Some(tx.last_insert_rowid())
}
None => None,
};
let now = chrono::Utc::now().to_rfc3339();
tx.execute(
r#"INSERT INTO invoices
(number, series_id, invoice_date, due_date, client_id, client_name, client_address,
client_gstin, po_number, place_of_supply_state_code, subtotal, discount, tax_type,
tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
?18, ?19, ?20, ?21, ?22, ?23, 'issued', ?24, ?24)"#,
params![
number,
series_id,
input.invoice_date,
input.due_date,
client_id,
input.client_name,
input.client_address,
input.client_gstin,
input.po_number,
input.place_of_supply_state_code,
input.subtotal,
input.discount,
input.tax_type,
input.tax_rate,
input.cgst_amount,
input.sgst_amount,
input.igst_amount,
input.total,
input.amount_in_words,
input.bank_account_id,
input.bank_snapshot,
input.signature_path,
input.notes,
now,
],
)
.map_err(|e| e.to_string())?;
let invoice_id = tx.last_insert_rowid();
{
let mut stmt = tx
.prepare(
"INSERT INTO invoice_items
(invoice_id, description, mode, rate, unit, quantity, amount, sort_order)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)
.map_err(|e| e.to_string())?;
for (i, item) in input.items.iter().enumerate() {
stmt.execute(params![
invoice_id,
item.description,
item.mode,
item.rate,
item.unit,
item.quantity,
item.amount,
i as i64,
])
.map_err(|e| e.to_string())?;
}
}
// Advance the serial for the next invoice in this series.
tx.execute(
"UPDATE invoice_series SET next_number = next_number + 1 WHERE id = ?1",
params![series_id],
)
.map_err(|e| e.to_string())?;
tx.commit().map_err(|e| e.to_string())?;
fetch_invoice(&conn, invoice_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, number, invoice_date, due_date, client_name, total, status, created_at
FROM invoices ORDER BY id DESC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok(InvoiceSummary {
id: row.get(0)?,
number: row.get(1)?,
invoice_date: row.get(2)?,
due_date: row.get(3)?,
client_name: row.get(4)?,
total: row.get(5)?,
status: row.get(6)?,
created_at: row.get(7)?,
})
})
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
#[tauri::command]
pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
fetch_invoice(&conn, id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_invoice(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM invoices WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
+5
View File
@@ -0,0 +1,5 @@
pub mod assets;
pub mod clients;
pub mod invoice;
pub mod series;
pub mod settings;
+83
View File
@@ -0,0 +1,83 @@
use crate::db::format_number;
use crate::models::InvoiceSeries;
use crate::AppState;
use rusqlite::{params, Row};
use tauri::State;
fn map_series(row: &Row) -> rusqlite::Result<InvoiceSeries> {
let prefix: String = row.get(1)?;
let padding: i64 = row.get(2)?;
let next_number: i64 = row.get(3)?;
Ok(InvoiceSeries {
id: row.get(0)?,
next_invoice_number: format_number(&prefix, padding, next_number),
prefix,
padding,
next_number,
is_active: row.get::<_, i64>(4)? != 0,
created_at: row.get(5)?,
})
}
const SERIES_SELECT: &str =
"SELECT id, prefix, padding, next_number, is_active, created_at FROM invoice_series";
#[tauri::command]
pub fn get_active_series(state: State<AppState>) -> Result<Option<InvoiceSeries>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(&format!(
"{} WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
SERIES_SELECT
))
.map_err(|e| e.to_string())?;
let mut rows = stmt.query_map([], map_series).map_err(|e| e.to_string())?;
match rows.next() {
Some(r) => Ok(Some(r.map_err(|e| e.to_string())?)),
None => Ok(None),
}
}
#[tauri::command]
pub fn list_series(state: State<AppState>) -> Result<Vec<InvoiceSeries>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(&format!("{} ORDER BY id DESC", SERIES_SELECT))
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], map_series)
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
/// Deactivates the current series and starts a fresh one, resetting the counter to 1.
#[tauri::command]
pub fn start_new_series(
state: State<AppState>,
prefix: String,
padding: i64,
) -> Result<InvoiceSeries, String> {
let prefix = prefix.trim().to_string();
if prefix.is_empty() {
return Err("Series prefix cannot be empty".into());
}
let padding = padding.clamp(1, 8);
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute("UPDATE invoice_series SET is_active = 0", [])
.map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at)
VALUES (?1, ?2, 1, 1, ?3)",
params![prefix, padding, chrono::Utc::now().to_rfc3339()],
)
.map_err(|e| e.to_string())?;
let id = conn.last_insert_rowid();
conn.query_row(
&format!("{} WHERE id = ?1", SERIES_SELECT),
params![id],
map_series,
)
.map_err(|e| e.to_string())
}
+168
View File
@@ -0,0 +1,168 @@
use crate::models::{BankAccount, Settings};
use crate::AppState;
use rusqlite::{params, Row};
use tauri::State;
const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate,
default_tax_type, payment_terms_days, currency, onboarded, theme";
fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
Ok(Settings {
vendor_name: row.get(0)?,
vendor_address: row.get(1)?,
vendor_email: row.get(2)?,
vendor_phone: row.get(3)?,
vendor_pan: row.get(4)?,
vendor_gstin: row.get(5)?,
vendor_state_code: row.get(6)?,
logo_path: row.get(7)?,
signature_path: row.get(8)?,
default_bank_id: row.get(9)?,
default_tax_rate: row.get(10)?,
default_tax_type: row.get(11)?,
payment_terms_days: row.get(12)?,
currency: row.get(13)?,
onboarded: row.get::<_, i64>(14)? != 0,
theme: row.get(15)?,
})
}
#[tauri::command]
pub fn get_settings(state: State<AppState>) -> Result<Settings, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.query_row(
&format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS),
[],
map_settings,
)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Settings, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute(
r#"UPDATE app_settings SET
vendor_name = ?1, vendor_address = ?2, vendor_email = ?3, vendor_phone = ?4,
vendor_pan = ?5, vendor_gstin = ?6, vendor_state_code = ?7, logo_path = ?8,
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
onboarded = ?15, theme = ?16
WHERE id = 1"#,
params![
settings.vendor_name,
settings.vendor_address,
settings.vendor_email,
settings.vendor_phone,
settings.vendor_pan,
settings.vendor_gstin,
settings.vendor_state_code,
settings.logo_path,
settings.signature_path,
settings.default_bank_id,
settings.default_tax_rate,
settings.default_tax_type,
settings.payment_terms_days,
settings.currency,
if settings.onboarded { 1 } else { 0 },
settings.theme,
],
)
.map_err(|e| e.to_string())?;
drop(conn);
get_settings(state)
}
fn map_bank(row: &Row) -> rusqlite::Result<BankAccount> {
Ok(BankAccount {
id: Some(row.get(0)?),
label: row.get(1)?,
bank_name: row.get(2)?,
account_name: row.get(3)?,
account_no: row.get(4)?,
branch: row.get(5)?,
ifsc: row.get(6)?,
is_default: row.get::<_, i64>(7)? != 0,
})
}
#[tauri::command]
pub fn list_banks(state: State<AppState>) -> Result<Vec<BankAccount>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, label, bank_name, account_name, account_no, branch, ifsc, is_default
FROM bank_accounts ORDER BY is_default DESC, id ASC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], map_bank)
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
#[tauri::command]
pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccount, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let is_default = if bank.is_default { 1 } else { 0 };
let id = match bank.id {
Some(id) => {
conn.execute(
r#"UPDATE bank_accounts SET label=?1, bank_name=?2, account_name=?3,
account_no=?4, branch=?5, ifsc=?6, is_default=?7 WHERE id=?8"#,
params![
bank.label, bank.bank_name, bank.account_name, bank.account_no,
bank.branch, bank.ifsc, is_default, id
],
)
.map_err(|e| e.to_string())?;
id
}
None => {
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, ?7)"#,
params![
bank.label, bank.bank_name, bank.account_name, bank.account_no,
bank.branch, bank.ifsc, is_default
],
)
.map_err(|e| e.to_string())?;
conn.last_insert_rowid()
}
};
if is_default == 1 {
conn.execute(
"UPDATE bank_accounts SET is_default = 0 WHERE id <> ?1",
params![id],
)
.map_err(|e| e.to_string())?;
conn.execute(
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
params![id],
)
.map_err(|e| e.to_string())?;
}
conn.query_row(
"SELECT id, label, bank_name, account_name, account_no, branch, ifsc, is_default
FROM bank_accounts WHERE id = ?1",
params![id],
map_bank,
)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_bank(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM bank_accounts WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
+175
View File
@@ -0,0 +1,175 @@
use rusqlite::{params, Connection};
use std::path::Path;
/// The full schema. Every statement is idempotent so it doubles as the migration step.
const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
vendor_name TEXT NOT NULL DEFAULT '',
vendor_address TEXT NOT NULL DEFAULT '',
vendor_email TEXT NOT NULL DEFAULT '',
vendor_phone TEXT NOT NULL DEFAULT '',
vendor_pan TEXT NOT NULL DEFAULT '',
vendor_gstin TEXT NOT NULL DEFAULT '',
vendor_state_code TEXT NOT NULL DEFAULT '29',
logo_path TEXT,
signature_path TEXT,
default_bank_id INTEGER,
default_tax_rate REAL NOT NULL DEFAULT 18,
default_tax_type TEXT NOT NULL DEFAULT 'cgst_sgst',
payment_terms_days INTEGER NOT NULL DEFAULT 30,
currency TEXT NOT NULL DEFAULT 'INR',
onboarded INTEGER NOT NULL DEFAULT 0,
theme TEXT NOT NULL DEFAULT 'g10'
);
CREATE TABLE IF NOT EXISTS bank_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL DEFAULT '',
bank_name TEXT NOT NULL DEFAULT '',
account_name TEXT NOT NULL DEFAULT '',
account_no TEXT NOT NULL DEFAULT '',
branch TEXT NOT NULL DEFAULT '',
ifsc TEXT NOT NULL DEFAULT '',
is_default INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS invoice_series (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prefix TEXT NOT NULL,
padding INTEGER NOT NULL DEFAULT 3,
next_number INTEGER NOT NULL DEFAULT 1,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
address TEXT NOT NULL DEFAULT '',
gstin TEXT NOT NULL DEFAULT '',
state_code TEXT NOT NULL DEFAULT '',
po_number TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
number TEXT NOT NULL UNIQUE,
series_id INTEGER,
invoice_date TEXT NOT NULL,
due_date TEXT NOT NULL DEFAULT '',
client_id INTEGER,
client_name TEXT NOT NULL DEFAULT '',
client_address TEXT NOT NULL DEFAULT '',
client_gstin TEXT NOT NULL DEFAULT '',
po_number TEXT NOT NULL DEFAULT '',
place_of_supply_state_code TEXT NOT NULL DEFAULT '',
subtotal REAL NOT NULL DEFAULT 0,
discount REAL NOT NULL DEFAULT 0,
tax_type TEXT NOT NULL DEFAULT 'none',
tax_rate REAL NOT NULL DEFAULT 0,
cgst_amount REAL NOT NULL DEFAULT 0,
sgst_amount REAL NOT NULL DEFAULT 0,
igst_amount REAL NOT NULL DEFAULT 0,
total REAL NOT NULL DEFAULT 0,
amount_in_words TEXT NOT NULL DEFAULT '',
bank_account_id INTEGER,
bank_snapshot TEXT NOT NULL DEFAULT '',
signature_path TEXT,
notes TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'issued',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS invoice_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
description TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'fixed',
rate REAL NOT NULL DEFAULT 0,
unit TEXT NOT NULL DEFAULT 'unit',
quantity REAL NOT NULL DEFAULT 1,
amount REAL NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id);
CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
"#;
pub fn open(path: &Path) -> rusqlite::Result<Connection> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.execute_batch(SCHEMA)?;
seed(&conn)?;
Ok(conn)
}
/// Insert the first-run defaults the very first time the app is opened.
/// Vendor details are pre-filled from the owner's existing invoice.
fn seed(conn: &Connection) -> rusqlite::Result<()> {
let settings_count: i64 =
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?;
if settings_count == 0 {
conn.execute(
r#"INSERT INTO app_settings
(id, vendor_name, vendor_address, vendor_email, vendor_phone,
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
default_tax_type, payment_terms_days, currency, onboarded, theme)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'cgst_sgst', 30, 'INR', 0, 'g10')"#,
params![
"Arun P",
"13-B type House, Robertson Road, Frazer Town, Bangalore - 560005",
"arunakira113@gmail.com",
"9738284459",
"CPOPP7388K",
"",
],
)?;
}
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",
"Arun P",
"54058708786",
"017060",
"SBIN0017060",
],
)?;
let bank_id = conn.last_insert_rowid();
conn.execute(
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
params![bank_id],
)?;
}
let series_count: i64 =
conn.query_row("SELECT COUNT(*) FROM invoice_series", [], |r| r.get(0))?;
if series_count == 0 {
let now = chrono::Utc::now().to_rfc3339();
let year = chrono::Local::now().format("%Y").to_string();
conn.execute(
r#"INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at)
VALUES (?1, 3, 1, 1, ?2)"#,
params![format!("AP/{}-", year), now],
)?;
}
Ok(())
}
/// Format an invoice number from a series definition, e.g. "AP/2026-" + 1 -> "AP/2026-001".
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize)
}
+59
View File
@@ -0,0 +1,59 @@
mod commands;
mod db;
mod models;
use rusqlite::Connection;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::Manager;
pub struct AppState {
pub db: Mutex<Connection>,
pub data_dir: PathBuf,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
let data_dir = app
.path()
.app_data_dir()
.expect("could not resolve the application data directory");
std::fs::create_dir_all(&data_dir).expect("could not create the application data directory");
let conn = db::open(&data_dir.join("voiced.db")).expect("could not open the database");
app.manage(AppState {
db: Mutex::new(conn),
data_dir,
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::settings::get_settings,
commands::settings::save_settings,
commands::settings::list_banks,
commands::settings::save_bank,
commands::settings::delete_bank,
commands::series::get_active_series,
commands::series::list_series,
commands::series::start_new_series,
commands::clients::list_clients,
commands::clients::save_client,
commands::clients::delete_client,
commands::invoice::peek_next_invoice_number,
commands::invoice::create_invoice,
commands::invoice::list_invoices,
commands::invoice::get_invoice,
commands::invoice::delete_invoice,
commands::assets::import_asset,
commands::assets::save_asset_bytes,
commands::assets::read_asset_data_uri,
commands::assets::remove_asset,
commands::assets::save_binary_file,
])
.run(tauri::generate_context!())
.expect("error while running the Voiced application");
}

Some files were not shown because too many files have changed in this diff Show More