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([]); const [clientId, setClientId] = useState(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([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([]); const [bankId, setBankId] = useState(settings.defaultBankId); const [signaturePath, setSignaturePath] = useState(settings.signaturePath); const [notes, setNotes] = useState(""); const [logoImages, setLogoImages] = useState(null); const [signatureUri, setSignatureUri] = useState(null); const [saving, setSaving] = useState(false); const [error, setError] = useState(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(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(null); const [archiving, setArchiving] = useState(false); const [exportMode, setExportMode] = useState(loadExportMode); const locked = issued !== null; // The issued invoice as the export flow needs it: with its archive fields once archived. const exportable = useMemo( () => (issued ? { ...issued, archivedPdfSha256: archive?.sha256 ?? issued.archivedPdfSha256, archivedFingerprint: archive?.fingerprint ?? issued.archivedFingerprint ?? null } : null), [issued, archive], ); // Drafts. const [draftId, setDraftId] = useState(null); const [baseline, setBaseline] = useState(null); const [savedSnapshot, setSavedSnapshot] = useState(null); const [savedAt, setSavedAt] = useState(null); const [draftSaving, setDraftSaving] = useState(false); const [draftError, setDraftError] = useState(null); const [drafts, setDrafts] = useState([]); 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(null); const savedRef = useRef(null); const issuedRef = useRef(false); const chain = useRef>(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 => { 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(() => 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) => 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 => { 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>; 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 ( <>