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:
@@ -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())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod assets;
|
||||
pub mod clients;
|
||||
pub mod invoice;
|
||||
pub mod series;
|
||||
pub mod settings;
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user