Classic now renders through buildRenderModel, computeFrame, the template and renderCore, with S.No and HSN/SAC columns, GST slots only through checked Slot components, taxable value, reverse-charge line, cancelled tag, continuation band and Page n of N. Letter absorbs its height deficit in elastic gaps. A harness runs 15 fixtures on A4 and Letter plus an item sweep and asserts zero audit errors, repeated headers, no split blocks, rupee extraction and deterministic output. The legacy InvoiceDocument is removed.
1117 lines
36 KiB
TypeScript
1117 lines
36 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
ActionableNotification,
|
||
Button,
|
||
Column,
|
||
Dropdown,
|
||
Grid,
|
||
InlineLoading,
|
||
InlineNotification,
|
||
Modal,
|
||
NumberInput,
|
||
Select,
|
||
SelectItem,
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
Tag,
|
||
TextArea,
|
||
TextInput,
|
||
Toggle,
|
||
} from "@carbon/react";
|
||
import {
|
||
Add,
|
||
Copy,
|
||
DocumentAdd,
|
||
Download,
|
||
Keyboard,
|
||
Renew,
|
||
Save,
|
||
TrashCan,
|
||
} from "@carbon/icons-react";
|
||
import { confirm } from "@tauri-apps/plugin-dialog";
|
||
import { api } from "../lib/api";
|
||
import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
|
||
import type {
|
||
BankAccount,
|
||
Client,
|
||
DraftSummary,
|
||
Invoice,
|
||
InvoiceInput,
|
||
InvoiceItem,
|
||
LineMode,
|
||
LineUnit,
|
||
Settings,
|
||
TaxType,
|
||
} from "../lib/types";
|
||
import { computeLineAmount, computeTotals } from "../lib/invoice";
|
||
import { amountInWords } from "../lib/numberToWords";
|
||
import { gstinFullError } from "../lib/validators";
|
||
import { addDays, formatAmount, todayIso } from "../lib/format";
|
||
import { exportInvoicePdf } from "../lib/pdf";
|
||
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
|
||
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
|
||
import { InvoicePreview } from "../components/InvoicePreview";
|
||
import { ImagePicker } from "../components/ImagePicker";
|
||
import type { InvoicePdfProps } from "../lib/pdfProps";
|
||
|
||
interface NewInvoiceProps {
|
||
settings: Settings;
|
||
onSettingsChange: () => void;
|
||
/** Whether this view is the one on screen. It stays mounted while hidden. */
|
||
active: boolean;
|
||
/** Ask the shell to show this view (used by Ctrl+N from another tab). */
|
||
onActivate: () => void;
|
||
}
|
||
|
||
const AUTOSAVE_MS = 1000;
|
||
|
||
const formatTime = (d: Date) => d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
|
||
const formatStamp = (iso: string) => {
|
||
const d = new Date(iso);
|
||
return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("en-GB")} ${formatTime(d)}`;
|
||
};
|
||
|
||
export default function NewInvoice({ settings, active, onActivate }: NewInvoiceProps) {
|
||
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
|
||
const blankState = (): InvoiceFormState => {
|
||
const t = todayIso();
|
||
return {
|
||
invoiceDate: t,
|
||
dueDate: addDays(t, settings.paymentTermsDays),
|
||
clientId: null,
|
||
clientName: "",
|
||
clientAddress: "",
|
||
clientGstin: "",
|
||
poNumber: "",
|
||
placeOfSupply: settings.vendorStateCode,
|
||
saveClient: false,
|
||
items: [newItem()],
|
||
discount: 0,
|
||
taxRate: settings.defaultTaxRate,
|
||
reverseCharge: false,
|
||
bankId: settings.defaultBankId,
|
||
signaturePath: settings.signaturePath,
|
||
notes: "",
|
||
};
|
||
};
|
||
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[]>([newItem()]);
|
||
const [discount, setDiscount] = useState(0);
|
||
|
||
const registered = settings.gstRegistration !== "unregistered";
|
||
const [reverseCharge, setReverseCharge] = useState(false);
|
||
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);
|
||
|
||
// Issued invoice shown read-only after a successful issue. Its number is consumed, so the
|
||
// form never issues again until the user starts a new one.
|
||
const [issued, setIssued] = useState<Invoice | null>(null);
|
||
const [exporting, setExporting] = useState(false);
|
||
const [exportedPath, setExportedPath] = useState<string | null>(null);
|
||
const [exportError, setExportError] = useState<string | null>(null);
|
||
const locked = issued !== null;
|
||
|
||
// Drafts.
|
||
const [draftId, setDraftId] = useState<number | null>(null);
|
||
const [baseline, setBaseline] = useState<string | null>(null);
|
||
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
|
||
const [savedAt, setSavedAt] = useState<Date | null>(null);
|
||
const [draftSaving, setDraftSaving] = useState(false);
|
||
const [draftError, setDraftError] = useState<string | null>(null);
|
||
const [drafts, setDrafts] = useState<DraftSummary[]>([]);
|
||
const [noticeOpen, setNoticeOpen] = useState(false);
|
||
const [draftsOpen, setDraftsOpen] = useState(false);
|
||
const [helpOpen, setHelpOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
(async () => {
|
||
try {
|
||
const [next, clientList, bankList, draftList] = await Promise.all([
|
||
api.peekNextInvoiceNumber(),
|
||
api.listClients(),
|
||
api.listBanks(),
|
||
api.listDrafts(),
|
||
]);
|
||
if (!active) return;
|
||
setNumber(next);
|
||
setClients(clientList);
|
||
setBanks(bankList);
|
||
setDrafts(draftList);
|
||
setNoticeOpen(draftList.length > 0);
|
||
} 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]);
|
||
|
||
// Rust derives the tax type from the supplier state and the place of supply; this
|
||
// mirrors that rule so the preview and the value sent for the cross-check agree.
|
||
const effectiveTaxType: TaxType = !registered
|
||
? "none"
|
||
: !placeOfSupply || placeOfSupply === settings.vendorStateCode
|
||
? "cgst_sgst"
|
||
: "igst";
|
||
const clientGstinError = gstinFullError(clientGstin);
|
||
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 payload = useMemo(
|
||
() =>
|
||
toDraftPayload({
|
||
invoiceDate,
|
||
dueDate,
|
||
clientId,
|
||
clientName,
|
||
clientAddress,
|
||
clientGstin,
|
||
poNumber,
|
||
placeOfSupply,
|
||
saveClient,
|
||
items,
|
||
discount,
|
||
taxRate,
|
||
reverseCharge,
|
||
bankId,
|
||
signaturePath,
|
||
notes,
|
||
}),
|
||
[
|
||
invoiceDate,
|
||
dueDate,
|
||
clientId,
|
||
clientName,
|
||
clientAddress,
|
||
clientGstin,
|
||
poNumber,
|
||
placeOfSupply,
|
||
saveClient,
|
||
items,
|
||
discount,
|
||
taxRate,
|
||
reverseCharge,
|
||
bankId,
|
||
signaturePath,
|
||
notes,
|
||
],
|
||
);
|
||
const snapshot = useMemo(() => JSON.stringify(payload), [payload]);
|
||
|
||
// The untouched form is the baseline; anything else is dirty. A pristine form is never saved.
|
||
useEffect(() => {
|
||
setBaseline((current) => current ?? snapshot);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
const dirty = baseline !== null && snapshot !== baseline;
|
||
const unsaved = !locked && dirty && snapshot !== savedSnapshot;
|
||
|
||
const latest = useRef({ snapshot, payload });
|
||
latest.current = { snapshot, payload };
|
||
const draftIdRef = useRef<number | null>(null);
|
||
const savedRef = useRef<string | null>(null);
|
||
const issuedRef = useRef(false);
|
||
const chain = useRef<Promise<void>>(Promise.resolve());
|
||
|
||
const refreshDrafts = useCallback(async () => {
|
||
try {
|
||
setDrafts(await api.listDrafts());
|
||
} catch {
|
||
/* the list is a convenience; ignore */
|
||
}
|
||
}, []);
|
||
|
||
const refreshNumber = async () => {
|
||
try {
|
||
setNumber(await api.peekNextInvoiceNumber());
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
};
|
||
|
||
// Saves are serialised so a slow first save cannot race a second one into creating two drafts.
|
||
const persistDraft = useCallback((): Promise<void> => {
|
||
const run = async () => {
|
||
if (issuedRef.current) return;
|
||
const { snapshot: snap, payload: body } = latest.current;
|
||
if (snap === savedRef.current) return;
|
||
setDraftSaving(true);
|
||
try {
|
||
const id = await api.saveDraft(draftIdRef.current, body);
|
||
draftIdRef.current = id;
|
||
savedRef.current = snap;
|
||
setDraftId(id);
|
||
setSavedSnapshot(snap);
|
||
setSavedAt(new Date());
|
||
setDraftError(null);
|
||
} finally {
|
||
setDraftSaving(false);
|
||
}
|
||
};
|
||
const next = chain.current.then(run);
|
||
chain.current = next.catch(() => {});
|
||
return next;
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!unsaved) return;
|
||
const timer = setTimeout(() => {
|
||
persistDraft()
|
||
.then(refreshDrafts)
|
||
.catch((e) => setDraftError(String(e)));
|
||
}, AUTOSAVE_MS);
|
||
return () => clearTimeout(timer);
|
||
}, [unsaved, snapshot, persistDraft, refreshDrafts]);
|
||
|
||
useEffect(() => {
|
||
if (!unsaved) return;
|
||
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
||
event.preventDefault();
|
||
event.returnValue = "";
|
||
};
|
||
window.addEventListener("beforeunload", onBeforeUnload);
|
||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||
}, [unsaved]);
|
||
|
||
const previewProps: InvoicePdfProps = {
|
||
vendor: settings,
|
||
bank,
|
||
logoDataUri: logoUri,
|
||
signatureDataUri: signatureUri,
|
||
number: issued?.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, newItem()]);
|
||
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 applyState = (s: InvoiceFormState) => {
|
||
setInvoiceDate(s.invoiceDate);
|
||
setDueDate(s.dueDate);
|
||
setClientId(s.clientId);
|
||
setClientName(s.clientName);
|
||
setClientAddress(s.clientAddress);
|
||
setClientGstin(s.clientGstin);
|
||
setPoNumber(s.poNumber);
|
||
setPlaceOfSupply(s.placeOfSupply);
|
||
setSaveClient(s.saveClient);
|
||
setItems(s.items);
|
||
setDiscount(s.discount);
|
||
setTaxRate(s.taxRate);
|
||
setReverseCharge(s.reverseCharge);
|
||
setBankId(s.bankId);
|
||
setSignaturePath(s.signaturePath);
|
||
setNotes(s.notes);
|
||
};
|
||
|
||
const clearIssued = () => {
|
||
issuedRef.current = false;
|
||
setIssued(null);
|
||
setExportedPath(null);
|
||
setExportError(null);
|
||
setError(null);
|
||
};
|
||
|
||
const detachDraft = () => {
|
||
draftIdRef.current = null;
|
||
savedRef.current = null;
|
||
setDraftId(null);
|
||
setSavedSnapshot(null);
|
||
setSavedAt(null);
|
||
setDraftError(null);
|
||
};
|
||
|
||
const resetForm = () => {
|
||
const blank = blankState();
|
||
applyState(blank);
|
||
setBaseline(JSON.stringify(toDraftPayload(blank)));
|
||
clearIssued();
|
||
detachDraft();
|
||
void refreshNumber();
|
||
api.listClients().then(setClients).catch(() => {});
|
||
void refreshDrafts();
|
||
};
|
||
|
||
// Keeps the work in a draft before it is replaced. Returns false if that was not possible
|
||
// and the user chose to keep the current form.
|
||
const parkCurrentWork = async (): Promise<boolean> => {
|
||
if (locked || !unsaved) return true;
|
||
try {
|
||
await persistDraft();
|
||
return true;
|
||
} catch (e) {
|
||
return confirm(`The current form could not be saved as a draft (${String(e)}). Discard it?`, {
|
||
title: "Unsaved changes",
|
||
kind: "warning",
|
||
});
|
||
}
|
||
};
|
||
|
||
const newInvoice = async () => {
|
||
onActivate();
|
||
if (!(await parkCurrentWork())) return;
|
||
resetForm();
|
||
};
|
||
|
||
const duplicateAsNew = () => {
|
||
const t = todayIso();
|
||
clearIssued();
|
||
detachDraft();
|
||
setInvoiceDate(t);
|
||
setDueDate(addDays(t, settings.paymentTermsDays));
|
||
setSaveClient(false);
|
||
void refreshNumber();
|
||
};
|
||
|
||
const saveDraftNow = async () => {
|
||
if (locked || !dirty) return;
|
||
try {
|
||
await persistDraft();
|
||
await refreshDrafts();
|
||
} catch (e) {
|
||
setDraftError(String(e));
|
||
}
|
||
};
|
||
|
||
const discardDraft = async () => {
|
||
const ok = await confirm(
|
||
draftId !== null
|
||
? "Discard this draft and clear the form?"
|
||
: "Clear the form? Nothing has been saved as a draft.",
|
||
{ title: "Discard draft", kind: "warning" },
|
||
);
|
||
if (!ok) return;
|
||
// Wait for any in-flight save so it cannot recreate the draft after the delete.
|
||
await chain.current;
|
||
const id = draftIdRef.current;
|
||
try {
|
||
if (id !== null) await api.deleteDraft(id);
|
||
} catch (e) {
|
||
setDraftError(String(e));
|
||
return;
|
||
}
|
||
resetForm();
|
||
};
|
||
|
||
const resumeDraft = async (id: number) => {
|
||
if (id === draftIdRef.current) {
|
||
setDraftsOpen(false);
|
||
return;
|
||
}
|
||
if (!(await parkCurrentWork())) return;
|
||
try {
|
||
const restored = fromDraftPayload(await api.getDraft(id), blankState());
|
||
if (restored.bankId !== null && !banks.some((b) => b.id === restored.bankId)) {
|
||
restored.bankId = null;
|
||
}
|
||
applyState(restored);
|
||
setBaseline(JSON.stringify(toDraftPayload(blankState())));
|
||
clearIssued();
|
||
const snap = JSON.stringify(toDraftPayload(restored));
|
||
draftIdRef.current = id;
|
||
savedRef.current = snap;
|
||
setDraftId(id);
|
||
setSavedSnapshot(snap);
|
||
setSavedAt(null);
|
||
setDraftError(null);
|
||
setNoticeOpen(false);
|
||
setDraftsOpen(false);
|
||
void refreshNumber();
|
||
void refreshDrafts();
|
||
} catch (e) {
|
||
setError(String(e));
|
||
}
|
||
};
|
||
|
||
const removeDraft = async (id: number) => {
|
||
try {
|
||
await api.deleteDraft(id);
|
||
} catch (e) {
|
||
setError(String(e));
|
||
}
|
||
await refreshDrafts();
|
||
};
|
||
|
||
const runExport = async (invoice: Invoice) => {
|
||
setExporting(true);
|
||
setExportError(null);
|
||
try {
|
||
const path = await exportInvoicePdf(invoice, settings, bank);
|
||
setExportedPath(path ?? null);
|
||
} catch (e) {
|
||
setExportError(String(e));
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
};
|
||
|
||
const onSave = async () => {
|
||
if (locked || saving) return;
|
||
if (clientGstinError) {
|
||
setError(`Client GSTIN: ${clientGstinError}`);
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
setError(null);
|
||
setExportedPath(null);
|
||
setExportError(null);
|
||
let invoice: Invoice;
|
||
try {
|
||
// Let a pending autosave land first so the issue call knows which draft to delete.
|
||
await chain.current;
|
||
issuedRef.current = true;
|
||
const input: InvoiceInput = {
|
||
invoiceDate,
|
||
dueDate,
|
||
clientId,
|
||
clientName,
|
||
clientAddress,
|
||
clientGstin,
|
||
poNumber,
|
||
placeOfSupplyStateCode: placeOfSupply,
|
||
discount,
|
||
taxType: effectiveTaxType,
|
||
taxRate,
|
||
reverseCharge: registered && reverseCharge,
|
||
bankAccountId: bankId,
|
||
signaturePath,
|
||
notes,
|
||
saveClient,
|
||
draftId: draftIdRef.current,
|
||
items: items.map((item, index) => ({ ...item, sortOrder: index })),
|
||
};
|
||
invoice = await api.issueInvoice(input, {});
|
||
} catch (e) {
|
||
issuedRef.current = false;
|
||
setError(String(e));
|
||
setSaving(false);
|
||
return;
|
||
}
|
||
// The number is consumed from here on. A failed export must never lead to a second issue.
|
||
setIssued(invoice);
|
||
detachDraft();
|
||
api.listClients().then(setClients).catch(() => {});
|
||
void refreshDrafts();
|
||
await runExport(invoice);
|
||
setSaving(false);
|
||
};
|
||
|
||
useShortcuts({
|
||
onNew: () => void newInvoice(),
|
||
onSaveDraft: () => active && void saveDraftNow(),
|
||
onIssue: () => active && !locked && void onSave(),
|
||
onAddLine: () => active && !locked && addItem(),
|
||
onHelp: () => active && setHelpOpen(true),
|
||
});
|
||
|
||
const otherDrafts = drafts.filter((d) => d.id !== draftId);
|
||
const busy = saving || exporting;
|
||
|
||
return (
|
||
<>
|
||
<div className="voiced-page" hidden={!active}>
|
||
<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">{locked ? `Invoice ${issued.number}` : "New invoice"}</h1>
|
||
<p className="voiced-page__subtitle" style={{ marginBottom: 0 }}>
|
||
{locked ? (
|
||
"Issued and read-only. Use the actions below to continue."
|
||
) : (
|
||
<>
|
||
Next number: <strong>{number || "—"}</strong>
|
||
</>
|
||
)}
|
||
</p>
|
||
</div>
|
||
{locked ? null : (
|
||
<div className="voiced-header-action" style={{ flexWrap: "wrap" }}>
|
||
{draftSaving ? (
|
||
<InlineLoading status="active" description="Saving draft…" />
|
||
) : savedAt && !unsaved ? (
|
||
<InlineLoading status="finished" description={`Draft saved ${formatTime(savedAt)}`} />
|
||
) : unsaved ? (
|
||
<Tag type="gray" size="sm">
|
||
Unsaved changes
|
||
</Tag>
|
||
) : null}
|
||
{drafts.length > 0 ? (
|
||
<Button kind="ghost" onClick={() => setDraftsOpen(true)} disabled={busy}>
|
||
{`Drafts (${drafts.length})`}
|
||
</Button>
|
||
) : null}
|
||
<Button kind="ghost" renderIcon={Keyboard} hasIconOnly iconDescription="Keyboard shortcuts (?)" tooltipPosition="bottom" onClick={() => setHelpOpen(true)} />
|
||
{dirty || draftId !== null ? (
|
||
<Button kind="ghost" onClick={discardDraft} disabled={busy}>
|
||
Discard draft
|
||
</Button>
|
||
) : null}
|
||
<Button kind="tertiary" renderIcon={Save} onClick={saveDraftNow} disabled={busy || !dirty}>
|
||
Save draft
|
||
</Button>
|
||
<Button renderIcon={Download} onClick={onSave} disabled={busy}>
|
||
{saving ? "Saving…" : "Save & export PDF"}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{error ? (
|
||
<InlineNotification
|
||
kind="error"
|
||
lowContrast
|
||
title="Could not save invoice"
|
||
subtitle={error}
|
||
onCloseButtonClick={() => setError(null)}
|
||
/>
|
||
) : null}
|
||
{draftError ? (
|
||
<InlineNotification
|
||
kind="warning"
|
||
lowContrast
|
||
title="Draft not saved"
|
||
subtitle={draftError}
|
||
onCloseButtonClick={() => setDraftError(null)}
|
||
/>
|
||
) : null}
|
||
{!locked && noticeOpen && !dirty && otherDrafts.length > 0 ? (
|
||
<ActionableNotification
|
||
inline
|
||
kind="info"
|
||
lowContrast
|
||
title={`You have ${otherDrafts.length} unfinished draft${otherDrafts.length === 1 ? "" : "s"}`}
|
||
subtitle="Pick up where you left off."
|
||
actionButtonLabel="Resume latest"
|
||
onActionButtonClick={() => void resumeDraft(otherDrafts[0].id)}
|
||
onCloseButtonClick={() => setNoticeOpen(false)}
|
||
/>
|
||
) : null}
|
||
{locked ? (
|
||
<>
|
||
{exportError ? (
|
||
<InlineNotification
|
||
kind="error"
|
||
lowContrast
|
||
hideCloseButton
|
||
title={`Invoice ${issued.number} was issued, but the PDF export failed`}
|
||
subtitle={`${exportError} The number is already used; retry the export, do not issue again.`}
|
||
/>
|
||
) : exportedPath ? (
|
||
<InlineNotification
|
||
kind="success"
|
||
lowContrast
|
||
hideCloseButton
|
||
title={`Invoice ${issued.number} issued`}
|
||
subtitle={exportedPath}
|
||
/>
|
||
) : exporting ? (
|
||
<InlineLoading description="Exporting PDF…" />
|
||
) : (
|
||
<InlineNotification
|
||
kind="info"
|
||
lowContrast
|
||
hideCloseButton
|
||
title={`Invoice ${issued.number} issued`}
|
||
subtitle="The PDF was not saved. Use Export again to save it."
|
||
/>
|
||
)}
|
||
<div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}>
|
||
<Button
|
||
kind={exportError ? "primary" : "tertiary"}
|
||
renderIcon={exportError ? Renew : Download}
|
||
onClick={() => void runExport(issued)}
|
||
disabled={exporting}
|
||
>
|
||
{exporting ? "Exporting…" : exportError ? "Retry export" : "Export again"}
|
||
</Button>
|
||
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={exporting}>
|
||
Duplicate as new
|
||
</Button>
|
||
<Button kind="tertiary" renderIcon={DocumentAdd} onClick={() => void newInvoice()} disabled={exporting}>
|
||
New invoice
|
||
</Button>
|
||
</div>
|
||
</>
|
||
) : 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"
|
||
disabled={locked}
|
||
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"
|
||
disabled={locked}
|
||
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={issued?.number ?? number} readOnly />
|
||
</Column>
|
||
|
||
<Column sm={4} md={8} lg={8}>
|
||
<Dropdown
|
||
id="savedClient"
|
||
disabled={locked}
|
||
titleText="Saved client (optional)"
|
||
label="Select a saved client to autofill"
|
||
items={clients}
|
||
itemToString={(item) => (item ? item.name : "")}
|
||
selectedItem={clients.find((c) => c.id === clientId) ?? null}
|
||
onChange={({ selectedItem }) => selectedItem && applyClient(selectedItem)}
|
||
/>
|
||
</Column>
|
||
<Column sm={4} md={8} lg={8}>
|
||
<TextInput
|
||
id="clientName"
|
||
disabled={locked}
|
||
labelText="Client name"
|
||
value={clientName}
|
||
onChange={(e) => {
|
||
setClientName(e.target.value);
|
||
setClientId(null);
|
||
}}
|
||
/>
|
||
</Column>
|
||
<Column sm={4} md={8} lg={16}>
|
||
<TextArea
|
||
id="clientAddress"
|
||
disabled={locked}
|
||
labelText="Client address"
|
||
rows={3}
|
||
value={clientAddress}
|
||
onChange={(e) => setClientAddress(e.target.value)}
|
||
/>
|
||
</Column>
|
||
<Column sm={4} md={4} lg={5}>
|
||
<TextInput
|
||
id="clientGstin"
|
||
disabled={locked}
|
||
labelText="Client GSTIN (optional)"
|
||
value={clientGstin}
|
||
onChange={(e) => setClientGstin(e.target.value.toUpperCase())}
|
||
invalid={Boolean(clientGstinError)}
|
||
invalidText={clientGstinError}
|
||
/>
|
||
</Column>
|
||
<Column sm={4} md={4} lg={5}>
|
||
<TextInput
|
||
id="poNumber"
|
||
disabled={locked}
|
||
labelText="PO number (optional)"
|
||
value={poNumber}
|
||
onChange={(e) => setPoNumber(e.target.value)}
|
||
/>
|
||
</Column>
|
||
<Column sm={4} md={8} lg={6}>
|
||
<Select
|
||
id="placeOfSupply"
|
||
disabled={locked}
|
||
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"
|
||
disabled={locked}
|
||
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}`}
|
||
disabled={locked}
|
||
labelText="Particulars / description"
|
||
value={item.description}
|
||
onChange={(e) => updateItem(index, { description: e.target.value })}
|
||
/>
|
||
</div>
|
||
{registered ? (
|
||
<div style={{ flex: "0 1 120px" }}>
|
||
<TextInput
|
||
id={`hsn-${index}`}
|
||
disabled={locked}
|
||
labelText="HSN/SAC"
|
||
value={item.hsnSac ?? ""}
|
||
onChange={(e) => updateItem(index, { hsnSac: e.target.value })}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<div style={{ flex: "0 1 150px" }}>
|
||
<Select
|
||
id={`mode-${index}`}
|
||
disabled={locked}
|
||
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}`}
|
||
disabled={locked}
|
||
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}`}
|
||
disabled={locked}
|
||
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}`}
|
||
disabled={locked}
|
||
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}`}
|
||
disabled={locked}
|
||
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={locked || items.length === 1}
|
||
/>
|
||
</div>
|
||
))}
|
||
<Button kind="tertiary" size="sm" renderIcon={Add} onClick={addItem} disabled={locked}>
|
||
Add line
|
||
</Button>
|
||
</div>
|
||
|
||
<Grid narrow>
|
||
<Column sm={4} md={4} lg={8}>
|
||
<NumberInput
|
||
id="discount"
|
||
disabled={locked}
|
||
label="Discount (₹)"
|
||
min={0}
|
||
step={1}
|
||
value={discount}
|
||
onChange={(_e, { value }) => setDiscount(Number(value) || 0)}
|
||
/>
|
||
</Column>
|
||
{registered ? (
|
||
<>
|
||
<Column sm={4} md={8} lg={16}>
|
||
<p className="voiced-page__subtitle" style={{ marginBottom: 0 }}>
|
||
{effectiveTaxType === "igst"
|
||
? "IGST (inter-state supply)"
|
||
: "CGST + SGST (supply within the supplier's state)"}
|
||
</p>
|
||
</Column>
|
||
<Column sm={4} md={4} lg={8}>
|
||
<NumberInput
|
||
id="taxRate"
|
||
disabled={locked}
|
||
label="Tax rate (%)"
|
||
min={0}
|
||
max={100}
|
||
step={0.5}
|
||
value={taxRate}
|
||
onChange={(_e, { value }) => setTaxRate(Number(value) || 0)}
|
||
/>
|
||
</Column>
|
||
<Column sm={4} md={8} lg={16}>
|
||
<Toggle
|
||
id="reverseCharge"
|
||
disabled={locked}
|
||
labelText="Tax payable on reverse charge"
|
||
toggled={reverseCharge}
|
||
onToggle={setReverseCharge}
|
||
/>
|
||
</Column>
|
||
</>
|
||
) : null}
|
||
</Grid>
|
||
|
||
<Select
|
||
id="bankAccount"
|
||
disabled={locked}
|
||
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}
|
||
disabled={locked}
|
||
/>
|
||
|
||
<TextArea
|
||
id="notes"
|
||
disabled={locked}
|
||
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>
|
||
|
||
<Modal
|
||
open={draftsOpen}
|
||
passiveModal
|
||
size="md"
|
||
modalHeading="Unfinished drafts"
|
||
onRequestClose={() => setDraftsOpen(false)}
|
||
>
|
||
<Table size="md" aria-label="Drafts">
|
||
<TableHead>
|
||
<TableRow>
|
||
<TableHeader>Client</TableHeader>
|
||
<TableHeader>Last edited</TableHeader>
|
||
<TableHeader>Actions</TableHeader>
|
||
</TableRow>
|
||
</TableHead>
|
||
<TableBody>
|
||
{drafts.map((d) => (
|
||
<TableRow key={d.id}>
|
||
<TableCell>{d.clientName || "—"}</TableCell>
|
||
<TableCell>{formatStamp(d.updatedAt)}</TableCell>
|
||
<TableCell>
|
||
{d.id === draftId ? (
|
||
<Tag type="blue" size="sm">
|
||
Open now
|
||
</Tag>
|
||
) : (
|
||
<div className="voiced-header-action">
|
||
<Button kind="ghost" size="sm" onClick={() => void resumeDraft(d.id)}>
|
||
Resume
|
||
</Button>
|
||
<Button
|
||
kind="ghost"
|
||
size="sm"
|
||
hasIconOnly
|
||
renderIcon={TrashCan}
|
||
iconDescription="Delete draft"
|
||
onClick={() => void removeDraft(d.id)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
{drafts.length === 0 ? <p style={{ marginTop: "1rem" }}>No drafts.</p> : null}
|
||
</Modal>
|
||
|
||
<Modal
|
||
open={helpOpen}
|
||
passiveModal
|
||
size="sm"
|
||
modalHeading="Keyboard shortcuts"
|
||
onRequestClose={() => setHelpOpen(false)}
|
||
>
|
||
<Table size="md" aria-label="Keyboard shortcuts">
|
||
<TableHead>
|
||
<TableRow>
|
||
<TableHeader>Keys</TableHeader>
|
||
<TableHeader>Action</TableHeader>
|
||
</TableRow>
|
||
</TableHead>
|
||
<TableBody>
|
||
{SHORTCUT_LIST.map((row) => (
|
||
<TableRow key={row.keys}>
|
||
<TableCell>
|
||
<strong>{row.keys}</strong>
|
||
</TableCell>
|
||
<TableCell>{row.action}</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</Modal>
|
||
</>
|
||
);
|
||
}
|