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
+98
View File
@@ -0,0 +1,98 @@
import { useCallback, useEffect, useState } from "react";
import { InlineNotification, Loading, Theme } from "@carbon/react";
import { api } from "./lib/api";
import type { Settings } from "./lib/types";
import { AppShell, type AppView } from "./components/AppShell";
import Onboarding from "./views/Onboarding";
import NewInvoice from "./views/NewInvoice";
import InvoiceHistory from "./views/InvoiceHistory";
import SeriesSettings from "./views/SeriesSettings";
import AppSettings from "./views/AppSettings";
export default function App() {
const [settings, setSettings] = useState<Settings | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [view, setView] = useState<AppView>("new");
const refreshSettings = useCallback(async () => {
try {
const next = await api.getSettings();
setSettings(next);
setError(null);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refreshSettings();
}, [refreshSettings]);
if (loading) {
return (
<Theme theme="g10">
<div className="voiced-empty">
<Loading description="Loading Voiced" withOverlay={false} small />
</div>
</Theme>
);
}
if (error) {
return (
<Theme theme="g10">
<div className="voiced-page">
<InlineNotification
kind="error"
lowContrast
title="Could not start Voiced"
subtitle={error}
hideCloseButton
/>
</div>
</Theme>
);
}
if (!settings) return null;
const theme = settings.theme === "g100" ? "g100" : "g10";
if (!settings.onboarded) {
return (
<Theme theme={theme}>
<Onboarding
initial={settings}
onDone={(next) => {
setSettings(next);
setView("new");
}}
/>
</Theme>
);
}
const toggleTheme = async () => {
const next: Settings = { ...settings, theme: settings.theme === "g100" ? "g10" : "g100" };
setSettings(next);
try {
await api.saveSettings(next);
} catch (e) {
setError(String(e));
}
};
return (
<Theme theme={theme}>
<AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}>
{view === "new" && <NewInvoice settings={settings} onSettingsChange={refreshSettings} />}
{view === "invoices" && <InvoiceHistory settings={settings} />}
{view === "series" && <SeriesSettings />}
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
</AppShell>
</Theme>
);
}
+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>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { invoke } from "@tauri-apps/api/core";
import type {
BankAccount,
Client,
Invoice,
InvoiceInput,
InvoiceSeries,
InvoiceSummary,
Settings,
} from "./types";
export const api = {
getSettings: () => invoke<Settings>("get_settings"),
saveSettings: (settings: Settings) => invoke<Settings>("save_settings", { settings }),
listBanks: () => invoke<BankAccount[]>("list_banks"),
saveBank: (bank: BankAccount) => invoke<BankAccount>("save_bank", { bank }),
deleteBank: (id: number) => invoke<void>("delete_bank", { id }),
getActiveSeries: () => invoke<InvoiceSeries | null>("get_active_series"),
listSeries: () => invoke<InvoiceSeries[]>("list_series"),
startNewSeries: (prefix: string, padding: number) =>
invoke<InvoiceSeries>("start_new_series", { prefix, padding }),
listClients: () => invoke<Client[]>("list_clients"),
saveClient: (client: Client) => invoke<Client>("save_client", { client }),
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
createInvoice: (input: InvoiceInput) => invoke<Invoice>("create_invoice", { input }),
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
deleteInvoice: (id: number) => invoke<void>("delete_invoice", { id }),
importAsset: (sourcePath: string, kind: string) =>
invoke<string>("import_asset", { sourcePath, kind }),
saveAssetBytes: (kind: string, fileName: string, dataBase64: string) =>
invoke<string>("save_asset_bytes", { kind, fileName, dataBase64 }),
readAssetDataUri: (path: string) => invoke<string>("read_asset_data_uri", { path }),
removeAsset: (path: string) => invoke<void>("remove_asset", { path }),
saveBinaryFile: (path: string, dataBase64: string) =>
invoke<void>("save_binary_file", { path, dataBase64 }),
};
+42
View File
@@ -0,0 +1,42 @@
const inr = new Intl.NumberFormat("en-IN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
/** Indian-grouped number, e.g. 25000 -> "25,000.00" */
export function formatAmount(amount: number): string {
const value = Number.isFinite(amount) ? amount : 0;
return inr.format(value);
}
/** Currency string with the rupee symbol, e.g. "₹25,000.00" */
export function formatCurrency(amount: number, symbol = "₹"): string {
return `${symbol}${formatAmount(amount)}`;
}
/** "2024-06-13" -> "13 Jun 2024" (falls back to the raw value if unparseable). */
export function formatDate(iso: string): string {
if (!iso) return "";
const date = new Date(`${iso}T00:00:00`);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
}
/** Today as an ISO date (YYYY-MM-DD) in local time. */
export function todayIso(): string {
const now = new Date();
const offset = now.getTimezoneOffset();
return new Date(now.getTime() - offset * 60_000).toISOString().slice(0, 10);
}
/** ISO date `days` from `iso` (defaults to today). */
export function addDays(iso: string, days: number): string {
const base = iso ? new Date(`${iso}T00:00:00`) : new Date();
base.setDate(base.getDate() + (Number.isFinite(days) ? days : 0));
const offset = base.getTimezoneOffset();
return new Date(base.getTime() - offset * 60_000).toISOString().slice(0, 10);
}
export function round2(n: number): number {
return Math.round((Number.isFinite(n) ? n : 0) * 100) / 100;
}
+54
View File
@@ -0,0 +1,54 @@
import { round2 } from "./format";
import type { InvoiceItem, TaxType } from "./types";
/** Amount for a single line: a fixed total, or rate × quantity. */
export function computeLineAmount(item: InvoiceItem): number {
if (item.mode === "rate") {
return round2((item.rate || 0) * (item.quantity || 0));
}
return round2(item.amount || 0);
}
export interface Totals {
subtotal: number;
discount: number;
taxable: number;
cgst: number;
sgst: number;
igst: number;
taxTotal: number;
total: number;
}
export function computeTotals(
items: InvoiceItem[],
discount: number,
taxType: TaxType,
taxRate: number,
): Totals {
const subtotal = round2(items.reduce((sum, item) => sum + computeLineAmount(item), 0));
const discountValue = round2(Math.min(Math.max(discount || 0, 0), subtotal));
const taxable = round2(subtotal - discountValue);
let cgst = 0;
let sgst = 0;
let igst = 0;
if (taxType === "cgst_sgst") {
cgst = round2((taxable * (taxRate / 2)) / 100);
sgst = round2((taxable * (taxRate / 2)) / 100);
} else if (taxType === "igst") {
igst = round2((taxable * taxRate) / 100);
}
const taxTotal = round2(cgst + sgst + igst);
return {
subtotal,
discount: discountValue,
taxable,
cgst,
sgst,
igst,
taxTotal,
total: round2(taxable + taxTotal),
};
}
+75
View File
@@ -0,0 +1,75 @@
const ONES = [
"",
"One",
"Two",
"Three",
"Four",
"Five",
"Six",
"Seven",
"Eight",
"Nine",
"Ten",
"Eleven",
"Twelve",
"Thirteen",
"Fourteen",
"Fifteen",
"Sixteen",
"Seventeen",
"Eighteen",
"Nineteen",
];
const TENS = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"];
function twoDigits(n: number): string {
if (n < 20) return ONES[n];
const tens = Math.floor(n / 10);
const ones = n % 10;
return TENS[tens] + (ones ? " " + ONES[ones] : "");
}
function threeDigits(n: number): string {
const hundreds = Math.floor(n / 100);
const rest = n % 100;
let out = "";
if (hundreds > 0) out += ONES[hundreds] + " Hundred";
if (rest > 0) out += (out ? " " : "") + twoDigits(rest);
return out;
}
/** Whole-number to words using the Indian numbering system (thousand, lakh, crore). */
function wholeToWords(n: number): string {
if (n === 0) return "Zero";
const parts: string[] = [];
const crore = Math.floor(n / 10000000);
let rest = n % 10000000;
if (crore > 0) parts.push(`${wholeToWords(crore)} Crore`);
const lakh = Math.floor(rest / 100000);
rest %= 100000;
if (lakh > 0) parts.push(`${twoDigits(lakh)} Lakh`);
const thousand = Math.floor(rest / 1000);
rest %= 1000;
if (thousand > 0) parts.push(`${twoDigits(thousand)} Thousand`);
if (rest > 0) parts.push(threeDigits(rest));
return parts.join(" ");
}
/** e.g. 25000 -> "Indian Rupees Twenty Five Thousand Only" */
export function amountInWords(amount: number, currency = "Indian Rupees"): string {
const value = Number.isFinite(amount) ? Math.abs(amount) : 0;
const rupees = Math.floor(value);
const paise = Math.round((value - rupees) * 100);
let out = "";
if (currency) out += `${currency} `;
out += wholeToWords(rupees);
if (paise > 0) out += ` and ${twoDigits(paise)} Paise`;
return `${out} Only`;
}
+94
View File
@@ -0,0 +1,94 @@
import { pdf } from "@react-pdf/renderer";
import { save } from "@tauri-apps/plugin-dialog";
import { InvoiceDocument, type InvoicePdfProps } from "../pdf/InvoiceDocument";
import { api } from "./api";
import type { BankAccount, Invoice, Settings } from "./types";
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result ?? "");
resolve(result.substring(result.indexOf(",") + 1));
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
export async function buildPdfProps(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
): Promise<InvoicePdfProps> {
const logoDataUri = settings.logoPath ? await api.readAssetDataUri(settings.logoPath) : null;
const signatureDataUri = invoice.signaturePath
? await api.readAssetDataUri(invoice.signaturePath)
: null;
let resolvedBank = bank;
if (!resolvedBank && invoice.bankSnapshot) {
try {
resolvedBank = JSON.parse(invoice.bankSnapshot) as BankAccount;
} catch {
resolvedBank = null;
}
}
return {
vendor: settings,
bank: resolvedBank,
logoDataUri,
signatureDataUri,
number: invoice.number,
invoiceDate: invoice.invoiceDate,
dueDate: invoice.dueDate,
clientName: invoice.clientName,
clientAddress: invoice.clientAddress,
clientGstin: invoice.clientGstin,
poNumber: invoice.poNumber,
placeOfSupplyStateCode: invoice.placeOfSupplyStateCode,
items: invoice.items,
subtotal: invoice.subtotal,
discount: invoice.discount,
taxType: invoice.taxType,
taxRate: invoice.taxRate,
cgst: invoice.cgstAmount,
sgst: invoice.sgstAmount,
igst: invoice.igstAmount,
total: invoice.total,
amountInWords: invoice.amountInWords,
notes: invoice.notes,
};
}
/** Render props straight to a PDF blob. */
export async function renderPdfBlob(props: InvoicePdfProps): Promise<Blob> {
return pdf(<InvoiceDocument {...props} />).toBlob();
}
/** Render a PDF and prompt the user for a save location. */
export async function exportPdfFromProps(
props: InvoicePdfProps,
fileName: string,
): Promise<string | null> {
const blob = await renderPdfBlob(props);
const dataBase64 = await blobToBase64(blob);
const suggested = fileName.replace(/[^A-Za-z0-9._-]+/g, "-") || "invoice";
const path = await save({
defaultPath: `${suggested}.pdf`,
filters: [{ name: "PDF document", extensions: ["pdf"] }],
});
if (!path) return null;
await api.saveBinaryFile(path, dataBase64);
return path;
}
export async function exportInvoicePdf(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
): Promise<string | null> {
const props = await buildPdfProps(invoice, settings, bank);
return exportPdfFromProps(props, invoice.number);
}
+168
View File
@@ -0,0 +1,168 @@
export type TaxType = "none" | "cgst_sgst" | "igst";
export type LineMode = "fixed" | "rate";
export type LineUnit = "second" | "minute" | "hour" | "session" | "unit";
export interface Settings {
vendorName: string;
vendorAddress: string;
vendorEmail: string;
vendorPhone: string;
vendorPan: string;
vendorGstin: string;
vendorStateCode: string;
logoPath: string | null;
signaturePath: string | null;
defaultBankId: number | null;
defaultTaxRate: number;
defaultTaxType: TaxType;
paymentTermsDays: number;
currency: string;
onboarded: boolean;
theme: string;
}
export interface BankAccount {
id: number | null;
label: string;
bankName: string;
accountName: string;
accountNo: string;
branch: string;
ifsc: string;
isDefault: boolean;
}
export interface InvoiceSeries {
id: number;
prefix: string;
padding: number;
nextNumber: number;
isActive: boolean;
createdAt: string;
nextInvoiceNumber: string;
}
export interface Client {
id: number | null;
name: string;
address: string;
gstin: string;
stateCode: string;
poNumber: string;
createdAt: string;
}
export interface InvoiceItem {
id?: number | null;
description: string;
mode: LineMode;
rate: number;
unit: LineUnit;
quantity: number;
amount: number;
sortOrder: number;
}
export interface InvoiceInput {
invoiceDate: string;
dueDate: string;
clientId: number | null;
clientName: string;
clientAddress: string;
clientGstin: string;
poNumber: string;
placeOfSupplyStateCode: string;
subtotal: number;
discount: number;
taxType: TaxType;
taxRate: number;
cgstAmount: number;
sgstAmount: number;
igstAmount: number;
total: number;
amountInWords: string;
bankAccountId: number | null;
bankSnapshot: string;
signaturePath: string | null;
notes: string;
saveClient: boolean;
items: InvoiceItem[];
}
export interface Invoice extends Omit<InvoiceInput, "saveClient"> {
id: number;
number: string;
seriesId: number | null;
status: string;
createdAt: string;
updatedAt: string;
items: InvoiceItem[];
}
export interface InvoiceSummary {
id: number;
number: string;
invoiceDate: string;
dueDate: string;
clientName: string;
total: number;
status: string;
createdAt: string;
}
export const EMPTY_ITEM: InvoiceItem = {
description: "",
mode: "fixed",
rate: 0,
unit: "unit",
quantity: 1,
amount: 0,
sortOrder: 0,
};
export const INDIAN_STATES: { code: string; name: string }[] = [
{ code: "01", name: "Jammu & Kashmir" },
{ code: "02", name: "Himachal Pradesh" },
{ code: "03", name: "Punjab" },
{ code: "04", name: "Chandigarh" },
{ code: "05", name: "Uttarakhand" },
{ code: "06", name: "Haryana" },
{ code: "07", name: "Delhi" },
{ code: "08", name: "Rajasthan" },
{ code: "09", name: "Uttar Pradesh" },
{ code: "10", name: "Bihar" },
{ code: "11", name: "Sikkim" },
{ code: "12", name: "Arunachal Pradesh" },
{ code: "13", name: "Nagaland" },
{ code: "14", name: "Manipur" },
{ code: "15", name: "Mizoram" },
{ code: "16", name: "Tripura" },
{ code: "17", name: "Meghalaya" },
{ code: "18", name: "Assam" },
{ code: "19", name: "West Bengal" },
{ code: "20", name: "Jharkhand" },
{ code: "21", name: "Odisha" },
{ code: "22", name: "Chhattisgarh" },
{ code: "23", name: "Madhya Pradesh" },
{ code: "24", name: "Gujarat" },
{ code: "26", name: "Dadra & Nagar Haveli and Daman & Diu" },
{ code: "27", name: "Maharashtra" },
{ code: "29", name: "Karnataka" },
{ code: "30", name: "Goa" },
{ code: "31", name: "Lakshadweep" },
{ code: "32", name: "Kerala" },
{ code: "33", name: "Tamil Nadu" },
{ code: "34", name: "Puducherry" },
{ code: "35", name: "Andaman & Nicobar Islands" },
{ code: "36", name: "Telangana" },
{ code: "37", name: "Andhra Pradesh" },
{ code: "38", name: "Ladakh" },
];
export const UNIT_LABELS: Record<LineUnit, string> = {
second: "per second",
minute: "per minute",
hour: "per hour",
session: "per session",
unit: "per unit",
};
+42
View File
@@ -0,0 +1,42 @@
const PAN_RE = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
const IFSC_RE = /^[A-Z]{4}0[A-Z0-9]{6}$/;
const GSTIN_RE = /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PHONE_RE = /^[0-9+\-()\s]{6,16}$/;
const norm = (v: string) => v.trim().toUpperCase();
export function panError(value: string): string | undefined {
if (!value.trim()) return undefined;
return PAN_RE.test(norm(value)) ? undefined : "Enter a valid PAN, e.g. CPOPP7388K";
}
export function ifscError(value: string): string | undefined {
if (!value.trim()) return undefined;
return IFSC_RE.test(norm(value)) ? undefined : "Enter a valid IFSC, e.g. SBIN0017060";
}
export function gstinError(value: string): string | undefined {
const v = norm(value);
if (!v || v === "NA") return undefined;
return GSTIN_RE.test(v) ? undefined : "Enter a valid 15-character GSTIN";
}
export function emailError(value: string): string | undefined {
if (!value.trim()) return undefined;
return EMAIL_RE.test(value.trim()) ? undefined : "Enter a valid email address";
}
export function phoneError(value: string): string | undefined {
if (!value.trim()) return undefined;
return PHONE_RE.test(value.trim()) ? undefined : "Enter a valid phone number";
}
export function accountError(value: string): string | undefined {
if (!value.trim()) return undefined;
return /^[0-9]{6,20}$/.test(value.trim()) ? undefined : "Account number should be 6–20 digits";
}
export const isPan = (v: string) => PAN_RE.test(norm(v));
export const isIfsc = (v: string) => IFSC_RE.test(norm(v));
export const isGstin = (v: string) => GSTIN_RE.test(norm(v));
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/carbon.scss";
createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+324
View File
@@ -0,0 +1,324 @@
import { Document, Font, Image, Page, StyleSheet, Text, View } from "@react-pdf/renderer";
import { UNIT_LABELS, type BankAccount, type InvoiceItem, type Settings, type TaxType } from "../lib/types";
import { computeLineAmount } from "../lib/invoice";
Font.register({
family: "IBM Plex Sans",
fonts: [
{ src: "/fonts/IBMPlexSans-Light.woff", fontWeight: 300 },
{ src: "/fonts/IBMPlexSans-Regular.woff", fontWeight: 400 },
{ src: "/fonts/IBMPlexSans-Italic.woff", fontWeight: 400, fontStyle: "italic" },
{ src: "/fonts/IBMPlexSans-Medium.woff", fontWeight: 500 },
{ src: "/fonts/IBMPlexSans-SemiBold.woff", fontWeight: 600 },
{ src: "/fonts/IBMPlexSans-Bold.woff", fontWeight: 700 },
],
});
Font.register({
family: "IBM Plex Mono",
fonts: [
{ src: "/fonts/IBMPlexMono-Regular.woff", fontWeight: 400 },
{ src: "/fonts/IBMPlexMono-SemiBold.woff", fontWeight: 600 },
],
});
const amountFmt = new Intl.NumberFormat("en-IN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const money = (n: number) => `${"\u20B9"}${amountFmt.format(Number.isFinite(n) ? n : 0)}`;
const dateFmt = (iso: string) => {
if (!iso) return "\u2014";
const d = new Date(`${iso}T00:00:00`);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
};
export interface InvoicePdfProps {
vendor: Settings;
bank: BankAccount | null;
logoDataUri: string | null;
signatureDataUri: string | null;
number: string;
invoiceDate: string;
dueDate: string;
clientName: string;
clientAddress: string;
clientGstin: string;
poNumber: string;
placeOfSupplyStateCode: string;
items: InvoiceItem[];
subtotal: number;
discount: number;
taxType: TaxType;
taxRate: number;
cgst: number;
sgst: number;
igst: number;
total: number;
amountInWords: string;
notes: string;
}
const styles = StyleSheet.create({
page: {
fontFamily: "IBM Plex Sans",
fontSize: 9.5,
color: "#161616",
paddingTop: 40,
paddingBottom: 48,
paddingHorizontal: 40,
lineHeight: 1.45,
},
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" },
vendor: { flexDirection: "row", maxWidth: "58%" },
logo: { maxHeight: 54, maxWidth: 170, marginRight: 14, objectFit: "contain" },
vendorName: { fontSize: 18, fontWeight: 600, marginBottom: 3 },
vendorLine: { color: "#393939" },
meta: { textAlign: "right", maxWidth: "40%" },
title: { fontSize: 18, fontWeight: 600, letterSpacing: 1.5, textTransform: "uppercase", marginBottom: 8 },
metaRow: { flexDirection: "row", justifyContent: "space-between", gap: 14 },
metaLabel: { color: "#525252" },
metaValue: { fontWeight: 600 },
divider: { borderBottomWidth: 1, borderBottomColor: "#e0e0e0", marginTop: 16, marginBottom: 16 },
split: { flexDirection: "row", justifyContent: "space-between", gap: 24 },
blockLabel: { fontSize: 7.5, letterSpacing: 1, textTransform: "uppercase", color: "#525252", marginBottom: 3 },
strong: { fontWeight: 600 },
block: { maxWidth: "48%" },
table: { marginTop: 22, borderTopWidth: 1.4, borderTopColor: "#161616" },
tr: { flexDirection: "row", borderBottomWidth: 0.6, borderBottomColor: "#e0e0e0", paddingVertical: 6 },
th: { fontSize: 7.5, letterSpacing: 0.8, textTransform: "uppercase", color: "#525252", paddingBottom: 4 },
colDesc: { flex: 5 },
colRate: { flex: 2, textAlign: "right" },
colQty: { flex: 2, textAlign: "right" },
colTotal: { flex: 2, textAlign: "right" },
totalsWrap: { flexDirection: "row", justifyContent: "flex-end", marginTop: 12 },
totals: { width: 250 },
totalsRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2.5 },
grand: {
flexDirection: "row",
justifyContent: "space-between",
borderTopWidth: 1.4,
borderTopColor: "#161616",
marginTop: 4,
paddingTop: 6,
fontSize: 13,
fontWeight: 600,
},
words: { marginTop: 12, fontStyle: "italic" },
bottom: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-end", marginTop: 34 },
signature: { textAlign: "right", minWidth: 180 },
signatureImg: { maxHeight: 58, maxWidth: 190, objectFit: "contain", marginLeft: "auto" },
signatureName: {
marginTop: 4,
paddingTop: 3,
borderTopWidth: 0.8,
borderTopColor: "#8d8d8d",
fontWeight: 600,
},
fineprint: { fontSize: 7.5, color: "#6f6f6f", textAlign: "right", marginTop: 6, maxWidth: 220 },
footer: {
position: "absolute",
bottom: 24,
left: 40,
right: 40,
flexDirection: "row",
justifyContent: "space-between",
borderTopWidth: 0.6,
borderTopColor: "#e0e0e0",
paddingTop: 6,
fontSize: 7.5,
color: "#6f6f6f",
},
});
export function InvoiceDocument(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(" \u00B7 ");
return (
<Document title={`${isTaxInvoice ? "Tax Invoice" : "Invoice"} ${number}`} author={vendor.vendorName}>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View style={styles.vendor}>
{logoDataUri ? <Image style={styles.logo} src={logoDataUri} /> : null}
<View>
<Text style={styles.vendorName}>{vendor.vendorName || "Vendor"}</Text>
<Text style={styles.vendorLine}>{vendor.vendorAddress}</Text>
{contact ? <Text style={styles.vendorLine}>{contact}</Text> : null}
{vendor.vendorPan ? <Text style={styles.vendorLine}>PAN: {vendor.vendorPan}</Text> : null}
<Text style={styles.vendorLine}>GSTIN: {vendor.vendorGstin || "NA"}</Text>
</View>
</View>
<View style={styles.meta}>
<Text style={styles.title}>{isTaxInvoice ? "Tax Invoice" : "Invoice"}</Text>
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>Invoice No.</Text>
<Text style={styles.metaValue}>{number}</Text>
</View>
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>Date</Text>
<Text style={styles.metaValue}>{dateFmt(invoiceDate)}</Text>
</View>
{dueDate ? (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>Due Date</Text>
<Text style={styles.metaValue}>{dateFmt(dueDate)}</Text>
</View>
) : null}
</View>
</View>
<View style={styles.divider} />
<View style={styles.split}>
<View style={styles.block}>
<Text style={styles.blockLabel}>Billed To</Text>
<Text style={styles.strong}>{clientName || "\u2014"}</Text>
{clientAddress ? <Text>{clientAddress}</Text> : null}
{clientGstin ? <Text>GSTIN: {clientGstin}</Text> : null}
{placeOfSupplyStateCode ? <Text>Place of Supply: {placeOfSupplyStateCode}</Text> : null}
</View>
<View style={styles.block}>
{poNumber ? (
<>
<Text style={styles.blockLabel}>PO Number</Text>
<Text style={styles.strong}>{poNumber}</Text>
</>
) : null}
</View>
</View>
<View style={styles.table}>
<View style={[styles.tr, { borderBottomColor: "#161616" }]}>
<Text style={[styles.th, styles.colDesc]}>Description</Text>
<Text style={[styles.th, styles.colRate]}>Unit Price</Text>
<Text style={[styles.th, styles.colQty]}>Qty</Text>
<Text style={[styles.th, styles.colTotal]}>Total</Text>
</View>
{items.map((item, i) => (
<View style={styles.tr} key={item.id ?? i}>
<Text style={styles.colDesc}>{item.description || "\u2014"}</Text>
<Text style={styles.colRate}>
{item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "\u2014"}
</Text>
<Text style={styles.colQty}>{item.mode === "rate" ? item.quantity : 1}</Text>
<Text style={styles.colTotal}>{money(computeLineAmount(item))}</Text>
</View>
))}
</View>
<View style={styles.totalsWrap}>
<View style={styles.totals}>
<View style={styles.totalsRow}>
<Text>Subtotal</Text>
<Text>{money(subtotal)}</Text>
</View>
{discount > 0 ? (
<View style={styles.totalsRow}>
<Text>Discount</Text>
<Text>- {money(discount)}</Text>
</View>
) : null}
{taxType === "cgst_sgst" ? (
<>
<View style={styles.totalsRow}>
<Text>CGST ({(taxRate / 2).toFixed(2)}%)</Text>
<Text>{money(cgst)}</Text>
</View>
<View style={styles.totalsRow}>
<Text>SGST ({(taxRate / 2).toFixed(2)}%)</Text>
<Text>{money(sgst)}</Text>
</View>
</>
) : null}
{taxType === "igst" ? (
<View style={styles.totalsRow}>
<Text>IGST ({taxRate.toFixed(2)}%)</Text>
<Text>{money(igst)}</Text>
</View>
) : null}
<View style={styles.grand}>
<Text>Total</Text>
<Text>{money(total)}</Text>
</View>
</View>
</View>
<Text style={styles.words}>Amount in words: {amountInWords}</Text>
{notes ? (
<View style={{ marginTop: 14 }}>
<Text style={styles.blockLabel}>Notes</Text>
<Text>{notes}</Text>
</View>
) : null}
<View style={styles.bottom}>
<View style={[styles.block, { maxWidth: "55%" }]}>
{bank ? (
<>
<Text style={styles.blockLabel}>Pay To</Text>
<Text style={styles.strong}>{bank.accountName || vendor.vendorName}</Text>
<Text>{bank.bankName}</Text>
{bank.accountNo ? <Text>Account No.: {bank.accountNo}</Text> : null}
{bank.branch ? <Text>Branch: {bank.branch}</Text> : null}
{bank.ifsc ? <Text>IFSC: {bank.ifsc}</Text> : null}
</>
) : null}
</View>
<View style={styles.signature}>
{signatureDataUri ? (
<>
<Image style={styles.signatureImg} src={signatureDataUri} />
<Text style={styles.signatureName}>{vendor.vendorName}</Text>
</>
) : (
<>
<Text style={styles.signatureName}>{vendor.vendorName}</Text>
<Text style={styles.fineprint}>
Computer-generated invoice; no signature is required.
</Text>
</>
)}
</View>
</View>
<View style={styles.footer} fixed>
<Text>{vendor.vendorName}</Text>
<Text>{"Generated with Voiced \u2014 a Bongbetic product"}</Text>
</View>
</Page>
</Document>
);
}
+392
View File
@@ -0,0 +1,392 @@
// Voiced — Carbon Design System, compiled with self-hosted IBM Plex for offline use.
// Font-face emission from Carbon is disabled so we can serve the fonts ourselves
// instead of pulling them from IBM's CDN (the app must work with no network).
@use '@carbon/react' with (
$css--font-face: false,
$use-akamai-cdn: false
);
/* ---------- IBM Plex (bundled in /public/fonts) ---------- */
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('/fonts/IBMPlexSans-Light.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 300;
font-display: swap;
src: url('/fonts/IBMPlexSans-LightItalic.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/IBMPlexSans-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/fonts/IBMPlexSans-Italic.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/IBMPlexSans-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 500;
font-display: swap;
src: url('/fonts/IBMPlexSans-MediumItalic.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/IBMPlexSans-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 600;
font-display: swap;
src: url('/fonts/IBMPlexSans-SemiBoldItalic.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/IBMPlexSans-Bold.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('/fonts/IBMPlexSans-BoldItalic.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/IBMPlexMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/IBMPlexMono-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/IBMPlexMono-SemiBold.woff2') format('woff2');
}
/* ---------- App shell ---------- */
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background-color: var(--cds-background);
color: var(--cds-text-primary);
}
.voiced-brand {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.voiced-brand__logo {
height: 1.25rem;
width: auto;
display: block;
}
.voiced-brand__divider {
width: 1px;
height: 1.25rem;
background: var(--cds-border-subtle-01);
}
.voiced-brand__product {
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
}
.voiced-header-action {
display: flex;
align-items: center;
gap: 0.5rem;
}
.voiced-page {
padding: 2rem;
max-width: 1600px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.voiced-page__title {
margin-bottom: 0.25rem;
}
.voiced-page__subtitle {
color: var(--cds-text-secondary);
margin-bottom: 2rem;
}
.voiced-stack > * + * {
margin-top: 1.5rem;
}
.voiced-row-gap {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
}
.voiced-muted {
color: var(--cds-text-secondary);
}
.voiced-empty {
padding: 3rem 1rem;
text-align: center;
color: var(--cds-text-secondary);
}
/* ---------- Invoice preview ("paper") ---------- */
.inv-preview-wrap {
display: flex;
justify-content: center;
}
.inv-paper {
background: #ffffff;
color: #161616;
width: 100%;
max-width: 820px;
padding: 40px 44px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
font-family: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
font-size: 13px;
line-height: 1.5;
box-sizing: border-box;
}
.inv-top {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: flex-start;
}
.inv-vendor {
display: flex;
gap: 16px;
align-items: flex-start;
min-width: 0;
}
.inv-vendor__logo {
max-height: 56px;
max-width: 220px;
object-fit: contain;
}
.inv-vendor__name {
font-size: 22px;
font-weight: 600;
margin: 0 0 4px;
}
.inv-vendor__line {
margin: 0;
white-space: pre-line;
}
.inv-meta {
text-align: right;
min-width: 220px;
}
.inv-meta__title {
font-size: 20px;
font-weight: 600;
letter-spacing: 0.08em;
margin: 0 0 8px;
text-transform: uppercase;
}
.inv-meta__row {
display: flex;
justify-content: space-between;
gap: 16px;
}
.inv-meta__label {
color: #525252;
}
.inv-meta__value {
font-weight: 600;
}
.inv-split {
display: flex;
justify-content: space-between;
gap: 24px;
margin-top: 28px;
}
.inv-block {
min-width: 0;
}
.inv-block__label {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #525252;
margin: 0 0 4px;
}
.inv-block__strong {
font-weight: 600;
}
.inv-table {
width: 100%;
border-collapse: collapse;
margin-top: 28px;
}
.inv-table th {
text-align: left;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #525252;
border-bottom: 1.5px solid #161616;
padding: 6px 8px;
}
.inv-table td {
padding: 8px;
border-bottom: 1px solid #e0e0e0;
vertical-align: top;
}
.inv-table .num {
text-align: right;
white-space: nowrap;
}
.inv-totals {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.inv-totals__table {
width: 320px;
}
.inv-totals__row {
display: flex;
justify-content: space-between;
padding: 4px 0;
}
.inv-totals__row--grand {
border-top: 1.5px solid #161616;
margin-top: 4px;
padding-top: 8px;
font-size: 16px;
font-weight: 600;
}
.inv-words {
margin-top: 16px;
font-style: italic;
}
.inv-bottom {
display: flex;
justify-content: space-between;
gap: 24px;
margin-top: 32px;
align-items: flex-end;
}
.inv-signature {
text-align: right;
}
.inv-signature__img {
max-height: 64px;
max-width: 220px;
object-fit: contain;
display: block;
margin-left: auto;
}
.inv-signature__name {
margin-top: 6px;
font-weight: 600;
border-top: 1px solid #8d8d8d;
padding-top: 4px;
display: inline-block;
min-width: 160px;
}
.inv-fineprint {
margin-top: 8px;
font-size: 10px;
color: #6f6f6f;
text-align: right;
}
.inv-footer {
margin-top: 28px;
padding-top: 10px;
border-top: 1px solid #e0e0e0;
font-size: 10px;
color: #6f6f6f;
display: flex;
justify-content: space-between;
gap: 12px;
}
@media print {
.inv-paper {
box-shadow: none;
max-width: none;
}
}
+411
View File
@@ -0,0 +1,411 @@
import { useEffect, useState } from "react";
import {
Button,
Column,
Grid,
InlineNotification,
NumberInput,
RadioButton,
RadioButtonGroup,
Select,
SelectItem,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
TextArea,
TextInput,
Toggle,
} from "@carbon/react";
import { Add, Save, TrashCan } from "@carbon/icons-react";
import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import { INDIAN_STATES, type BankAccount, type Settings, type TaxType } from "../lib/types";
import { emailError, ifscError, panError, phoneError } from "../lib/validators";
import { ImagePicker } from "../components/ImagePicker";
const blankBank = (): BankAccount => ({
id: null,
label: "",
bankName: "",
accountName: "",
accountNo: "",
branch: "",
ifsc: "",
isDefault: false,
});
export default function AppSettings({
settings,
onSaved,
}: {
settings: Settings;
onSaved: () => void;
}) {
const [form, setForm] = useState<Settings>({ ...settings });
const [banks, setBanks] = useState<BankAccount[]>([]);
const [editing, setEditing] = useState<BankAccount | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const loadBanks = async () => {
try {
setBanks(await api.listBanks());
} catch (e) {
setError(String(e));
}
};
useEffect(() => {
void loadBanks();
}, []);
const set = <K extends keyof Settings>(key: K, value: Settings[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const setBank = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
setEditing((prev) => (prev ? { ...prev, [key]: value } : prev));
const saveAll = async () => {
setBusy(true);
setError(null);
setNotice(null);
try {
await api.saveSettings(form);
setNotice("Settings saved.");
onSaved();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const saveBank = async () => {
if (!editing) return;
setBusy(true);
setError(null);
try {
const saved = await api.saveBank(editing);
if (saved.isDefault || form.defaultBankId === null) {
setForm((prev) => ({ ...prev, defaultBankId: saved.id }));
}
setEditing(null);
await loadBanks();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const removeBank = async (bank: BankAccount) => {
if (bank.id === null) return;
const ok = await confirm(`Delete bank account “${bank.bankName}”?`, {
title: "Delete bank account",
kind: "warning",
});
if (!ok) return;
try {
await api.deleteBank(bank.id);
if (form.defaultBankId === bank.id) set("defaultBankId", null);
await loadBanks();
} catch (e) {
setError(String(e));
}
};
return (
<div className="voiced-page">
<h1 className="voiced-page__title">Settings</h1>
<p className="voiced-page__subtitle">Vendor details, defaults and bank accounts.</p>
{error ? (
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
) : null}
{notice ? (
<InlineNotification
kind="success"
lowContrast
title="Saved"
subtitle={notice}
onCloseButtonClick={() => setNotice(null)}
/>
) : null}
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Vendor details</h3>
<Grid narrow>
<Column sm={4} md={8} lg={12}>
<TextInput
id="s-vendorName"
labelText="Name on invoice"
value={form.vendorName}
onChange={(e) => set("vendorName", e.target.value)}
/>
</Column>
<Column sm={4} md={8} lg={12}>
<TextArea
id="s-vendorAddress"
labelText="Address"
rows={3}
value={form.vendorAddress}
onChange={(e) => set("vendorAddress", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-vendorEmail"
labelText="Email"
value={form.vendorEmail}
onChange={(e) => set("vendorEmail", e.target.value)}
invalid={Boolean(emailError(form.vendorEmail))}
invalidText={emailError(form.vendorEmail)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-vendorPhone"
labelText="Phone"
value={form.vendorPhone}
onChange={(e) => set("vendorPhone", e.target.value)}
invalid={Boolean(phoneError(form.vendorPhone))}
invalidText={phoneError(form.vendorPhone)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-vendorPan"
labelText="PAN"
value={form.vendorPan}
onChange={(e) => set("vendorPan", e.target.value.toUpperCase())}
invalid={Boolean(panError(form.vendorPan))}
invalidText={panError(form.vendorPan)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-vendorGstin"
labelText="GSTIN (blank if unregistered)"
value={form.vendorGstin}
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
/>
</Column>
<Column sm={4} md={8} lg={12}>
<Select
id="s-vendorState"
labelText="State of supply"
value={form.vendorStateCode}
onChange={(e) => set("vendorStateCode", e.target.value)}
>
{INDIAN_STATES.map((s) => (
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
))}
</Select>
</Column>
<Column sm={4} md={8} lg={8}>
<ImagePicker
kind="logo"
label="Vendor logo"
value={form.logoPath}
onChange={(path) => set("logoPath", path)}
/>
</Column>
<Column sm={4} md={8} lg={8}>
<ImagePicker
kind="signature"
label="Default signature"
value={form.signaturePath}
onChange={(path) => set("signaturePath", path)}
/>
</Column>
</Grid>
</section>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Defaults</h3>
<Grid narrow>
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-taxRate"
label="Default tax rate (%)"
min={0}
max={100}
step={0.5}
value={form.defaultTaxRate}
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-terms"
label="Payment terms (days)"
min={0}
max={365}
value={form.paymentTermsDays}
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={8} lg={6}>
<Select
id="s-theme"
labelText="Theme"
value={form.theme}
onChange={(e) => set("theme", e.target.value)}
>
<SelectItem value="g10" text="Carbon light (g10)" />
<SelectItem value="g100" text="Carbon dark (g100)" />
</Select>
</Column>
<Column sm={4} md={8} lg={16}>
<RadioButtonGroup
legendText="Default tax treatment"
name="s-taxType"
valueSelected={form.defaultTaxType}
onChange={(value) => set("defaultTaxType", value as TaxType)}
>
<RadioButton id="s-tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
<RadioButton id="s-tt-igst" labelText="IGST (inter-state)" value="igst" />
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
</RadioButtonGroup>
</Column>
</Grid>
</section>
<section className="voiced-stack" style={{ marginBottom: "2rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h3>Bank accounts</h3>
<Button kind="tertiary" size="sm" renderIcon={Add} onClick={() => setEditing(blankBank())}>
Add account
</Button>
</div>
{banks.length > 0 ? (
<TableContainer>
<Table aria-label="Bank accounts">
<TableHead>
<TableRow>
<TableHeader>Bank</TableHeader>
<TableHeader>Account name</TableHeader>
<TableHeader>Account no.</TableHeader>
<TableHeader>IFSC</TableHeader>
<TableHeader>Default</TableHeader>
<TableHeader>Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{banks.map((bank) => (
<TableRow key={bank.id ?? bank.bankName}>
<TableCell>{bank.bankName}</TableCell>
<TableCell>{bank.accountName}</TableCell>
<TableCell>{bank.accountNo}</TableCell>
<TableCell>{bank.ifsc}</TableCell>
<TableCell>{bank.isDefault ? "Yes" : "No"}</TableCell>
<TableCell>
<div className="voiced-header-action">
<Button kind="ghost" size="sm" onClick={() => setEditing({ ...bank })}>
Edit
</Button>
<Button
kind="ghost"
size="sm"
renderIcon={TrashCan}
onClick={() => removeBank(bank)}
>
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<div className="voiced-empty">No bank accounts yet.</div>
)}
{editing ? (
<div style={{ border: "1px solid var(--cds-border-subtle-01)", padding: "1rem" }}>
<Grid narrow>
<Column sm={4} md={4} lg={6}>
<TextInput
id="b-label"
labelText="Label"
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 kind="ghost" size="sm" onClick={() => setEditing(null)}>
Cancel
</Button>
</div>
</div>
) : null}
</section>
<Button renderIcon={Save} onClick={saveAll} disabled={busy}>
{busy ? "Saving…" : "Save settings"}
</Button>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useState } from "react";
import {
Button,
InlineLoading,
InlineNotification,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@carbon/react";
import { Download, TrashCan } from "@carbon/icons-react";
import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import { formatAmount, formatDate } from "../lib/format";
import { exportInvoicePdf } from "../lib/pdf";
import type { InvoiceSummary, Settings } from "../lib/types";
export default function InvoiceHistory({ settings }: { settings: Settings }) {
const [rows, setRows] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<number | null>(null);
const load = async () => {
try {
setRows(await api.listInvoices());
setError(null);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const onExport = async (id: number) => {
setBusyId(id);
setError(null);
try {
const invoice = await api.getInvoice(id);
await exportInvoicePdf(invoice, settings, null);
} catch (e) {
setError(String(e));
} finally {
setBusyId(null);
}
};
const onDelete = async (row: InvoiceSummary) => {
const ok = await confirm(`Delete invoice ${row.number}? This cannot be undone.`, {
title: "Delete invoice",
kind: "warning",
});
if (!ok) return;
try {
await api.deleteInvoice(row.id);
await load();
} catch (e) {
setError(String(e));
}
};
return (
<div className="voiced-page">
<h1 className="voiced-page__title">Invoices</h1>
<p className="voiced-page__subtitle">Every invoice you have issued, newest first.</p>
{error ? (
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
) : null}
{loading ? (
<InlineLoading description="Loading invoices" />
) : rows.length === 0 ? (
<div className="voiced-empty">No invoices yet. Create one from “New invoice”.</div>
) : (
<TableContainer>
<Table aria-label="Invoices">
<TableHead>
<TableRow>
<TableHeader>Number</TableHeader>
<TableHeader>Date</TableHeader>
<TableHeader>Client</TableHeader>
<TableHeader>Total</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.number}</TableCell>
<TableCell>{formatDate(row.invoiceDate)}</TableCell>
<TableCell>{row.clientName || "—"}</TableCell>
<TableCell>₹{formatAmount(row.total)}</TableCell>
<TableCell>{row.status}</TableCell>
<TableCell>
<div className="voiced-header-action">
<Button
kind="ghost"
size="sm"
renderIcon={Download}
onClick={() => onExport(row.id)}
disabled={busyId === row.id}
>
{busyId === row.id ? "Exporting…" : "PDF"}
</Button>
<Button
kind="ghost"
size="sm"
renderIcon={TrashCan}
onClick={() => onDelete(row)}
>
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</div>
);
}
+559
View File
@@ -0,0 +1,559 @@
import { useEffect, useMemo, useState } from "react";
import {
Button,
Column,
Dropdown,
Grid,
InlineNotification,
NumberInput,
RadioButton,
RadioButtonGroup,
Select,
SelectItem,
TextArea,
TextInput,
Toggle,
} from "@carbon/react";
import { Add, Save, TrashCan } from "@carbon/icons-react";
import { api } from "../lib/api";
import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
import type {
BankAccount,
Client,
InvoiceInput,
InvoiceItem,
LineMode,
LineUnit,
Settings,
TaxType,
} from "../lib/types";
import { computeLineAmount, computeTotals } from "../lib/invoice";
import { amountInWords } from "../lib/numberToWords";
import { addDays, formatAmount, todayIso } from "../lib/format";
import { buildPdfProps, exportPdfFromProps } from "../lib/pdf";
import { InvoicePreview } from "../components/InvoicePreview";
import { ImagePicker } from "../components/ImagePicker";
import type { InvoicePdfProps } from "../pdf/InvoiceDocument";
interface NewInvoiceProps {
settings: Settings;
onSettingsChange: () => void;
}
export default function NewInvoice({ settings }: NewInvoiceProps) {
const today = todayIso();
const [invoiceDate, setInvoiceDate] = useState(today);
const [dueDate, setDueDate] = useState(addDays(today, settings.paymentTermsDays));
const [number, setNumber] = useState("");
const [clients, setClients] = useState<Client[]>([]);
const [clientId, setClientId] = useState<number | null>(null);
const [clientName, setClientName] = useState("");
const [clientAddress, setClientAddress] = useState("");
const [clientGstin, setClientGstin] = useState("");
const [poNumber, setPoNumber] = useState("");
const [placeOfSupply, setPlaceOfSupply] = useState(settings.vendorStateCode);
const [saveClient, setSaveClient] = useState(false);
const [items, setItems] = useState<InvoiceItem[]>([{ ...EMPTY_ITEM }]);
const [discount, setDiscount] = useState(0);
const [taxesApplicable, setTaxesApplicable] = useState(settings.defaultTaxType !== "none");
const [taxType, setTaxType] = useState<TaxType>(
settings.defaultTaxType === "none" ? "cgst_sgst" : settings.defaultTaxType,
);
const [taxRate, setTaxRate] = useState(settings.defaultTaxRate);
const [banks, setBanks] = useState<BankAccount[]>([]);
const [bankId, setBankId] = useState<number | null>(settings.defaultBankId);
const [signaturePath, setSignaturePath] = useState<string | null>(settings.signaturePath);
const [notes, setNotes] = useState("");
const [logoUri, setLogoUri] = useState<string | null>(null);
const [signatureUri, setSignatureUri] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState<string | null>(null);
useEffect(() => {
let active = true;
(async () => {
try {
const [next, clientList, bankList] = await Promise.all([
api.peekNextInvoiceNumber(),
api.listClients(),
api.listBanks(),
]);
if (!active) return;
setNumber(next);
setClients(clientList);
setBanks(bankList);
} catch (e) {
if (active) setError(String(e));
}
})();
return () => {
active = false;
};
}, []);
useEffect(() => {
let active = true;
if (!settings.logoPath) {
setLogoUri(null);
return;
}
api.readAssetDataUri(settings.logoPath).then((uri) => active && setLogoUri(uri)).catch(() => {});
return () => {
active = false;
};
}, [settings.logoPath]);
useEffect(() => {
let active = true;
if (!signaturePath) {
setSignatureUri(null);
return;
}
api.readAssetDataUri(signaturePath).then((uri) => active && setSignatureUri(uri)).catch(() => {});
return () => {
active = false;
};
}, [signaturePath]);
const effectiveTaxType: TaxType = taxesApplicable ? taxType : "none";
const totals = useMemo(
() => computeTotals(items, discount, effectiveTaxType, taxRate),
[items, discount, effectiveTaxType, taxRate],
);
const words = useMemo(() => amountInWords(totals.total), [totals.total]);
const bank = useMemo(() => banks.find((b) => b.id === bankId) ?? null, [banks, bankId]);
const previewProps: InvoicePdfProps = {
vendor: settings,
bank,
logoDataUri: logoUri,
signatureDataUri: signatureUri,
number: number || "DRAFT",
invoiceDate,
dueDate,
clientName,
clientAddress,
clientGstin,
poNumber,
placeOfSupplyStateCode: placeOfSupply,
items,
subtotal: totals.subtotal,
discount: totals.discount,
taxType: effectiveTaxType,
taxRate,
cgst: totals.cgst,
sgst: totals.sgst,
igst: totals.igst,
total: totals.total,
amountInWords: words,
notes,
};
const updateItem = (index: number, patch: Partial<InvoiceItem>) =>
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
const addItem = () => setItems((prev) => [...prev, { ...EMPTY_ITEM }]);
const removeItem = (index: number) =>
setItems((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== index)));
const applyClient = (selected: Client) => {
setClientId(selected.id);
setClientName(selected.name);
setClientAddress(selected.address);
setClientGstin(selected.gstin);
setPoNumber(selected.poNumber);
if (selected.stateCode) setPlaceOfSupply(selected.stateCode);
};
const resetForNext = async () => {
setClientId(null);
setClientName("");
setClientAddress("");
setClientGstin("");
setPoNumber("");
setSaveClient(false);
setItems([{ ...EMPTY_ITEM }]);
setDiscount(0);
setNotes("");
setInvoiceDate(todayIso());
setDueDate(addDays(todayIso(), settings.paymentTermsDays));
try {
setNumber(await api.peekNextInvoiceNumber());
setClients(await api.listClients());
} catch {
/* ignore */
}
};
const onSave = async () => {
setSaving(true);
setError(null);
setSaved(null);
try {
const input: InvoiceInput = {
invoiceDate,
dueDate,
clientId,
clientName,
clientAddress,
clientGstin,
poNumber,
placeOfSupplyStateCode: placeOfSupply,
subtotal: totals.subtotal,
discount: totals.discount,
taxType: effectiveTaxType,
taxRate,
cgstAmount: totals.cgst,
sgstAmount: totals.sgst,
igstAmount: totals.igst,
total: totals.total,
amountInWords: words,
bankAccountId: bankId,
bankSnapshot: bank ? JSON.stringify(bank) : "",
signaturePath,
notes,
saveClient,
items: items.map((item, index) => ({
...item,
sortOrder: index,
amount: computeLineAmount(item),
})),
};
const invoice = await api.createInvoice(input);
const props = await buildPdfProps(invoice, settings, bank);
const path = await exportPdfFromProps(props, invoice.number);
if (path) setSaved(path);
await resetForNext();
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
};
return (
<div className="voiced-page">
<Grid>
<Column sm={4} md={8} lg={16}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", gap: "1rem", flexWrap: "wrap" }}>
<div>
<h1 className="voiced-page__title">New invoice</h1>
<p className="voiced-page__subtitle" style={{ marginBottom: 0 }}>
Next number: <strong>{number || "—"}</strong>
</p>
</div>
<div className="voiced-header-action">
<Button kind="ghost" onClick={resetForNext} disabled={saving}>
Clear
</Button>
<Button renderIcon={Save} onClick={onSave} disabled={saving}>
{saving ? "Saving…" : "Save & export PDF"}
</Button>
</div>
</div>
{error ? (
<InlineNotification kind="error" lowContrast title="Could not save invoice" subtitle={error} />
) : null}
{saved ? (
<InlineNotification
kind="success"
lowContrast
title="Invoice saved"
subtitle={saved}
onCloseButtonClick={() => setSaved(null)}
/>
) : null}
<div style={{ marginTop: "1.5rem" }} />
</Column>
<Column sm={4} md={8} lg={9} className="voiced-stack">
<Grid narrow>
<Column sm={4} md={4} lg={5}>
<TextInput
id="invoiceDate"
type="date"
labelText="Invoice date"
value={invoiceDate}
onChange={(e) => {
setInvoiceDate(e.target.value);
setDueDate(addDays(e.target.value, settings.paymentTermsDays));
}}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput
id="dueDate"
type="date"
labelText={`Due date (net ${settings.paymentTermsDays} days)`}
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</Column>
<Column sm={4} md={8} lg={6}>
<TextInput id="invoiceNumber" labelText="Invoice number" value={number} readOnly />
</Column>
<Column sm={4} md={8} lg={8}>
<Dropdown
id="savedClient"
titleText="Saved client (optional)"
label="Select a saved client to autofill"
items={clients}
itemToString={(item) => (item ? item.name : "")}
onChange={({ selectedItem }) => selectedItem && applyClient(selectedItem)}
/>
</Column>
<Column sm={4} md={8} lg={8}>
<TextInput
id="clientName"
labelText="Client name"
value={clientName}
onChange={(e) => {
setClientName(e.target.value);
setClientId(null);
}}
/>
</Column>
<Column sm={4} md={8} lg={16}>
<TextArea
id="clientAddress"
labelText="Client address"
rows={3}
value={clientAddress}
onChange={(e) => setClientAddress(e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput
id="clientGstin"
labelText="Client GSTIN (optional)"
value={clientGstin}
onChange={(e) => setClientGstin(e.target.value.toUpperCase())}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput
id="poNumber"
labelText="PO number (optional)"
value={poNumber}
onChange={(e) => setPoNumber(e.target.value)}
/>
</Column>
<Column sm={4} md={8} lg={6}>
<Select
id="placeOfSupply"
labelText="Place of supply"
value={placeOfSupply}
onChange={(e) => setPlaceOfSupply(e.target.value)}
>
{INDIAN_STATES.map((s) => (
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
))}
</Select>
</Column>
<Column sm={4} md={8} lg={16}>
<Toggle
id="saveClient"
labelText="Save this client for next time"
toggled={saveClient}
onToggle={setSaveClient}
/>
</Column>
</Grid>
<div>
<h3 style={{ marginBottom: "0.75rem" }}>Line items</h3>
{items.map((item, index) => (
<div
key={index}
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
alignItems: "flex-end",
padding: "0.75rem",
border: "1px solid var(--cds-border-subtle-01)",
marginBottom: "0.5rem",
}}
>
<div style={{ flex: "3 1 240px" }}>
<TextInput
id={`desc-${index}`}
labelText="Particulars / description"
value={item.description}
onChange={(e) => updateItem(index, { description: e.target.value })}
/>
</div>
<div style={{ flex: "0 1 150px" }}>
<Select
id={`mode-${index}`}
labelText="Type"
value={item.mode}
onChange={(e) => updateItem(index, { mode: e.target.value as LineMode })}
>
<SelectItem value="fixed" text="Fixed amount" />
<SelectItem value="rate" text="Rate × quantity" />
</Select>
</div>
{item.mode === "rate" ? (
<>
<div style={{ flex: "0 1 130px" }}>
<NumberInput
id={`rate-${index}`}
label="Rate (₹)"
min={0}
step={1}
value={item.rate}
onChange={(_e, { value }) => updateItem(index, { rate: Number(value) || 0 })}
/>
</div>
<div style={{ flex: "0 1 140px" }}>
<Select
id={`unit-${index}`}
labelText="Unit"
value={item.unit}
onChange={(e) => updateItem(index, { unit: e.target.value as LineUnit })}
>
{Object.entries(UNIT_LABELS).map(([value, text]) => (
<SelectItem key={value} value={value} text={text} />
))}
</Select>
</div>
<div style={{ flex: "0 1 110px" }}>
<NumberInput
id={`qty-${index}`}
label="Quantity"
min={0}
step={1}
value={item.quantity}
onChange={(_e, { value }) => updateItem(index, { quantity: Number(value) || 0 })}
/>
</div>
</>
) : (
<div style={{ flex: "0 1 150px" }}>
<NumberInput
id={`amount-${index}`}
label="Amount (₹)"
min={0}
step={1}
value={item.amount}
onChange={(_e, { value }) => updateItem(index, { amount: Number(value) || 0 })}
/>
</div>
)}
<div style={{ flex: "0 0 auto", paddingBottom: "0.4rem" }}>
<strong>₹{formatAmount(computeLineAmount(item))}</strong>
</div>
<Button
kind="ghost"
size="sm"
hasIconOnly
renderIcon={TrashCan}
iconDescription="Remove line"
onClick={() => removeItem(index)}
disabled={items.length === 1}
/>
</div>
))}
<Button kind="tertiary" size="sm" renderIcon={Add} onClick={addItem}>
Add line
</Button>
</div>
<Grid narrow>
<Column sm={4} md={4} lg={8}>
<NumberInput
id="discount"
label="Discount (₹)"
min={0}
step={1}
value={discount}
onChange={(_e, { value }) => setDiscount(Number(value) || 0)}
/>
</Column>
<Column sm={4} md={8} lg={16}>
<Toggle
id="taxes"
labelText="Taxes applicable"
toggled={taxesApplicable}
onToggle={setTaxesApplicable}
/>
</Column>
{taxesApplicable ? (
<>
<Column sm={4} md={8} lg={16}>
<RadioButtonGroup
legendText="Tax treatment"
name="taxType"
valueSelected={taxType}
onChange={(value) => setTaxType(value as TaxType)}
>
<RadioButton id="t-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
<RadioButton id="t-igst" labelText="IGST (inter-state)" value="igst" />
</RadioButtonGroup>
</Column>
<Column sm={4} md={4} lg={8}>
<NumberInput
id="taxRate"
label="Tax rate (%)"
min={0}
max={100}
step={0.5}
value={taxRate}
onChange={(_e, { value }) => setTaxRate(Number(value) || 0)}
/>
</Column>
</>
) : null}
</Grid>
<Select
id="bankAccount"
labelText="Bank account (Pay To)"
value={bankId ?? ""}
onChange={(e) => setBankId(e.target.value ? Number(e.target.value) : null)}
>
<SelectItem value="" text="No bank details" />
{banks.map((b) => (
<SelectItem
key={b.id ?? 0}
value={String(b.id)}
text={`${b.bankName} · ${b.accountNo}`}
/>
))}
</Select>
<ImagePicker
kind="signature"
label="Signature for this invoice (optional)"
value={signaturePath}
onChange={setSignaturePath}
/>
<TextArea
id="notes"
labelText="Notes (optional)"
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</Column>
<Column sm={4} md={8} lg={7}>
<div style={{ position: "sticky", top: "3rem" }}>
<h3 style={{ marginBottom: "0.75rem" }}>Preview</h3>
<InvoicePreview {...previewProps} />
</div>
</Column>
</Grid>
</div>
);
}
+362
View File
@@ -0,0 +1,362 @@
import { useEffect, useMemo, useState } from "react";
import {
Button,
Column,
Grid,
InlineNotification,
NumberInput,
ProgressIndicator,
ProgressStep,
RadioButton,
RadioButtonGroup,
Select,
SelectItem,
TextArea,
TextInput,
} from "@carbon/react";
import { api } from "../lib/api";
import type { BankAccount, Settings, TaxType } from "../lib/types";
import { INDIAN_STATES } from "../lib/types";
import { emailError, ifscError, panError, phoneError } from "../lib/validators";
import { ImagePicker } from "../components/ImagePicker";
interface OnboardingProps {
initial: Settings;
onDone: (settings: Settings) => void;
}
const bankers = (): BankAccount => ({
id: null,
label: "Primary",
bankName: "",
accountName: "",
accountNo: "",
branch: "",
ifsc: "",
isDefault: true,
});
export default function Onboarding({ initial, onDone }: OnboardingProps) {
const [step, setStep] = useState(0);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<Settings>({ ...initial });
const [bank, setBank] = useState<BankAccount>(bankers());
const [prefix, setPrefix] = useState(`AP/${new Date().getFullYear()}-`);
const [padding, setPadding] = useState(3);
useEffect(() => {
let active = true;
(async () => {
try {
const [banks, series] = await Promise.all([api.listBanks(), api.getActiveSeries()]);
if (!active) return;
const preferred = banks.find((b) => b.id === initial.defaultBankId) ?? banks[0];
if (preferred) setBank(preferred);
if (series) {
setPrefix(series.prefix);
setPadding(series.padding);
}
} catch {
/* first run – defaults are fine */
}
})();
return () => {
active = false;
};
}, [initial.defaultBankId]);
const set = <K extends keyof Settings>(key: K, value: Settings[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const bankSet = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
setBank((prev) => ({ ...prev, [key]: value }));
const businessInvalid = useMemo(
() => Boolean(panError(form.vendorPan) || emailError(form.vendorEmail) || phoneError(form.vendorPhone)),
[form.vendorPan, form.vendorEmail, form.vendorPhone],
);
const finish = async () => {
setSaving(true);
setError(null);
try {
const savedBank = await api.saveBank({ ...bank, isDefault: true });
await api.startNewSeries(prefix.trim() || `AP/${new Date().getFullYear()}-`, padding);
const next: Settings = {
...form,
vendorName: form.vendorName.trim() || "Vendor",
defaultBankId: savedBank.id,
onboarded: true,
};
const saved = await api.saveSettings(next);
onDone(saved);
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
};
const steps = [
{
label: "Business",
content: (
<Grid narrow>
<Column sm={4} md={8} lg={12}>
<p className="voiced-muted" style={{ marginBottom: "1rem" }}>
These details are printed on every invoice. They are pre-filled from your existing invoice —
adjust anything that has changed.
</p>
</Column>
<Column sm={4} md={8} lg={12}>
<TextInput
id="vendorName"
labelText="Name on invoice"
value={form.vendorName}
onChange={(e) => set("vendorName", e.target.value)}
required
/>
</Column>
<Column sm={4} md={8} lg={12}>
<TextArea
id="vendorAddress"
labelText="Address"
rows={3}
value={form.vendorAddress}
onChange={(e) => set("vendorAddress", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="vendorEmail"
labelText="Email"
value={form.vendorEmail}
onChange={(e) => set("vendorEmail", e.target.value)}
invalid={Boolean(emailError(form.vendorEmail))}
invalidText={emailError(form.vendorEmail)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="vendorPhone"
labelText="Phone"
value={form.vendorPhone}
onChange={(e) => set("vendorPhone", e.target.value)}
invalid={Boolean(phoneError(form.vendorPhone))}
invalidText={phoneError(form.vendorPhone)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="vendorPan"
labelText="PAN"
value={form.vendorPan}
onChange={(e) => set("vendorPan", e.target.value.toUpperCase())}
invalid={Boolean(panError(form.vendorPan))}
invalidText={panError(form.vendorPan)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="vendorGstin"
labelText="GSTIN (leave blank if not registered)"
value={form.vendorGstin}
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
/>
</Column>
<Column sm={4} md={8} lg={12}>
<Select
id="vendorState"
labelText="State of supply (your state)"
value={form.vendorStateCode}
onChange={(e) => set("vendorStateCode", e.target.value)}
>
{INDIAN_STATES.map((s) => (
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
))}
</Select>
</Column>
</Grid>
),
},
{
label: "Branding",
content: (
<Grid narrow>
<Column sm={4} md={8} lg={12}>
<ImagePicker
kind="logo"
label="Vendor logo (shown on the invoice header)"
value={form.logoPath}
onChange={(path) => set("logoPath", path)}
/>
</Column>
<Column sm={4} md={8} lg={12}>
<ImagePicker
kind="signature"
label="Signature (PNG or JPEG). If left empty, invoices note that no signature is required."
value={form.signaturePath}
onChange={(path) => set("signaturePath", path)}
/>
</Column>
</Grid>
),
},
{
label: "Bank",
content: (
<Grid narrow>
<Column sm={4} md={8} lg={12}>
<p className="voiced-muted" style={{ marginBottom: "1rem" }}>
Shown as the “Pay To” block. You can add more accounts later in Settings.
</p>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="bankName"
labelText="Bank name"
value={bank.bankName}
onChange={(e) => bankSet("bankName", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="accountName"
labelText="Account name"
value={bank.accountName}
onChange={(e) => bankSet("accountName", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="accountNo"
labelText="Account number"
value={bank.accountNo}
onChange={(e) => bankSet("accountNo", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="branch"
labelText="Branch"
value={bank.branch}
onChange={(e) => bankSet("branch", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="ifsc"
labelText="IFSC code"
value={bank.ifsc}
onChange={(e) => bankSet("ifsc", e.target.value.toUpperCase())}
invalid={Boolean(ifscError(bank.ifsc))}
invalidText={ifscError(bank.ifsc)}
/>
</Column>
</Grid>
),
},
{
label: "Numbering",
content: (
<Grid narrow>
<Column sm={4} md={8} lg={12}>
<p className="voiced-muted" style={{ marginBottom: "1rem" }}>
The serial keeps counting up automatically. Choose a prefix now; you can start a new series
(resetting to 1) at any time from the Series page.
</p>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="prefix"
labelText="Series prefix"
helperText={`First invoice will be ${prefix}${String(1).padStart(padding, "0")}`}
value={prefix}
onChange={(e) => setPrefix(e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<NumberInput
id="padding"
label="Serial digits"
min={1}
max={8}
value={padding}
onChange={(_e, { value }) => setPadding(Number(value) || 3)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<NumberInput
id="taxRate"
label="Default tax rate (%)"
min={0}
max={100}
value={form.defaultTaxRate}
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<NumberInput
id="terms"
label="Payment terms (days)"
min={0}
max={365}
value={form.paymentTermsDays}
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={8} lg={12}>
<RadioButtonGroup
legendText="Default tax treatment"
name="taxType"
valueSelected={form.defaultTaxType}
onChange={(value) => set("defaultTaxType", value as TaxType)}
>
<RadioButton id="tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
<RadioButton id="tt-igst" labelText="IGST (inter-state)" value="igst" />
<RadioButton id="tt-none" labelText="No tax" value="none" />
</RadioButtonGroup>
</Column>
</Grid>
),
},
];
const isLast = step === steps.length - 1;
return (
<div className="voiced-page" style={{ maxWidth: 880 }}>
<h1 className="voiced-page__title">Set up Voiced</h1>
<p className="voiced-page__subtitle">
A one-time setup. Everything here can be changed later from Settings.
</p>
<ProgressIndicator currentIndex={step} spaceEqually style={{ marginBottom: "2rem" }}>
{steps.map((s) => (
<ProgressStep key={s.label} label={s.label} />
))}
</ProgressIndicator>
{error ? (
<InlineNotification kind="error" lowContrast title="Could not save" subtitle={error} />
) : null}
<div style={{ marginTop: "1.5rem" }}>{steps[step].content}</div>
<div className="voiced-header-action" style={{ marginTop: "2rem" }}>
<Button kind="secondary" disabled={step === 0 || saving} onClick={() => setStep((s) => s - 1)}>
Back
</Button>
{isLast ? (
<Button disabled={saving || businessInvalid} onClick={finish}>
{saving ? "Saving…" : "Finish setup"}
</Button>
) : (
<Button onClick={() => setStep((s) => s + 1)}>Next</Button>
)}
</div>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useState } from "react";
import {
Button,
Column,
Grid,
InlineLoading,
InlineNotification,
NumberInput,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
Tag,
TextInput,
} from "@carbon/react";
import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import type { InvoiceSeries } from "../lib/types";
export default function SeriesSettings() {
const [active, setActive] = useState<InvoiceSeries | null>(null);
const [all, setAll] = useState<InvoiceSeries[]>([]);
const [loading, setLoading] = useState(true);
const [prefix, setPrefix] = useState(`AP/${new Date().getFullYear()}-`);
const [padding, setPadding] = useState(3);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const load = async () => {
try {
const [current, list] = await Promise.all([api.getActiveSeries(), api.listSeries()]);
setActive(current);
setAll(list);
if (current) {
setPrefix(current.prefix);
setPadding(current.padding);
}
setError(null);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const startNew = async () => {
const ok = await confirm(
`Start a new series from ${prefix}${String(1).padStart(padding, "0")}? ` +
"The current series will be retired and the counter resets to 1.",
{ title: "Start new series", kind: "warning" },
);
if (!ok) return;
setBusy(true);
setError(null);
setNotice(null);
try {
const created = await api.startNewSeries(prefix, padding);
setNotice(`New series started. Next invoice: ${created.nextInvoiceNumber}`);
await load();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
if (loading) {
return (
<div className="voiced-page">
<InlineLoading description="Loading series" />
</div>
);
}
return (
<div className="voiced-page">
<h1 className="voiced-page__title">Invoice series</h1>
<p className="voiced-page__subtitle">
The active series drives the automatic invoice numbers. Start a new series to reset the counter.
</p>
{error ? (
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
) : null}
{notice ? (
<InlineNotification
kind="success"
lowContrast
title="Done"
subtitle={notice}
onCloseButtonClick={() => setNotice(null)}
/>
) : null}
<Grid narrow style={{ marginBottom: "2rem" }}>
<Column sm={4} md={4} lg={6}>
<TextInput
id="seriesPrefix"
labelText="New series prefix"
helperText={`Next: ${prefix}${String(1).padStart(padding, "0")}`}
value={prefix}
onChange={(e) => setPrefix(e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={4}>
<NumberInput
id="seriesPadding"
label="Serial digits"
min={1}
max={8}
value={padding}
onChange={(_e, { value }) => setPadding(Number(value) || 3)}
/>
</Column>
<Column sm={4} md={8} lg={6} style={{ display: "flex", alignItems: "flex-end" }}>
<Button onClick={startNew} disabled={busy || !prefix.trim()}>
{busy ? "Starting…" : "Start new series"}
</Button>
</Column>
</Grid>
<TableContainer title="Series history">
<Table aria-label="Invoice series">
<TableHead>
<TableRow>
<TableHeader>Prefix</TableHeader>
<TableHeader>Next number</TableHeader>
<TableHeader>Digits</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Created</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{all.map((series) => (
<TableRow key={series.id}>
<TableCell>{series.prefix}</TableCell>
<TableCell>{series.nextInvoiceNumber}</TableCell>
<TableCell>{series.padding}</TableCell>
<TableCell>
{series.isActive ? <Tag type="green">Active</Tag> : <Tag type="gray">Retired</Tag>}
</TableCell>
<TableCell>{new Date(series.createdAt).toLocaleDateString("en-GB")}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</div>
);
}