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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user