Files
Voiced/src/views/NewInvoice.tsx
T
xavierkandCommandCodeBot 875a12b2e6 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>
2026-10-03 23:07:51 +05:30

560 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}