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:
+601
-51
@@ -1,23 +1,45 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActionableNotification,
|
||||
Button,
|
||||
Column,
|
||||
Dropdown,
|
||||
Grid,
|
||||
InlineLoading,
|
||||
InlineNotification,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
SelectItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tag,
|
||||
TextArea,
|
||||
TextInput,
|
||||
Toggle,
|
||||
} from "@carbon/react";
|
||||
import { Add, 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 { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
|
||||
import type {
|
||||
BankAccount,
|
||||
Client,
|
||||
DraftSummary,
|
||||
Invoice,
|
||||
InvoiceInput,
|
||||
InvoiceItem,
|
||||
LineMode,
|
||||
@@ -30,6 +52,8 @@ import { amountInWords } from "../lib/numberToWords";
|
||||
import { gstinFullError } from "../lib/validators";
|
||||
import { addDays, formatAmount, todayIso } from "../lib/format";
|
||||
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 { ImagePicker } from "../components/ImagePicker";
|
||||
import type { InvoicePdfProps } from "../pdf/InvoiceDocument";
|
||||
@@ -37,10 +61,43 @@ import type { InvoicePdfProps } from "../pdf/InvoiceDocument";
|
||||
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;
|
||||
}
|
||||
|
||||
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 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));
|
||||
@@ -72,21 +129,43 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
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(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const [next, clientList, bankList] = await Promise.all([
|
||||
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));
|
||||
}
|
||||
@@ -135,12 +214,128 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
const words = useMemo(() => amountInWords(totals.total), [totals.total]);
|
||||
const bank = useMemo(() => banks.find((b) => b.id === bankId) ?? null, [banks, bankId]);
|
||||
|
||||
const payload = useMemo(
|
||||
() =>
|
||||
toDraftPayload({
|
||||
invoiceDate,
|
||||
dueDate,
|
||||
clientId,
|
||||
clientName,
|
||||
clientAddress,
|
||||
clientGstin,
|
||||
poNumber,
|
||||
placeOfSupply,
|
||||
saveClient,
|
||||
items,
|
||||
discount,
|
||||
taxRate,
|
||||
reverseCharge,
|
||||
bankId,
|
||||
signaturePath,
|
||||
notes,
|
||||
}),
|
||||
[
|
||||
invoiceDate,
|
||||
dueDate,
|
||||
clientId,
|
||||
clientName,
|
||||
clientAddress,
|
||||
clientGstin,
|
||||
poNumber,
|
||||
placeOfSupply,
|
||||
saveClient,
|
||||
items,
|
||||
discount,
|
||||
taxRate,
|
||||
reverseCharge,
|
||||
bankId,
|
||||
signaturePath,
|
||||
notes,
|
||||
],
|
||||
);
|
||||
const snapshot = useMemo(() => JSON.stringify(payload), [payload]);
|
||||
|
||||
// The untouched form is the baseline; anything else is dirty. A pristine form is never saved.
|
||||
useEffect(() => {
|
||||
setBaseline((current) => current ?? snapshot);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const dirty = baseline !== null && snapshot !== baseline;
|
||||
const unsaved = !locked && dirty && snapshot !== savedSnapshot;
|
||||
|
||||
const latest = useRef({ snapshot, payload });
|
||||
latest.current = { snapshot, payload };
|
||||
const draftIdRef = useRef<number | null>(null);
|
||||
const savedRef = useRef<string | null>(null);
|
||||
const issuedRef = useRef(false);
|
||||
const chain = useRef<Promise<void>>(Promise.resolve());
|
||||
|
||||
const refreshDrafts = useCallback(async () => {
|
||||
try {
|
||||
setDrafts(await api.listDrafts());
|
||||
} catch {
|
||||
/* the list is a convenience; ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshNumber = async () => {
|
||||
try {
|
||||
setNumber(await api.peekNextInvoiceNumber());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
// Saves are serialised so a slow first save cannot race a second one into creating two drafts.
|
||||
const persistDraft = useCallback((): Promise<void> => {
|
||||
const run = async () => {
|
||||
if (issuedRef.current) return;
|
||||
const { snapshot: snap, payload: body } = latest.current;
|
||||
if (snap === savedRef.current) return;
|
||||
setDraftSaving(true);
|
||||
try {
|
||||
const id = await api.saveDraft(draftIdRef.current, body);
|
||||
draftIdRef.current = id;
|
||||
savedRef.current = snap;
|
||||
setDraftId(id);
|
||||
setSavedSnapshot(snap);
|
||||
setSavedAt(new Date());
|
||||
setDraftError(null);
|
||||
} finally {
|
||||
setDraftSaving(false);
|
||||
}
|
||||
};
|
||||
const next = chain.current.then(run);
|
||||
chain.current = next.catch(() => {});
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!unsaved) return;
|
||||
const timer = setTimeout(() => {
|
||||
persistDraft()
|
||||
.then(refreshDrafts)
|
||||
.catch((e) => setDraftError(String(e)));
|
||||
}, AUTOSAVE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [unsaved, snapshot, persistDraft, refreshDrafts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!unsaved) return;
|
||||
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}, [unsaved]);
|
||||
|
||||
const previewProps: InvoicePdfProps = {
|
||||
vendor: settings,
|
||||
bank,
|
||||
logoDataUri: logoUri,
|
||||
signatureDataUri: signatureUri,
|
||||
number: number || "DRAFT",
|
||||
number: issued?.number ?? (number || "DRAFT"),
|
||||
invoiceDate,
|
||||
dueDate,
|
||||
clientName,
|
||||
@@ -177,35 +372,182 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
if (selected.stateCode) setPlaceOfSupply(selected.stateCode);
|
||||
};
|
||||
|
||||
const resetForNext = async () => {
|
||||
setClientId(null);
|
||||
setClientName("");
|
||||
setClientAddress("");
|
||||
setClientGstin("");
|
||||
setPoNumber("");
|
||||
setSaveClient(false);
|
||||
setItems([newItem()]);
|
||||
setDiscount(0);
|
||||
setNotes("");
|
||||
setInvoiceDate(todayIso());
|
||||
setDueDate(addDays(todayIso(), settings.paymentTermsDays));
|
||||
const applyState = (s: InvoiceFormState) => {
|
||||
setInvoiceDate(s.invoiceDate);
|
||||
setDueDate(s.dueDate);
|
||||
setClientId(s.clientId);
|
||||
setClientName(s.clientName);
|
||||
setClientAddress(s.clientAddress);
|
||||
setClientGstin(s.clientGstin);
|
||||
setPoNumber(s.poNumber);
|
||||
setPlaceOfSupply(s.placeOfSupply);
|
||||
setSaveClient(s.saveClient);
|
||||
setItems(s.items);
|
||||
setDiscount(s.discount);
|
||||
setTaxRate(s.taxRate);
|
||||
setReverseCharge(s.reverseCharge);
|
||||
setBankId(s.bankId);
|
||||
setSignaturePath(s.signaturePath);
|
||||
setNotes(s.notes);
|
||||
};
|
||||
|
||||
const clearIssued = () => {
|
||||
issuedRef.current = false;
|
||||
setIssued(null);
|
||||
setExportedPath(null);
|
||||
setExportError(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const detachDraft = () => {
|
||||
draftIdRef.current = null;
|
||||
savedRef.current = null;
|
||||
setDraftId(null);
|
||||
setSavedSnapshot(null);
|
||||
setSavedAt(null);
|
||||
setDraftError(null);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
const blank = blankState();
|
||||
applyState(blank);
|
||||
setBaseline(JSON.stringify(toDraftPayload(blank)));
|
||||
clearIssued();
|
||||
detachDraft();
|
||||
void refreshNumber();
|
||||
api.listClients().then(setClients).catch(() => {});
|
||||
void refreshDrafts();
|
||||
};
|
||||
|
||||
// Keeps the work in a draft before it is replaced. Returns false if that was not possible
|
||||
// and the user chose to keep the current form.
|
||||
const parkCurrentWork = async (): Promise<boolean> => {
|
||||
if (locked || !unsaved) return true;
|
||||
try {
|
||||
setNumber(await api.peekNextInvoiceNumber());
|
||||
setClients(await api.listClients());
|
||||
} catch {
|
||||
/* ignore */
|
||||
await persistDraft();
|
||||
return true;
|
||||
} catch (e) {
|
||||
return confirm(`The current form could not be saved as a draft (${String(e)}). Discard it?`, {
|
||||
title: "Unsaved changes",
|
||||
kind: "warning",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const newInvoice = async () => {
|
||||
onActivate();
|
||||
if (!(await parkCurrentWork())) return;
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const duplicateAsNew = () => {
|
||||
const t = todayIso();
|
||||
clearIssued();
|
||||
detachDraft();
|
||||
setInvoiceDate(t);
|
||||
setDueDate(addDays(t, settings.paymentTermsDays));
|
||||
setSaveClient(false);
|
||||
void refreshNumber();
|
||||
};
|
||||
|
||||
const saveDraftNow = async () => {
|
||||
if (locked || !dirty) return;
|
||||
try {
|
||||
await persistDraft();
|
||||
await refreshDrafts();
|
||||
} catch (e) {
|
||||
setDraftError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const discardDraft = async () => {
|
||||
const ok = await confirm(
|
||||
draftId !== null
|
||||
? "Discard this draft and clear the form?"
|
||||
: "Clear the form? Nothing has been saved as a draft.",
|
||||
{ title: "Discard draft", kind: "warning" },
|
||||
);
|
||||
if (!ok) return;
|
||||
// Wait for any in-flight save so it cannot recreate the draft after the delete.
|
||||
await chain.current;
|
||||
const id = draftIdRef.current;
|
||||
try {
|
||||
if (id !== null) await api.deleteDraft(id);
|
||||
} catch (e) {
|
||||
setDraftError(String(e));
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const resumeDraft = async (id: number) => {
|
||||
if (id === draftIdRef.current) {
|
||||
setDraftsOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!(await parkCurrentWork())) return;
|
||||
try {
|
||||
const restored = fromDraftPayload(await api.getDraft(id), blankState());
|
||||
if (restored.bankId !== null && !banks.some((b) => b.id === restored.bankId)) {
|
||||
restored.bankId = null;
|
||||
}
|
||||
applyState(restored);
|
||||
setBaseline(JSON.stringify(toDraftPayload(blankState())));
|
||||
clearIssued();
|
||||
const snap = JSON.stringify(toDraftPayload(restored));
|
||||
draftIdRef.current = id;
|
||||
savedRef.current = snap;
|
||||
setDraftId(id);
|
||||
setSavedSnapshot(snap);
|
||||
setSavedAt(null);
|
||||
setDraftError(null);
|
||||
setNoticeOpen(false);
|
||||
setDraftsOpen(false);
|
||||
void refreshNumber();
|
||||
void refreshDrafts();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const removeDraft = async (id: number) => {
|
||||
try {
|
||||
await api.deleteDraft(id);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
await refreshDrafts();
|
||||
};
|
||||
|
||||
const runExport = async (invoice: Invoice) => {
|
||||
setExporting(true);
|
||||
setExportError(null);
|
||||
try {
|
||||
const 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 () => {
|
||||
if (locked || saving) return;
|
||||
if (clientGstinError) {
|
||||
setError(`Client GSTIN: ${clientGstinError}`);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(null);
|
||||
setExportedPath(null);
|
||||
setExportError(null);
|
||||
let invoice: Invoice;
|
||||
try {
|
||||
// Let a pending autosave land first so the issue call knows which draft to delete.
|
||||
await chain.current;
|
||||
issuedRef.current = true;
|
||||
const input: InvoiceInput = {
|
||||
invoiceDate,
|
||||
dueDate,
|
||||
@@ -223,53 +565,162 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
signaturePath,
|
||||
notes,
|
||||
saveClient,
|
||||
draftId: null,
|
||||
draftId: draftIdRef.current,
|
||||
items: items.map((item, index) => ({ ...item, sortOrder: index })),
|
||||
};
|
||||
const 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();
|
||||
invoice = await api.issueInvoice(input, {});
|
||||
} catch (e) {
|
||||
issuedRef.current = false;
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
// The number is consumed from here on. A failed export must never lead to a second issue.
|
||||
setIssued(invoice);
|
||||
detachDraft();
|
||||
api.listClients().then(setClients).catch(() => {});
|
||||
void refreshDrafts();
|
||||
await runExport(invoice);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
useShortcuts({
|
||||
onNew: () => void newInvoice(),
|
||||
onSaveDraft: () => active && void saveDraftNow(),
|
||||
onIssue: () => active && !locked && void onSave(),
|
||||
onAddLine: () => active && !locked && addItem(),
|
||||
onHelp: () => active && setHelpOpen(true),
|
||||
});
|
||||
|
||||
const otherDrafts = drafts.filter((d) => d.id !== draftId);
|
||||
const busy = saving || exporting;
|
||||
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<>
|
||||
<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">New invoice</h1>
|
||||
<h1 className="voiced-page__title">{locked ? `Invoice ${issued.number}` : "New invoice"}</h1>
|
||||
<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>
|
||||
</div>
|
||||
<div className="voiced-header-action">
|
||||
<Button kind="ghost" onClick={resetForNext} disabled={saving}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button renderIcon={Save} onClick={onSave} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save & export PDF"}
|
||||
</Button>
|
||||
</div>
|
||||
{locked ? null : (
|
||||
<div className="voiced-header-action" style={{ flexWrap: "wrap" }}>
|
||||
{draftSaving ? (
|
||||
<InlineLoading status="active" description="Saving draft…" />
|
||||
) : savedAt && !unsaved ? (
|
||||
<InlineLoading status="finished" description={`Draft saved ${formatTime(savedAt)}`} />
|
||||
) : unsaved ? (
|
||||
<Tag type="gray" size="sm">
|
||||
Unsaved changes
|
||||
</Tag>
|
||||
) : null}
|
||||
{drafts.length > 0 ? (
|
||||
<Button kind="ghost" onClick={() => setDraftsOpen(true)} disabled={busy}>
|
||||
{`Drafts (${drafts.length})`}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button kind="ghost" renderIcon={Keyboard} hasIconOnly iconDescription="Keyboard shortcuts (?)" tooltipPosition="bottom" onClick={() => setHelpOpen(true)} />
|
||||
{dirty || draftId !== null ? (
|
||||
<Button kind="ghost" onClick={discardDraft} disabled={busy}>
|
||||
Discard draft
|
||||
</Button>
|
||||
) : null}
|
||||
<Button kind="tertiary" renderIcon={Save} onClick={saveDraftNow} disabled={busy || !dirty}>
|
||||
Save draft
|
||||
</Button>
|
||||
<Button renderIcon={Download} onClick={onSave} disabled={busy}>
|
||||
{saving ? "Saving…" : "Save & export PDF"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error ? (
|
||||
<InlineNotification kind="error" lowContrast title="Could not save invoice" subtitle={error} />
|
||||
) : null}
|
||||
{saved ? (
|
||||
<InlineNotification
|
||||
kind="success"
|
||||
kind="error"
|
||||
lowContrast
|
||||
title="Invoice saved"
|
||||
subtitle={saved}
|
||||
onCloseButtonClick={() => setSaved(null)}
|
||||
title="Could not save invoice"
|
||||
subtitle={error}
|
||||
onCloseButtonClick={() => setError(null)}
|
||||
/>
|
||||
) : null}
|
||||
{draftError ? (
|
||||
<InlineNotification
|
||||
kind="warning"
|
||||
lowContrast
|
||||
title="Draft not saved"
|
||||
subtitle={draftError}
|
||||
onCloseButtonClick={() => setDraftError(null)}
|
||||
/>
|
||||
) : null}
|
||||
{!locked && noticeOpen && !dirty && otherDrafts.length > 0 ? (
|
||||
<ActionableNotification
|
||||
inline
|
||||
kind="info"
|
||||
lowContrast
|
||||
title={`You have ${otherDrafts.length} unfinished draft${otherDrafts.length === 1 ? "" : "s"}`}
|
||||
subtitle="Pick up where you left off."
|
||||
actionButtonLabel="Resume latest"
|
||||
onActionButtonClick={() => void resumeDraft(otherDrafts[0].id)}
|
||||
onCloseButtonClick={() => setNoticeOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{locked ? (
|
||||
<>
|
||||
{exportError ? (
|
||||
<InlineNotification
|
||||
kind="error"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title={`Invoice ${issued.number} was issued, but the PDF export failed`}
|
||||
subtitle={`${exportError} The number is already used; retry the export, do not issue again.`}
|
||||
/>
|
||||
) : exportedPath ? (
|
||||
<InlineNotification
|
||||
kind="success"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title={`Invoice ${issued.number} issued`}
|
||||
subtitle={exportedPath}
|
||||
/>
|
||||
) : exporting ? (
|
||||
<InlineLoading description="Exporting PDF…" />
|
||||
) : (
|
||||
<InlineNotification
|
||||
kind="info"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title={`Invoice ${issued.number} issued`}
|
||||
subtitle="The PDF was not saved. Use Export again to save it."
|
||||
/>
|
||||
)}
|
||||
<div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}>
|
||||
<Button
|
||||
kind={exportError ? "primary" : "tertiary"}
|
||||
renderIcon={exportError ? Renew : Download}
|
||||
onClick={() => void runExport(issued)}
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting ? "Exporting…" : exportError ? "Retry export" : "Export again"}
|
||||
</Button>
|
||||
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={exporting}>
|
||||
Duplicate as new
|
||||
</Button>
|
||||
<Button kind="tertiary" renderIcon={DocumentAdd} onClick={() => void newInvoice()} disabled={exporting}>
|
||||
New invoice
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div style={{ marginTop: "1.5rem" }} />
|
||||
</Column>
|
||||
|
||||
@@ -278,6 +729,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<TextInput
|
||||
id="invoiceDate"
|
||||
disabled={locked}
|
||||
type="date"
|
||||
labelText="Invoice date"
|
||||
value={invoiceDate}
|
||||
@@ -290,6 +742,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<TextInput
|
||||
id="dueDate"
|
||||
disabled={locked}
|
||||
type="date"
|
||||
labelText={`Due date (net ${settings.paymentTermsDays} days)`}
|
||||
value={dueDate}
|
||||
@@ -297,22 +750,25 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
/>
|
||||
</Column>
|
||||
<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 sm={4} md={8} lg={8}>
|
||||
<Dropdown
|
||||
id="savedClient"
|
||||
disabled={locked}
|
||||
titleText="Saved client (optional)"
|
||||
label="Select a saved client to autofill"
|
||||
items={clients}
|
||||
itemToString={(item) => (item ? item.name : "")}
|
||||
selectedItem={clients.find((c) => c.id === clientId) ?? null}
|
||||
onChange={({ selectedItem }) => selectedItem && applyClient(selectedItem)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={8}>
|
||||
<TextInput
|
||||
id="clientName"
|
||||
disabled={locked}
|
||||
labelText="Client name"
|
||||
value={clientName}
|
||||
onChange={(e) => {
|
||||
@@ -324,6 +780,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<TextArea
|
||||
id="clientAddress"
|
||||
disabled={locked}
|
||||
labelText="Client address"
|
||||
rows={3}
|
||||
value={clientAddress}
|
||||
@@ -333,6 +790,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<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())}
|
||||
@@ -343,6 +801,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<TextInput
|
||||
id="poNumber"
|
||||
disabled={locked}
|
||||
labelText="PO number (optional)"
|
||||
value={poNumber}
|
||||
onChange={(e) => setPoNumber(e.target.value)}
|
||||
@@ -351,6 +810,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<Column sm={4} md={8} lg={6}>
|
||||
<Select
|
||||
id="placeOfSupply"
|
||||
disabled={locked}
|
||||
labelText="Place of supply"
|
||||
value={placeOfSupply}
|
||||
onChange={(e) => setPlaceOfSupply(e.target.value)}
|
||||
@@ -363,6 +823,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<Toggle
|
||||
id="saveClient"
|
||||
disabled={locked}
|
||||
labelText="Save this client for next time"
|
||||
toggled={saveClient}
|
||||
onToggle={setSaveClient}
|
||||
@@ -388,6 +849,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<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 })}
|
||||
@@ -397,6 +859,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<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 })}
|
||||
@@ -406,6 +869,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<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 })}
|
||||
@@ -419,6 +883,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<div style={{ flex: "0 1 130px" }}>
|
||||
<NumberInput
|
||||
id={`rate-${index}`}
|
||||
disabled={locked}
|
||||
label="Rate (₹)"
|
||||
min={0}
|
||||
step={1}
|
||||
@@ -429,6 +894,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<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 })}
|
||||
@@ -441,6 +907,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<div style={{ flex: "0 1 110px" }}>
|
||||
<NumberInput
|
||||
id={`qty-${index}`}
|
||||
disabled={locked}
|
||||
label="Quantity"
|
||||
min={0}
|
||||
step={1}
|
||||
@@ -453,6 +920,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<div style={{ flex: "0 1 150px" }}>
|
||||
<NumberInput
|
||||
id={`amount-${index}`}
|
||||
disabled={locked}
|
||||
label="Amount (₹)"
|
||||
min={0}
|
||||
step={1}
|
||||
@@ -471,11 +939,11 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
renderIcon={TrashCan}
|
||||
iconDescription="Remove line"
|
||||
onClick={() => removeItem(index)}
|
||||
disabled={items.length === 1}
|
||||
disabled={locked || items.length === 1}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button kind="tertiary" size="sm" renderIcon={Add} onClick={addItem}>
|
||||
<Button kind="tertiary" size="sm" renderIcon={Add} onClick={addItem} disabled={locked}>
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
@@ -484,6 +952,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<NumberInput
|
||||
id="discount"
|
||||
disabled={locked}
|
||||
label="Discount (₹)"
|
||||
min={0}
|
||||
step={1}
|
||||
@@ -503,6 +972,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<NumberInput
|
||||
id="taxRate"
|
||||
disabled={locked}
|
||||
label="Tax rate (%)"
|
||||
min={0}
|
||||
max={100}
|
||||
@@ -514,6 +984,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<Toggle
|
||||
id="reverseCharge"
|
||||
disabled={locked}
|
||||
labelText="Tax payable on reverse charge"
|
||||
toggled={reverseCharge}
|
||||
onToggle={setReverseCharge}
|
||||
@@ -525,6 +996,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
|
||||
<Select
|
||||
id="bankAccount"
|
||||
disabled={locked}
|
||||
labelText="Bank account (Pay To)"
|
||||
value={bankId ?? ""}
|
||||
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)"
|
||||
value={signaturePath}
|
||||
onChange={setSignaturePath}
|
||||
disabled={locked}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
id="notes"
|
||||
disabled={locked}
|
||||
labelText="Notes (optional)"
|
||||
rows={2}
|
||||
value={notes}
|
||||
@@ -563,5 +1037,81 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user