- Global Carbon toast stack replaces scattered inline notices; errors persist until closed. - Settings is split into eight tabs with one Save and a remembered last tab; Numbering embeds the series settings. - New Clients page with a DataTable, shared add/edit modal, structured addresses, GST category (informational), per-client notes and payment terms. The composed address string is kept for back-compat. Deleting a client warns about invoice count; issued invoices keep their own snapshot. - Migration M7 adds the client columns and item_presets; existing clients with a GSTIN are backfilled to registered_regular. - New invoice form: client ComboBox with type-ahead and inline create, client defaults applied on selection, and an Add from preset control. - Rust tests for M7 (fresh and v6 upgrade), client validation and CRUD, presets CRUD; vitest for the toast store and new helpers. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
1201 lines
43 KiB
TypeScript
1201 lines
43 KiB
TypeScript
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
ActionableNotification,
|
||
Button,
|
||
ComboBox,
|
||
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,
|
||
ItemPreset,
|
||
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";
|
||
import ClientModal from "../components/ClientModal";
|
||
import { useToast } from "../components/ToastProvider";
|
||
import { itemFromPreset } from "../lib/presets";
|
||
|
||
// 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 toast = useToast();
|
||
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 [presets, setPresets] = useState<ItemPreset[]>([]);
|
||
const [clientModalOpen, setClientModalOpen] = useState(false);
|
||
const newClientButton = useRef<HTMLButtonElement | null>(null);
|
||
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) toast.error("Could not load invoice data", String(e));
|
||
}
|
||
})();
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, []);
|
||
|
||
// The view stays mounted, so pick up clients and presets edited on the Clients page when it is shown again.
|
||
useEffect(() => {
|
||
if (!active) return;
|
||
api.listClients().then(setClients).catch(() => {});
|
||
api.listItemPresets().then(setPresets).catch(() => {});
|
||
}, [active]);
|
||
|
||
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;
|
||
}, []);
|
||
|
||
// A failed autosave is reported once per failure streak (the message clears on the next success).
|
||
useEffect(() => {
|
||
if (draftError) toast.warning("Draft not saved", draftError);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [draftError]);
|
||
|
||
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);
|
||
// Client defaults fill gaps only: typed notes are never overwritten.
|
||
if (selected.defaultNotes && !notes.trim()) setNotes(selected.defaultNotes);
|
||
if (selected.paymentTermsDays !== null) setDueDate(addDays(invoiceDate, selected.paymentTermsDays));
|
||
};
|
||
|
||
const addPreset = (preset: ItemPreset) => {
|
||
const line = itemFromPreset(preset, { hsnSac: settings.defaultHsnSac });
|
||
const isBlank = (i: InvoiceItem) => !i.description.trim() && !i.amount && !i.rate;
|
||
setItems((prev) => (prev.length === 1 && isBlank(prev[0]) ? [line] : [...prev, line]));
|
||
};
|
||
|
||
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) {
|
||
toast.error("Could not open the draft", String(e));
|
||
}
|
||
};
|
||
|
||
const removeDraft = async (id: number) => {
|
||
try {
|
||
await api.deleteDraft(id);
|
||
} catch (e) {
|
||
toast.error("Could not delete the draft", 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;
|
||
toast.error("Could not issue the invoice", 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="Fix this before issuing"
|
||
subtitle={error}
|
||
onCloseButtonClick={() => setError(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}>
|
||
<ComboBox
|
||
id="savedClient"
|
||
disabled={locked}
|
||
titleText="Saved client (optional)"
|
||
placeholder="Type to search saved clients"
|
||
items={clients}
|
||
itemToString={(item: Client | null) => (item ? item.name : "")}
|
||
selectedItem={clients.find((c) => c.id === clientId) ?? null}
|
||
shouldFilterItem={({ item, inputValue }: { item: Client; inputValue: string | null }) => {
|
||
const q = (inputValue ?? "").trim().toLowerCase();
|
||
return !q || item.name.toLowerCase().includes(q) || item.gstin.toLowerCase().includes(q);
|
||
}}
|
||
onChange={({ selectedItem }: { selectedItem?: Client | null }) => selectedItem && applyClient(selectedItem)}
|
||
/>
|
||
<Button
|
||
ref={newClientButton}
|
||
kind="ghost"
|
||
size="sm"
|
||
renderIcon={Add}
|
||
disabled={locked}
|
||
onClick={() => setClientModalOpen(true)}
|
||
>
|
||
Create client
|
||
</Button>
|
||
</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>
|
||
{presets.length > 0 ? (
|
||
<div style={{ maxWidth: "24rem", marginBottom: "0.75rem" }}>
|
||
<Dropdown
|
||
id="addFromPreset"
|
||
disabled={locked}
|
||
titleText="Add from preset"
|
||
label="Choose a saved item"
|
||
items={presets}
|
||
itemToString={(item: ItemPreset | null) => (item ? item.description : "")}
|
||
selectedItem={null}
|
||
onChange={({ selectedItem }: { selectedItem?: ItemPreset | null }) => selectedItem && addPreset(selectedItem)}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
{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>
|
||
<ClientModal
|
||
open={clientModalOpen}
|
||
client={null}
|
||
launcherRef={newClientButton}
|
||
onClose={() => setClientModalOpen(false)}
|
||
onSaved={(saved) => {
|
||
setClientModalOpen(false);
|
||
applyClient(saved);
|
||
api.listClients().then(setClients).catch(() => {});
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|