Initial commit: Voiced invoice desktop app
Offline-first invoicing app for Test Vendor, built by Bongbetic. - Tauri v2 (Rust) + React/TypeScript + IBM Carbon Design System UI - SQLite (rusqlite) storage with a persistent invoice-number series - Guided first-run setup, pre-filled vendor/bank details - Fixed-amount or rate x quantity line items (per sec/min/hour/session/unit) - GST as CGST+SGST or IGST, Indian amount-in-words - Optional signature, with a computer-generated note as fallback - Print-ready PDF export via @react-pdf/renderer - Windows cross-compile via cargo-xwin (NSIS installer) Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user