Add invoice drafts, read-only issued state and keyboard shortcuts

Views stay mounted so switching tabs no longer loses a half-typed invoice.
The form autosaves a draft after 1 s, drafts can be resumed or deleted,
and after issuing the form is read-only with Export again, Duplicate and
New invoice actions. A failed export keeps the issued invoice and offers a
retry instead of issuing again. Adds Ctrl+N/S/Enter/L and a shortcut sheet;
F5 and Ctrl+R are blocked in production builds.
This commit is contained in:
2026-10-04 04:21:47 +05:30
parent a00c816635
commit 12d80efcb8
8 changed files with 859 additions and 62 deletions
+13 -3
View File
@@ -88,9 +88,19 @@ export default function App() {
return ( return (
<Theme theme={theme}> <Theme theme={theme}>
<AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}> <AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}>
{view === "new" && <NewInvoice settings={settings} onSettingsChange={refreshSettings} />} {/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
{view === "invoices" && <InvoiceHistory settings={settings} />} <NewInvoice
{view === "series" && <SeriesSettings />} settings={settings}
onSettingsChange={refreshSettings}
active={view === "new"}
onActivate={() => setView("new")}
/>
<div hidden={view !== "invoices"}>
<InvoiceHistory settings={settings} active={view === "invoices"} />
</div>
<div hidden={view !== "series"}>
<SeriesSettings active={view === "series"} />
</div>
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />} {view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
</AppShell> </AppShell>
</Theme> </Theme>
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useRef } from "react";
export interface ShortcutHandlers {
onNew?: () => void;
onSaveDraft?: () => void;
onIssue?: () => void;
onAddLine?: () => void;
onHelp?: () => void;
}
const isTyping = (target: EventTarget | null) => {
const el = target as HTMLElement | null;
if (!el) return false;
return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable;
};
/**
* Global keyboard shortcuts. Ctrl combinations work everywhere; the bare `?` is ignored
* while typing. F5 / Ctrl+R are blocked in production builds because a webview reload
* would discard in-memory state.
*/
export function useShortcuts(handlers: ShortcutHandlers) {
const ref = useRef(handlers);
ref.current = handlers;
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
const ctrl = event.ctrlKey || event.metaKey;
const key = event.key.toLowerCase();
if (import.meta.env.PROD && (event.key === "F5" || (ctrl && key === "r"))) {
event.preventDefault();
return;
}
const run = (fn?: () => void) => {
event.preventDefault();
fn?.();
};
if (ctrl && !event.shiftKey && !event.altKey) {
if (key === "n") return run(ref.current.onNew);
if (key === "s") return run(ref.current.onSaveDraft);
if (key === "l") return run(ref.current.onAddLine);
if (key === "enter") return run(ref.current.onIssue);
}
if (!ctrl && !event.altKey && event.key === "?" && !isTyping(event.target)) {
run(ref.current.onHelp);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
}
export const SHORTCUT_LIST: { keys: string; action: string }[] = [
{ keys: "Ctrl + N", action: "New invoice" },
{ keys: "Ctrl + S", action: "Save draft" },
{ keys: "Ctrl + Enter", action: "Issue and export PDF" },
{ keys: "Ctrl + L", action: "Add line item" },
{ keys: "?", action: "Show this list" },
];
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "./draft";
import { EMPTY_ITEM } from "./types";
const defaults: InvoiceFormState = {
invoiceDate: "2026-01-01",
dueDate: "2026-01-31",
clientId: null,
clientName: "",
clientAddress: "",
clientGstin: "",
poNumber: "",
placeOfSupply: "29",
saveClient: false,
items: [{ ...EMPTY_ITEM, hsnSac: "9983" }],
discount: 0,
taxRate: 18,
reverseCharge: false,
bankId: 2,
signaturePath: "/sig/default.png",
notes: "",
};
const filled: InvoiceFormState = {
invoiceDate: "2026-03-05",
dueDate: "2026-04-04",
clientId: 7,
clientName: "Acme Pvt Ltd",
clientAddress: "1 Main Rd\nKolkata",
clientGstin: "19AAAAA0000A1Z5",
poNumber: "PO-9",
placeOfSupply: "19",
saveClient: true,
items: [
{ ...EMPTY_ITEM, description: "Design", mode: "rate", rate: 500, unit: "hour", quantity: 4, hsnSac: "9983" },
{ ...EMPTY_ITEM, description: "Fee", amount: 1200, sortOrder: 1, hsnSac: "9983" },
],
discount: 100,
taxRate: 12,
reverseCharge: true,
bankId: null,
signaturePath: null,
notes: "Thanks",
};
describe("draft payload", () => {
it("round-trips through JSON", () => {
const json = JSON.parse(JSON.stringify(toDraftPayload(filled)));
expect(fromDraftPayload(json, defaults)).toEqual(filled);
});
it("does not store derived totals", () => {
const keys = Object.keys(toDraftPayload(filled));
for (const k of ["total", "subtotal", "cgst", "sgst", "igst", "amountInWords"]) {
expect(keys).not.toContain(k);
}
});
it("falls back to defaults for missing fields", () => {
const out = fromDraftPayload({ clientName: "Only name" }, defaults);
expect(out).toEqual({ ...defaults, clientName: "Only name" });
});
it("tolerates garbage input and partial items", () => {
expect(fromDraftPayload(null, defaults)).toEqual(defaults);
expect(fromDraftPayload("x", defaults)).toEqual(defaults);
const out = fromDraftPayload({ items: [{ description: "A" }], discount: "oops" }, defaults);
expect(out.discount).toBe(0);
expect(out.items).toHaveLength(1);
expect(out.items[0]).toMatchObject({ description: "A", mode: "fixed", quantity: 1, hsnSac: "" });
});
});
+92
View File
@@ -0,0 +1,92 @@
import { EMPTY_ITEM } from "./types";
import type { InvoiceItem, LineMode, LineUnit } from "./types";
/** Everything that defines an invoice on the New invoice form. Derived totals are not part of it. */
export interface InvoiceFormState {
invoiceDate: string;
dueDate: string;
clientId: number | null;
clientName: string;
clientAddress: string;
clientGstin: string;
poNumber: string;
placeOfSupply: string;
saveClient: boolean;
items: InvoiceItem[];
discount: number;
taxRate: number;
reverseCharge: boolean;
bankId: number | null;
signaturePath: string | null;
notes: string;
}
const str = (v: unknown, fallback: string) => (typeof v === "string" ? v : fallback);
const num = (v: unknown, fallback: number) =>
typeof v === "number" && Number.isFinite(v) ? v : fallback;
const bool = (v: unknown, fallback: boolean) => (typeof v === "boolean" ? v : fallback);
const nullableNum = (v: unknown, fallback: number | null) =>
v === null ? null : typeof v === "number" && Number.isFinite(v) ? v : fallback;
function itemFromJson(raw: unknown, index: number): InvoiceItem {
const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
return {
description: str(r.description, ""),
mode: (r.mode === "rate" ? "rate" : "fixed") as LineMode,
rate: num(r.rate, EMPTY_ITEM.rate),
unit: str(r.unit, EMPTY_ITEM.unit) as LineUnit,
quantity: num(r.quantity, EMPTY_ITEM.quantity),
amount: num(r.amount, EMPTY_ITEM.amount),
sortOrder: index,
hsnSac: str(r.hsnSac, ""),
};
}
export function toDraftPayload(state: InvoiceFormState): Record<string, unknown> {
return {
version: 1,
invoiceDate: state.invoiceDate,
dueDate: state.dueDate,
clientId: state.clientId,
clientName: state.clientName,
clientAddress: state.clientAddress,
clientGstin: state.clientGstin,
poNumber: state.poNumber,
placeOfSupply: state.placeOfSupply,
saveClient: state.saveClient,
items: state.items.map((item, index) => ({ ...item, sortOrder: index })),
discount: state.discount,
taxRate: state.taxRate,
reverseCharge: state.reverseCharge,
bankId: state.bankId,
signaturePath: state.signaturePath,
notes: state.notes,
};
}
/** Tolerant: any missing or wrongly typed field falls back to `defaults`. */
export function fromDraftPayload(json: unknown, defaults: InvoiceFormState): InvoiceFormState {
const r = (json && typeof json === "object" ? json : {}) as Record<string, unknown>;
const items = Array.isArray(r.items) && r.items.length > 0 ? r.items.map(itemFromJson) : defaults.items;
return {
invoiceDate: str(r.invoiceDate, defaults.invoiceDate),
dueDate: str(r.dueDate, defaults.dueDate),
clientId: nullableNum(r.clientId, defaults.clientId),
clientName: str(r.clientName, defaults.clientName),
clientAddress: str(r.clientAddress, defaults.clientAddress),
clientGstin: str(r.clientGstin, defaults.clientGstin),
poNumber: str(r.poNumber, defaults.poNumber),
placeOfSupply: str(r.placeOfSupply, defaults.placeOfSupply),
saveClient: bool(r.saveClient, defaults.saveClient),
items,
discount: num(r.discount, defaults.discount),
taxRate: num(r.taxRate, defaults.taxRate),
reverseCharge: bool(r.reverseCharge, defaults.reverseCharge),
bankId: nullableNum(r.bankId, defaults.bankId),
signaturePath:
r.signaturePath === null || typeof r.signaturePath === "string"
? r.signaturePath
: defaults.signaturePath,
notes: str(r.notes, defaults.notes),
};
}
+5
View File
@@ -143,6 +143,11 @@ body {
gap: 0.5rem; gap: 0.5rem;
} }
// Views stay mounted while hidden so their state survives navigation.
[hidden] {
display: none !important;
}
.voiced-page { .voiced-page {
padding: 2rem; padding: 2rem;
max-width: 1600px; max-width: 1600px;
+4 -3
View File
@@ -20,7 +20,7 @@ import { formatAmount, formatDate } from "../lib/format";
import { exportInvoicePdf } from "../lib/pdf"; import { exportInvoicePdf } from "../lib/pdf";
import type { InvoiceSummary, Settings } from "../lib/types"; import type { InvoiceSummary, Settings } from "../lib/types";
export default function InvoiceHistory({ settings }: { settings: Settings }) { export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) {
const [rows, setRows] = useState<InvoiceSummary[]>([]); const [rows, setRows] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -39,9 +39,10 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
} }
}; };
// The view stays mounted, so refetch every time it is shown again.
useEffect(() => { useEffect(() => {
void load(); if (active) void load();
}, []); }, [active]);
const onExport = async (id: number) => { const onExport = async (id: number) => {
setBusyId(id); setBusyId(id);
+601 -51
View File
@@ -1,23 +1,45 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
ActionableNotification,
Button, Button,
Column, Column,
Dropdown, Dropdown,
Grid, Grid,
InlineLoading,
InlineNotification, InlineNotification,
Modal,
NumberInput, NumberInput,
Select, Select,
SelectItem, SelectItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tag,
TextArea, TextArea,
TextInput, TextInput,
Toggle, Toggle,
} from "@carbon/react"; } from "@carbon/react";
import { Add, Save, TrashCan } from "@carbon/icons-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 { api } from "../lib/api";
import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types"; import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
import type { import type {
BankAccount, BankAccount,
Client, Client,
DraftSummary,
Invoice,
InvoiceInput, InvoiceInput,
InvoiceItem, InvoiceItem,
LineMode, LineMode,
@@ -30,6 +52,8 @@ import { amountInWords } from "../lib/numberToWords";
import { gstinFullError } from "../lib/validators"; import { gstinFullError } from "../lib/validators";
import { addDays, formatAmount, todayIso } from "../lib/format"; import { addDays, formatAmount, todayIso } from "../lib/format";
import { buildPdfProps, exportPdfFromProps } from "../lib/pdf"; import { buildPdfProps, exportPdfFromProps } from "../lib/pdf";
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
import { InvoicePreview } from "../components/InvoicePreview"; import { InvoicePreview } from "../components/InvoicePreview";
import { ImagePicker } from "../components/ImagePicker"; import { ImagePicker } from "../components/ImagePicker";
import type { InvoicePdfProps } from "../pdf/InvoiceDocument"; import type { InvoicePdfProps } from "../pdf/InvoiceDocument";
@@ -37,10 +61,43 @@ import type { InvoicePdfProps } from "../pdf/InvoiceDocument";
interface NewInvoiceProps { interface NewInvoiceProps {
settings: Settings; settings: Settings;
onSettingsChange: () => void; 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;
} }
export default function NewInvoice({ settings }: NewInvoiceProps) { 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 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 today = todayIso();
const [invoiceDate, setInvoiceDate] = useState(today); const [invoiceDate, setInvoiceDate] = useState(today);
const [dueDate, setDueDate] = useState(addDays(today, settings.paymentTermsDays)); const [dueDate, setDueDate] = useState(addDays(today, settings.paymentTermsDays));
@@ -72,21 +129,43 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = 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(() => { useEffect(() => {
let active = true; let active = true;
(async () => { (async () => {
try { try {
const [next, clientList, bankList] = await Promise.all([ const [next, clientList, bankList, draftList] = await Promise.all([
api.peekNextInvoiceNumber(), api.peekNextInvoiceNumber(),
api.listClients(), api.listClients(),
api.listBanks(), api.listBanks(),
api.listDrafts(),
]); ]);
if (!active) return; if (!active) return;
setNumber(next); setNumber(next);
setClients(clientList); setClients(clientList);
setBanks(bankList); setBanks(bankList);
setDrafts(draftList);
setNoticeOpen(draftList.length > 0);
} catch (e) { } catch (e) {
if (active) setError(String(e)); if (active) setError(String(e));
} }
@@ -135,12 +214,128 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
const words = useMemo(() => amountInWords(totals.total), [totals.total]); const words = useMemo(() => amountInWords(totals.total), [totals.total]);
const bank = useMemo(() => banks.find((b) => b.id === bankId) ?? null, [banks, bankId]); 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 = { const previewProps: InvoicePdfProps = {
vendor: settings, vendor: settings,
bank, bank,
logoDataUri: logoUri, logoDataUri: logoUri,
signatureDataUri: signatureUri, signatureDataUri: signatureUri,
number: number || "DRAFT", number: issued?.number ?? (number || "DRAFT"),
invoiceDate, invoiceDate,
dueDate, dueDate,
clientName, clientName,
@@ -177,35 +372,182 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
if (selected.stateCode) setPlaceOfSupply(selected.stateCode); if (selected.stateCode) setPlaceOfSupply(selected.stateCode);
}; };
const resetForNext = async () => { const applyState = (s: InvoiceFormState) => {
setClientId(null); setInvoiceDate(s.invoiceDate);
setClientName(""); setDueDate(s.dueDate);
setClientAddress(""); setClientId(s.clientId);
setClientGstin(""); setClientName(s.clientName);
setPoNumber(""); setClientAddress(s.clientAddress);
setSaveClient(false); setClientGstin(s.clientGstin);
setItems([newItem()]); setPoNumber(s.poNumber);
setDiscount(0); setPlaceOfSupply(s.placeOfSupply);
setNotes(""); setSaveClient(s.saveClient);
setInvoiceDate(todayIso()); setItems(s.items);
setDueDate(addDays(todayIso(), settings.paymentTermsDays)); 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 { try {
setNumber(await api.peekNextInvoiceNumber()); await persistDraft();
setClients(await api.listClients()); return true;
} catch { } catch (e) {
/* ignore */ 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 props = await buildPdfProps(invoice, settings, bank);
const path = await exportPdfFromProps(props, invoice.number);
setExportedPath(path ?? null);
} catch (e) {
setExportError(String(e));
} finally {
setExporting(false);
} }
}; };
const onSave = async () => { const onSave = async () => {
if (locked || saving) return;
if (clientGstinError) { if (clientGstinError) {
setError(`Client GSTIN: ${clientGstinError}`); setError(`Client GSTIN: ${clientGstinError}`);
return; return;
} }
setSaving(true); setSaving(true);
setError(null); setError(null);
setSaved(null); setExportedPath(null);
setExportError(null);
let invoice: Invoice;
try { 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 = { const input: InvoiceInput = {
invoiceDate, invoiceDate,
dueDate, dueDate,
@@ -223,53 +565,162 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
signaturePath, signaturePath,
notes, notes,
saveClient, saveClient,
draftId: null, draftId: draftIdRef.current,
items: items.map((item, index) => ({ ...item, sortOrder: index })), items: items.map((item, index) => ({ ...item, sortOrder: index })),
}; };
const invoice = await api.issueInvoice(input, {}); invoice = await api.issueInvoice(input, {});
const props = await buildPdfProps(invoice, settings, bank);
const path = await exportPdfFromProps(props, invoice.number);
if (path) setSaved(path);
await resetForNext();
} catch (e) { } catch (e) {
issuedRef.current = false;
setError(String(e)); setError(String(e));
} finally {
setSaving(false); 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 ( return (
<div className="voiced-page"> <>
<div className="voiced-page" hidden={!active}>
<Grid> <Grid>
<Column sm={4} md={8} lg={16}> <Column sm={4} md={8} lg={16}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", gap: "1rem", flexWrap: "wrap" }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", gap: "1rem", flexWrap: "wrap" }}>
<div> <div>
<h1 className="voiced-page__title">New invoice</h1> <h1 className="voiced-page__title">{locked ? `Invoice ${issued.number}` : "New invoice"}</h1>
<p className="voiced-page__subtitle" style={{ marginBottom: 0 }}> <p className="voiced-page__subtitle" style={{ marginBottom: 0 }}>
Next number: <strong>{number || "—"}</strong> {locked ? (
"Issued and read-only. Use the actions below to continue."
) : (
<>
Next number: <strong>{number || "—"}</strong>
</>
)}
</p> </p>
</div> </div>
<div className="voiced-header-action"> {locked ? null : (
<Button kind="ghost" onClick={resetForNext} disabled={saving}> <div className="voiced-header-action" style={{ flexWrap: "wrap" }}>
Clear {draftSaving ? (
</Button> <InlineLoading status="active" description="Saving draft…" />
<Button renderIcon={Save} onClick={onSave} disabled={saving}> ) : savedAt && !unsaved ? (
{saving ? "Saving…" : "Save & export PDF"} <InlineLoading status="finished" description={`Draft saved ${formatTime(savedAt)}`} />
</Button> ) : unsaved ? (
</div> <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> </div>
{error ? ( {error ? (
<InlineNotification kind="error" lowContrast title="Could not save invoice" subtitle={error} />
) : null}
{saved ? (
<InlineNotification <InlineNotification
kind="success" kind="error"
lowContrast lowContrast
title="Invoice saved" title="Could not save invoice"
subtitle={saved} subtitle={error}
onCloseButtonClick={() => setSaved(null)} onCloseButtonClick={() => setError(null)}
/> />
) : 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" }} /> <div style={{ marginTop: "1.5rem" }} />
</Column> </Column>
@@ -278,6 +729,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<Column sm={4} md={4} lg={5}> <Column sm={4} md={4} lg={5}>
<TextInput <TextInput
id="invoiceDate" id="invoiceDate"
disabled={locked}
type="date" type="date"
labelText="Invoice date" labelText="Invoice date"
value={invoiceDate} value={invoiceDate}
@@ -290,6 +742,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<Column sm={4} md={4} lg={5}> <Column sm={4} md={4} lg={5}>
<TextInput <TextInput
id="dueDate" id="dueDate"
disabled={locked}
type="date" type="date"
labelText={`Due date (net ${settings.paymentTermsDays} days)`} labelText={`Due date (net ${settings.paymentTermsDays} days)`}
value={dueDate} value={dueDate}
@@ -297,22 +750,25 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
/> />
</Column> </Column>
<Column sm={4} md={8} lg={6}> <Column sm={4} md={8} lg={6}>
<TextInput id="invoiceNumber" labelText="Invoice number" value={number} readOnly /> <TextInput id="invoiceNumber" labelText="Invoice number" value={issued?.number ?? number} readOnly />
</Column> </Column>
<Column sm={4} md={8} lg={8}> <Column sm={4} md={8} lg={8}>
<Dropdown <Dropdown
id="savedClient" id="savedClient"
disabled={locked}
titleText="Saved client (optional)" titleText="Saved client (optional)"
label="Select a saved client to autofill" label="Select a saved client to autofill"
items={clients} items={clients}
itemToString={(item) => (item ? item.name : "")} itemToString={(item) => (item ? item.name : "")}
selectedItem={clients.find((c) => c.id === clientId) ?? null}
onChange={({ selectedItem }) => selectedItem && applyClient(selectedItem)} onChange={({ selectedItem }) => selectedItem && applyClient(selectedItem)}
/> />
</Column> </Column>
<Column sm={4} md={8} lg={8}> <Column sm={4} md={8} lg={8}>
<TextInput <TextInput
id="clientName" id="clientName"
disabled={locked}
labelText="Client name" labelText="Client name"
value={clientName} value={clientName}
onChange={(e) => { onChange={(e) => {
@@ -324,6 +780,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<Column sm={4} md={8} lg={16}> <Column sm={4} md={8} lg={16}>
<TextArea <TextArea
id="clientAddress" id="clientAddress"
disabled={locked}
labelText="Client address" labelText="Client address"
rows={3} rows={3}
value={clientAddress} value={clientAddress}
@@ -333,6 +790,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<Column sm={4} md={4} lg={5}> <Column sm={4} md={4} lg={5}>
<TextInput <TextInput
id="clientGstin" id="clientGstin"
disabled={locked}
labelText="Client GSTIN (optional)" labelText="Client GSTIN (optional)"
value={clientGstin} value={clientGstin}
onChange={(e) => setClientGstin(e.target.value.toUpperCase())} onChange={(e) => setClientGstin(e.target.value.toUpperCase())}
@@ -343,6 +801,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<Column sm={4} md={4} lg={5}> <Column sm={4} md={4} lg={5}>
<TextInput <TextInput
id="poNumber" id="poNumber"
disabled={locked}
labelText="PO number (optional)" labelText="PO number (optional)"
value={poNumber} value={poNumber}
onChange={(e) => setPoNumber(e.target.value)} onChange={(e) => setPoNumber(e.target.value)}
@@ -351,6 +810,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<Column sm={4} md={8} lg={6}> <Column sm={4} md={8} lg={6}>
<Select <Select
id="placeOfSupply" id="placeOfSupply"
disabled={locked}
labelText="Place of supply" labelText="Place of supply"
value={placeOfSupply} value={placeOfSupply}
onChange={(e) => setPlaceOfSupply(e.target.value)} onChange={(e) => setPlaceOfSupply(e.target.value)}
@@ -363,6 +823,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<Column sm={4} md={8} lg={16}> <Column sm={4} md={8} lg={16}>
<Toggle <Toggle
id="saveClient" id="saveClient"
disabled={locked}
labelText="Save this client for next time" labelText="Save this client for next time"
toggled={saveClient} toggled={saveClient}
onToggle={setSaveClient} onToggle={setSaveClient}
@@ -388,6 +849,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<div style={{ flex: "3 1 240px" }}> <div style={{ flex: "3 1 240px" }}>
<TextInput <TextInput
id={`desc-${index}`} id={`desc-${index}`}
disabled={locked}
labelText="Particulars / description" labelText="Particulars / description"
value={item.description} value={item.description}
onChange={(e) => updateItem(index, { description: e.target.value })} onChange={(e) => updateItem(index, { description: e.target.value })}
@@ -397,6 +859,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<div style={{ flex: "0 1 120px" }}> <div style={{ flex: "0 1 120px" }}>
<TextInput <TextInput
id={`hsn-${index}`} id={`hsn-${index}`}
disabled={locked}
labelText="HSN/SAC" labelText="HSN/SAC"
value={item.hsnSac ?? ""} value={item.hsnSac ?? ""}
onChange={(e) => updateItem(index, { hsnSac: e.target.value })} onChange={(e) => updateItem(index, { hsnSac: e.target.value })}
@@ -406,6 +869,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<div style={{ flex: "0 1 150px" }}> <div style={{ flex: "0 1 150px" }}>
<Select <Select
id={`mode-${index}`} id={`mode-${index}`}
disabled={locked}
labelText="Type" labelText="Type"
value={item.mode} value={item.mode}
onChange={(e) => updateItem(index, { mode: e.target.value as LineMode })} onChange={(e) => updateItem(index, { mode: e.target.value as LineMode })}
@@ -419,6 +883,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<div style={{ flex: "0 1 130px" }}> <div style={{ flex: "0 1 130px" }}>
<NumberInput <NumberInput
id={`rate-${index}`} id={`rate-${index}`}
disabled={locked}
label="Rate (₹)" label="Rate (₹)"
min={0} min={0}
step={1} step={1}
@@ -429,6 +894,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<div style={{ flex: "0 1 140px" }}> <div style={{ flex: "0 1 140px" }}>
<Select <Select
id={`unit-${index}`} id={`unit-${index}`}
disabled={locked}
labelText="Unit" labelText="Unit"
value={item.unit} value={item.unit}
onChange={(e) => updateItem(index, { unit: e.target.value as LineUnit })} onChange={(e) => updateItem(index, { unit: e.target.value as LineUnit })}
@@ -441,6 +907,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<div style={{ flex: "0 1 110px" }}> <div style={{ flex: "0 1 110px" }}>
<NumberInput <NumberInput
id={`qty-${index}`} id={`qty-${index}`}
disabled={locked}
label="Quantity" label="Quantity"
min={0} min={0}
step={1} step={1}
@@ -453,6 +920,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<div style={{ flex: "0 1 150px" }}> <div style={{ flex: "0 1 150px" }}>
<NumberInput <NumberInput
id={`amount-${index}`} id={`amount-${index}`}
disabled={locked}
label="Amount (₹)" label="Amount (₹)"
min={0} min={0}
step={1} step={1}
@@ -471,11 +939,11 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
renderIcon={TrashCan} renderIcon={TrashCan}
iconDescription="Remove line" iconDescription="Remove line"
onClick={() => removeItem(index)} onClick={() => removeItem(index)}
disabled={items.length === 1} disabled={locked || items.length === 1}
/> />
</div> </div>
))} ))}
<Button kind="tertiary" size="sm" renderIcon={Add} onClick={addItem}> <Button kind="tertiary" size="sm" renderIcon={Add} onClick={addItem} disabled={locked}>
Add line Add line
</Button> </Button>
</div> </div>
@@ -484,6 +952,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<Column sm={4} md={4} lg={8}> <Column sm={4} md={4} lg={8}>
<NumberInput <NumberInput
id="discount" id="discount"
disabled={locked}
label="Discount (₹)" label="Discount (₹)"
min={0} min={0}
step={1} step={1}
@@ -503,6 +972,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<Column sm={4} md={4} lg={8}> <Column sm={4} md={4} lg={8}>
<NumberInput <NumberInput
id="taxRate" id="taxRate"
disabled={locked}
label="Tax rate (%)" label="Tax rate (%)"
min={0} min={0}
max={100} max={100}
@@ -514,6 +984,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<Column sm={4} md={8} lg={16}> <Column sm={4} md={8} lg={16}>
<Toggle <Toggle
id="reverseCharge" id="reverseCharge"
disabled={locked}
labelText="Tax payable on reverse charge" labelText="Tax payable on reverse charge"
toggled={reverseCharge} toggled={reverseCharge}
onToggle={setReverseCharge} onToggle={setReverseCharge}
@@ -525,6 +996,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
<Select <Select
id="bankAccount" id="bankAccount"
disabled={locked}
labelText="Bank account (Pay To)" labelText="Bank account (Pay To)"
value={bankId ?? ""} value={bankId ?? ""}
onChange={(e) => setBankId(e.target.value ? Number(e.target.value) : null)} onChange={(e) => setBankId(e.target.value ? Number(e.target.value) : null)}
@@ -544,10 +1016,12 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
label="Signature for this invoice (optional)" label="Signature for this invoice (optional)"
value={signaturePath} value={signaturePath}
onChange={setSignaturePath} onChange={setSignaturePath}
disabled={locked}
/> />
<TextArea <TextArea
id="notes" id="notes"
disabled={locked}
labelText="Notes (optional)" labelText="Notes (optional)"
rows={2} rows={2}
value={notes} value={notes}
@@ -563,5 +1037,81 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
</Column> </Column>
</Grid> </Grid>
</div> </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>
</>
); );
} }
+10 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
Button, Button,
Column, Column,
@@ -20,7 +20,7 @@ import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api"; import { api } from "../lib/api";
import type { InvoiceSeries } from "../lib/types"; import type { InvoiceSeries } from "../lib/types";
export default function SeriesSettings() { export default function SeriesSettings({ active: visible }: { active: boolean }) {
const [active, setActive] = useState<InvoiceSeries | null>(null); const [active, setActive] = useState<InvoiceSeries | null>(null);
const [all, setAll] = useState<InvoiceSeries[]>([]); const [all, setAll] = useState<InvoiceSeries[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -31,14 +31,18 @@ export default function SeriesSettings() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const seeded = useRef(false);
const load = async () => { const load = async () => {
try { try {
const [current, list] = await Promise.all([api.getActiveSeries(), api.listSeries()]); const [current, list] = await Promise.all([api.getActiveSeries(), api.listSeries()]);
setActive(current); setActive(current);
setAll(list); setAll(list);
if (current) { // Only seed the form once, so a refetch on re-entry does not overwrite what was typed.
if (current && !seeded.current) {
setPrefix(current.prefix); setPrefix(current.prefix);
setPadding(current.padding); setPadding(current.padding);
seeded.current = true;
} }
setError(null); setError(null);
} catch (e) { } catch (e) {
@@ -48,9 +52,10 @@ export default function SeriesSettings() {
} }
}; };
// The view stays mounted, so refetch every time it is shown again.
useEffect(() => { useEffect(() => {
void load(); if (visible) void load();
}, []); }, [visible]);
const startNew = async () => { const startNew = async () => {
const ok = await confirm( const ok = await confirm(