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 5597fb791d
commit fbed18a0bd
8 changed files with 859 additions and 62 deletions
+601 -51
View File
@@ -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>
</>
);
}