Files
Voiced/src/views/NewInvoice.tsx
T
xavierk afbc0aa56f Add font import for Now, Gotham and Open Sauce One overrides; package fonts for Void
Font import:
- Migration M6 adds user_fonts; files are stored content-addressed under
  the local data dir. New Rust commands inspect, import, list, read and
  remove fonts, refusing WOFF2, collections, variable fonts and
  restricted fsType, and unpacking WOFF1 to plain sfnt.
- Imported fonts register in the render worker as user:<Face> families
  with an italic alias, and are pinned in the frozen render prefs so
  issued invoices can still re-render. Fonts referenced by issued
  invoices are hidden, never deleted.
- Size and tracking compensations tuned for the substitutes apply only
  while the face is not overridden; default output and fingerprint
  goldens are unchanged.
- Settings gets a Fonts section with a licence acknowledgement.

Void packaging:
- Install fonts to /usr/share/fonts/voiced with per-family OFL licences,
  set font_dirs, and add INSTALL/REMOVE scripts that run fc-cache.
  xbps-create carries both scripts (verified in a void-glibc container).
- Fix xbps-create dependency specs in build-xbps.sh.
2026-10-04 13:58:06 +05:30

1140 lines
40 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 { lazy, Suspense, 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,
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 { modelFromAssets, prefsForInvoice } from "../lib/pdf";
import { formToPreviewInvoice } from "../lib/previewInvoice";
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
import PageSetupControls from "../components/PageSetupControls";
import { useInvoicePdf } from "../hooks/useInvoicePdf";
import { useInvoiceExport } from "../hooks/useInvoiceExport";
import ExportButton, { ExportModePicker } from "../components/ExportButton";
import ExportFeedback from "../components/ExportFeedback";
import { createExportDeps } from "../lib/exportDeps";
import { issueAndArchive, renderAndArchive } from "../lib/exportFlow";
import { loadExportMode } from "../lib/exportMode";
import type { ExportMode } from "../lib/filename";
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
import { useUserFonts } from "../hooks/useUserFonts";
import { withUserFonts } from "../lib/fontImport";
import { ImagePicker } from "../components/ImagePicker";
import { loadLogoImages, type LogoImages } from "../lib/logo";
// pdf.js is only needed once a preview exists, so it loads on demand.
const PdfPreview = lazy(() => import("../components/PdfPreview"));
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, onSettingsChange, 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 [logoImages, setLogoImages] = useState<LogoImages | 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);
// What the backend recorded when the issued PDF was archived; null until that worked.
const [archive, setArchive] = useState<{ sha256: string; fingerprint: string } | null>(null);
const [archiveError, setArchiveError] = useState<string | null>(null);
const [archiving, setArchiving] = useState(false);
const [exportMode, setExportMode] = useState<ExportMode>(loadExportMode);
const locked = issued !== null;
// The issued invoice as the export flow needs it: with its archive fields once archived.
const exportable = useMemo<Invoice | null>(
() => (issued ? { ...issued, archivedPdfSha256: archive?.sha256 ?? issued.archivedPdfSha256, archivedFingerprint: archive?.fingerprint ?? issued.archivedFingerprint ?? null } : null),
[issued, archive],
);
// 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) {
setLogoImages(null);
return;
}
loadLogoImages(settings, api.readAssetDataUri)
.then((images) => active && setLogoImages(images))
.catch(() => {});
return () => {
active = false;
};
}, [settings.logoPath, settings.logoKnockoutPath]);
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]);
// Page setup for the invoice being edited; an issued invoice shows what was frozen at issue.
const [prefs, setPrefs] = useState<RenderPrefsV1>(() => parsePrefs(settings.renderPrefs));
useEffect(() => setPrefs(parsePrefs(settings.renderPrefs)), [settings.renderPrefs]);
// setupPrefs is the page setup as edited (and saved as the default); activePrefs adds the imported fonts the template
// draws, which is what renders and what is frozen on the invoice at issue. An issued invoice keeps its frozen fonts.
const userFonts = useUserFonts();
const setupPrefs = useMemo(() => (issued ? prefsForInvoice(issued, settings) : prefs), [issued, prefs, settings]);
const activePrefs = useMemo(() => (issued ? setupPrefs : withUserFonts(setupPrefs, userFonts)), [issued, setupPrefs, userFonts]);
// The preview renders the real PDF from a draft-status Invoice built from the form. It is skipped
// while this view is hidden (it stays mounted) so typing elsewhere never starts a render.
const previewModel = useMemo(() => {
if (!active) return null;
const invoice =
issued ??
formToPreviewInvoice(
{ invoiceDate, dueDate, clientId, clientName, clientAddress, clientGstin, poNumber, placeOfSupply, discount, taxRate, reverseCharge, signaturePath, notes, items },
number || "DRAFT",
settings,
bank,
);
return modelFromAssets(invoice, settings, bank, logoImages, signatureUri);
}, [active, issued, invoiceDate, dueDate, clientId, clientName, clientAddress, clientGstin, poNumber, placeOfSupply, discount, taxRate, reverseCharge, signaturePath, notes, items, number, settings, bank, logoImages, signatureUri]);
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);
setArchive(null);
setArchiveError(null);
exp.dismiss();
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 noteArchived = (invoice: Invoice) => {
if (invoice.archivedPdfSha256) {
setArchive({ sha256: invoice.archivedPdfSha256, fingerprint: invoice.archivedFingerprint ?? "" });
setArchiveError(null);
}
};
const exp = useInvoiceExport({ settings, bank, onInvoiceUpdated: noteArchived });
const retryArchive = async () => {
if (!exportable || archiving) return;
setArchiving(true);
const outcome = await renderAndArchive(exportable, createExportDeps({ settings, bank }));
if (outcome.archived) noteArchived(outcome.invoice);
else setArchiveError(outcome.error ?? "The PDF could not be archived.");
setArchiving(false);
};
const onSave = async () => {
if (locked || saving) return;
if (clientGstinError) {
setError(`Client GSTIN: ${clientGstinError}`);
return;
}
setSaving(true);
setError(null);
exp.dismiss();
const mode = loadExportMode();
let outcome: Awaited<ReturnType<typeof issueAndArchive>>;
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 })),
};
outcome = await issueAndArchive(input, activePrefs, createExportDeps({ settings, bank }));
} catch (e) {
issuedRef.current = false;
setError(String(e));
setSaving(false);
return;
}
// The number is consumed from here on. A failed archive or export must never lead to a second issue.
setIssued(outcome.invoice);
if (outcome.archived) noteArchived(outcome.invoice);
else setArchiveError(outcome.error ?? "The PDF could not be archived.");
detachDraft();
api.listClients().then(setClients).catch(() => {});
void refreshDrafts();
// Unarchived invoices export by rendering and archiving first (see exportInvoice), so this still works.
await exp.start(outcome.invoice, mode, "original");
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 || exp.busy || archiving;
// Yield the shared render worker to the export; the model changes back when it is done and re-renders.
const pdf = useInvoicePdf({ model: busy ? null : previewModel, prefs: activePrefs });
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>
<div className="voiced-export-split">
<Button renderIcon={Download} onClick={onSave} disabled={busy} title={`Issues the invoice and exports the ${exportMode} PDF`}>
{saving ? "Issuing…" : "Issue & export"}
</Button>
<ExportModePicker mode={exportMode} onChange={setExportMode} disabled={busy} />
</div>
</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 && exportable ? (
<>
{!exportable.archivedPdfSha256 ? (
<ActionableNotification
inline
kind="warning"
lowContrast
hideCloseButton
title="Issued — PDF not archived yet"
subtitle={`${archiveError ?? "The PDF was not archived."} The invoice is safe; it is not issued again. You can still export.`}
actionButtonLabel={archiving ? "Archiving…" : "Render & archive"}
onActionButtonClick={() => void retryArchive()}
/>
) : null}
<ExportFeedback exp={exp} />
<div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}>
<ExportButton onExport={(mode) => void exp.start(exportable, mode, "original")} disabled={busy} />
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={busy}>
Duplicate as new
</Button>
<Button kind="tertiary" renderIcon={DocumentAdd} onClick={() => void newInvoice()} disabled={busy}>
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>
<PageSetupControls prefs={setupPrefs} onChange={setPrefs} settings={settings} onSaved={onSettingsChange} disabled={locked} />
<Suspense fallback={<InlineLoading description="Loading preview…" />}>
<PdfPreview
bytes={pdf.bytes}
pages={pdf.pages}
issues={pdf.issues}
rendering={pdf.rendering}
mode={pdf.mode}
error={pdf.error}
/>
</Suspense>
</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>
</>
);
}