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>
560 lines
19 KiB
TypeScript
560 lines
19 KiB
TypeScript
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>
|
||
);
|
||
}
|