Initial commit: Voiced invoice desktop app

Offline-first invoicing app for Test Vendor, 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 875a12b2e6
121 changed files with 13660 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
import {
Content,
Header,
HeaderGlobalAction,
HeaderGlobalBar,
HeaderMenuItem,
HeaderName,
HeaderNavigation,
SkipToContent,
} from "@carbon/react";
import { Asleep, Light } from "@carbon/icons-react";
import type { ReactNode } from "react";
export type AppView = "new" | "invoices" | "series" | "settings";
const NAV: { id: AppView; label: string }[] = [
{ id: "new", label: "New invoice" },
{ id: "invoices", label: "Invoices" },
{ id: "series", label: "Series" },
{ id: "settings", label: "Settings" },
];
interface AppShellProps {
view: AppView;
onNavigate: (view: AppView) => void;
theme: string;
onToggleTheme: () => void;
children: ReactNode;
}
export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: AppShellProps) {
const wordmark =
theme === "g100" ? "/brand/bongbetic-wordmark-light.png" : "/brand/bongbetic-wordmark-dark.png";
return (
<>
<Header aria-label="Voiced">
<SkipToContent />
<HeaderName href="#" prefix="">
<span className="voiced-brand">
<img className="voiced-brand__logo" src={wordmark} alt="Bongbetic" />
<span className="voiced-brand__divider" aria-hidden="true" />
<span className="voiced-brand__product">Voiced</span>
</span>
</HeaderName>
<HeaderNavigation aria-label="Voiced navigation">
{NAV.map((item) => (
<HeaderMenuItem
key={item.id}
isCurrentPage={view === item.id}
onClick={(event) => {
event.preventDefault();
onNavigate(item.id);
}}
>
{item.label}
</HeaderMenuItem>
))}
</HeaderNavigation>
<HeaderGlobalBar>
<HeaderGlobalAction aria-label="Toggle colour theme" onClick={onToggleTheme}>
{theme === "g100" ? <Light size={20} /> : <Asleep size={20} />}
</HeaderGlobalAction>
</HeaderGlobalBar>
</Header>
<Content id="main-content">{children}</Content>
</>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { Button, InlineLoading } from "@carbon/react";
import { TrashCan, Upload } from "@carbon/icons-react";
import { open } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
interface ImagePickerProps {
kind: string;
label: string;
value: string | null;
onChange: (path: string | null) => void;
disabled?: boolean;
}
/** Pick an image, copy it into the app's asset store and show a preview. */
export function ImagePicker({ kind, label, value, onChange, disabled }: ImagePickerProps) {
const [preview, setPreview] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
if (!value) {
setPreview(null);
return;
}
api
.readAssetDataUri(value)
.then((uri) => active && setPreview(uri))
.catch(() => active && setPreview(null));
return () => {
active = false;
};
}, [value]);
const pick = async () => {
setError(null);
try {
const selected = await open({
multiple: false,
directory: false,
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp"] }],
});
if (typeof selected !== "string") return;
setBusy(true);
const stored = await api.importAsset(selected, kind);
onChange(stored);
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const clear = () => {
setError(null);
// Detach only — the file may be shared with other invoices/settings.
onChange(null);
};
return (
<div className="voiced-stack">
<div className="voiced-muted">{label}</div>
{preview ? (
<img
src={preview}
alt={label}
style={{ maxHeight: 64, maxWidth: 220, objectFit: "contain", background: "#fff", padding: 4 }}
/>
) : (
<div className="voiced-muted">{busy ? <InlineLoading description="Importing" /> : "No file selected"}</div>
)}
<div className="voiced-header-action">
<Button
kind="tertiary"
size="sm"
renderIcon={Upload}
onClick={pick}
disabled={disabled || busy}
>
Choose image
</Button>
{value ? (
<Button kind="ghost" size="sm" renderIcon={TrashCan} onClick={clear} disabled={disabled}>
Remove
</Button>
) : null}
</div>
{error ? <span className="voiced-muted">{error}</span> : null}
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { UNIT_LABELS } from "../lib/types";
import { computeLineAmount } from "../lib/invoice";
import { formatAmount, formatDate } from "../lib/format";
import type { InvoicePdfProps } from "../pdf/InvoiceDocument";
const money = (n: number) => `₹${formatAmount(n)}`;
/** On-screen preview that mirrors the exported PDF layout. */
export function InvoicePreview(props: InvoicePdfProps) {
const {
vendor,
bank,
logoDataUri,
signatureDataUri,
number,
invoiceDate,
dueDate,
clientName,
clientAddress,
clientGstin,
poNumber,
placeOfSupplyStateCode,
items,
subtotal,
discount,
taxType,
taxRate,
cgst,
sgst,
igst,
total,
amountInWords,
notes,
} = props;
const isTaxInvoice = taxType !== "none";
const contact = [vendor.vendorEmail, vendor.vendorPhone].filter(Boolean).join(" · ");
return (
<div className="inv-preview-wrap">
<div className="inv-paper">
<div className="inv-top">
<div className="inv-vendor">
{logoDataUri ? <img className="inv-vendor__logo" src={logoDataUri} alt="" /> : null}
<div>
<h2 className="inv-vendor__name">{vendor.vendorName || "Vendor"}</h2>
<p className="inv-vendor__line">{vendor.vendorAddress}</p>
{contact ? <p className="inv-vendor__line">{contact}</p> : null}
{vendor.vendorPan ? <p className="inv-vendor__line">PAN: {vendor.vendorPan}</p> : null}
<p className="inv-vendor__line">GSTIN: {vendor.vendorGstin || "NA"}</p>
</div>
</div>
<div className="inv-meta">
<h3 className="inv-meta__title">{isTaxInvoice ? "Tax Invoice" : "Invoice"}</h3>
<div className="inv-meta__row">
<span className="inv-meta__label">Invoice No.</span>
<span className="inv-meta__value">{number}</span>
</div>
<div className="inv-meta__row">
<span className="inv-meta__label">Date</span>
<span className="inv-meta__value">{formatDate(invoiceDate)}</span>
</div>
{dueDate ? (
<div className="inv-meta__row">
<span className="inv-meta__label">Due Date</span>
<span className="inv-meta__value">{formatDate(dueDate)}</span>
</div>
) : null}
</div>
</div>
<div className="inv-split">
<div className="inv-block" style={{ maxWidth: "60%" }}>
<p className="inv-block__label">Billed To</p>
<div className="inv-block__strong">{clientName || "—"}</div>
{clientAddress ? <div style={{ whiteSpace: "pre-line" }}>{clientAddress}</div> : null}
{clientGstin ? <div>GSTIN: {clientGstin}</div> : null}
{placeOfSupplyStateCode ? <div>Place of Supply: {placeOfSupplyStateCode}</div> : null}
</div>
{poNumber ? (
<div className="inv-block">
<p className="inv-block__label">PO Number</p>
<div className="inv-block__strong">{poNumber}</div>
</div>
) : null}
</div>
<table className="inv-table">
<thead>
<tr>
<th>Description</th>
<th className="num">Unit Price</th>
<th className="num">Qty</th>
<th className="num">Total</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={item.id ?? index}>
<td>{item.description || "—"}</td>
<td className="num">
{item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "—"}
</td>
<td className="num">{item.mode === "rate" ? item.quantity : 1}</td>
<td className="num">{money(computeLineAmount(item))}</td>
</tr>
))}
</tbody>
</table>
<div className="inv-totals">
<div className="inv-totals__table">
<div className="inv-totals__row">
<span>Subtotal</span>
<span>{money(subtotal)}</span>
</div>
{discount > 0 ? (
<div className="inv-totals__row">
<span>Discount</span>
<span>- {money(discount)}</span>
</div>
) : null}
{taxType === "cgst_sgst" ? (
<>
<div className="inv-totals__row">
<span>CGST ({(taxRate / 2).toFixed(2)}%)</span>
<span>{money(cgst)}</span>
</div>
<div className="inv-totals__row">
<span>SGST ({(taxRate / 2).toFixed(2)}%)</span>
<span>{money(sgst)}</span>
</div>
</>
) : null}
{taxType === "igst" ? (
<div className="inv-totals__row">
<span>IGST ({taxRate.toFixed(2)}%)</span>
<span>{money(igst)}</span>
</div>
) : null}
<div className="inv-totals__row inv-totals__row--grand">
<span>Total</span>
<span>{money(total)}</span>
</div>
</div>
</div>
<p className="inv-words">Amount in words: {amountInWords}</p>
{notes ? (
<div style={{ marginTop: 16 }}>
<p className="inv-block__label">Notes</p>
<div style={{ whiteSpace: "pre-line" }}>{notes}</div>
</div>
) : null}
<div className="inv-bottom">
<div className="inv-block" style={{ maxWidth: "55%" }}>
{bank ? (
<>
<p className="inv-block__label">Pay To</p>
<div className="inv-block__strong">{bank.accountName || vendor.vendorName}</div>
<div>{bank.bankName}</div>
{bank.accountNo ? <div>Account No.: {bank.accountNo}</div> : null}
{bank.branch ? <div>Branch: {bank.branch}</div> : null}
{bank.ifsc ? <div>IFSC: {bank.ifsc}</div> : null}
</>
) : null}
</div>
<div className="inv-signature">
{signatureDataUri ? (
<>
<img className="inv-signature__img" src={signatureDataUri} alt="Signature" />
<div className="inv-signature__name">{vendor.vendorName}</div>
</>
) : (
<>
<div className="inv-signature__name">{vendor.vendorName}</div>
<p className="inv-fineprint">Computer-generated invoice; no signature is required.</p>
</>
)}
</div>
</div>
<div className="inv-footer">
<span>{vendor.vendorName}</span>
<span>Generated with Voiced — a Bongbetic product</span>
</div>
</div>
</div>
);
}