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:
+13
-3
@@ -88,9 +88,19 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<Theme theme={theme}>
|
<Theme theme={theme}>
|
||||||
<AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}>
|
<AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}>
|
||||||
{view === "new" && <NewInvoice settings={settings} onSettingsChange={refreshSettings} />}
|
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
|
||||||
{view === "invoices" && <InvoiceHistory settings={settings} />}
|
<NewInvoice
|
||||||
{view === "series" && <SeriesSettings />}
|
settings={settings}
|
||||||
|
onSettingsChange={refreshSettings}
|
||||||
|
active={view === "new"}
|
||||||
|
onActivate={() => setView("new")}
|
||||||
|
/>
|
||||||
|
<div hidden={view !== "invoices"}>
|
||||||
|
<InvoiceHistory settings={settings} active={view === "invoices"} />
|
||||||
|
</div>
|
||||||
|
<div hidden={view !== "series"}>
|
||||||
|
<SeriesSettings active={view === "series"} />
|
||||||
|
</div>
|
||||||
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
|
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</Theme>
|
</Theme>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export interface ShortcutHandlers {
|
||||||
|
onNew?: () => void;
|
||||||
|
onSaveDraft?: () => void;
|
||||||
|
onIssue?: () => void;
|
||||||
|
onAddLine?: () => void;
|
||||||
|
onHelp?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTyping = (target: EventTarget | null) => {
|
||||||
|
const el = target as HTMLElement | null;
|
||||||
|
if (!el) return false;
|
||||||
|
return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global keyboard shortcuts. Ctrl combinations work everywhere; the bare `?` is ignored
|
||||||
|
* while typing. F5 / Ctrl+R are blocked in production builds because a webview reload
|
||||||
|
* would discard in-memory state.
|
||||||
|
*/
|
||||||
|
export function useShortcuts(handlers: ShortcutHandlers) {
|
||||||
|
const ref = useRef(handlers);
|
||||||
|
ref.current = handlers;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
const ctrl = event.ctrlKey || event.metaKey;
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
|
||||||
|
if (import.meta.env.PROD && (event.key === "F5" || (ctrl && key === "r"))) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = (fn?: () => void) => {
|
||||||
|
event.preventDefault();
|
||||||
|
fn?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ctrl && !event.shiftKey && !event.altKey) {
|
||||||
|
if (key === "n") return run(ref.current.onNew);
|
||||||
|
if (key === "s") return run(ref.current.onSaveDraft);
|
||||||
|
if (key === "l") return run(ref.current.onAddLine);
|
||||||
|
if (key === "enter") return run(ref.current.onIssue);
|
||||||
|
}
|
||||||
|
if (!ctrl && !event.altKey && event.key === "?" && !isTyping(event.target)) {
|
||||||
|
run(ref.current.onHelp);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHORTCUT_LIST: { keys: string; action: string }[] = [
|
||||||
|
{ keys: "Ctrl + N", action: "New invoice" },
|
||||||
|
{ keys: "Ctrl + S", action: "Save draft" },
|
||||||
|
{ keys: "Ctrl + Enter", action: "Issue and export PDF" },
|
||||||
|
{ keys: "Ctrl + L", action: "Add line item" },
|
||||||
|
{ keys: "?", action: "Show this list" },
|
||||||
|
];
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "./draft";
|
||||||
|
import { EMPTY_ITEM } from "./types";
|
||||||
|
|
||||||
|
const defaults: InvoiceFormState = {
|
||||||
|
invoiceDate: "2026-01-01",
|
||||||
|
dueDate: "2026-01-31",
|
||||||
|
clientId: null,
|
||||||
|
clientName: "",
|
||||||
|
clientAddress: "",
|
||||||
|
clientGstin: "",
|
||||||
|
poNumber: "",
|
||||||
|
placeOfSupply: "29",
|
||||||
|
saveClient: false,
|
||||||
|
items: [{ ...EMPTY_ITEM, hsnSac: "9983" }],
|
||||||
|
discount: 0,
|
||||||
|
taxRate: 18,
|
||||||
|
reverseCharge: false,
|
||||||
|
bankId: 2,
|
||||||
|
signaturePath: "/sig/default.png",
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const filled: InvoiceFormState = {
|
||||||
|
invoiceDate: "2026-03-05",
|
||||||
|
dueDate: "2026-04-04",
|
||||||
|
clientId: 7,
|
||||||
|
clientName: "Acme Pvt Ltd",
|
||||||
|
clientAddress: "1 Main Rd\nKolkata",
|
||||||
|
clientGstin: "19AAAAA0000A1Z5",
|
||||||
|
poNumber: "PO-9",
|
||||||
|
placeOfSupply: "19",
|
||||||
|
saveClient: true,
|
||||||
|
items: [
|
||||||
|
{ ...EMPTY_ITEM, description: "Design", mode: "rate", rate: 500, unit: "hour", quantity: 4, hsnSac: "9983" },
|
||||||
|
{ ...EMPTY_ITEM, description: "Fee", amount: 1200, sortOrder: 1, hsnSac: "9983" },
|
||||||
|
],
|
||||||
|
discount: 100,
|
||||||
|
taxRate: 12,
|
||||||
|
reverseCharge: true,
|
||||||
|
bankId: null,
|
||||||
|
signaturePath: null,
|
||||||
|
notes: "Thanks",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("draft payload", () => {
|
||||||
|
it("round-trips through JSON", () => {
|
||||||
|
const json = JSON.parse(JSON.stringify(toDraftPayload(filled)));
|
||||||
|
expect(fromDraftPayload(json, defaults)).toEqual(filled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not store derived totals", () => {
|
||||||
|
const keys = Object.keys(toDraftPayload(filled));
|
||||||
|
for (const k of ["total", "subtotal", "cgst", "sgst", "igst", "amountInWords"]) {
|
||||||
|
expect(keys).not.toContain(k);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to defaults for missing fields", () => {
|
||||||
|
const out = fromDraftPayload({ clientName: "Only name" }, defaults);
|
||||||
|
expect(out).toEqual({ ...defaults, clientName: "Only name" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates garbage input and partial items", () => {
|
||||||
|
expect(fromDraftPayload(null, defaults)).toEqual(defaults);
|
||||||
|
expect(fromDraftPayload("x", defaults)).toEqual(defaults);
|
||||||
|
const out = fromDraftPayload({ items: [{ description: "A" }], discount: "oops" }, defaults);
|
||||||
|
expect(out.discount).toBe(0);
|
||||||
|
expect(out.items).toHaveLength(1);
|
||||||
|
expect(out.items[0]).toMatchObject({ description: "A", mode: "fixed", quantity: 1, hsnSac: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { EMPTY_ITEM } from "./types";
|
||||||
|
import type { InvoiceItem, LineMode, LineUnit } from "./types";
|
||||||
|
|
||||||
|
/** Everything that defines an invoice on the New invoice form. Derived totals are not part of it. */
|
||||||
|
export interface InvoiceFormState {
|
||||||
|
invoiceDate: string;
|
||||||
|
dueDate: string;
|
||||||
|
clientId: number | null;
|
||||||
|
clientName: string;
|
||||||
|
clientAddress: string;
|
||||||
|
clientGstin: string;
|
||||||
|
poNumber: string;
|
||||||
|
placeOfSupply: string;
|
||||||
|
saveClient: boolean;
|
||||||
|
items: InvoiceItem[];
|
||||||
|
discount: number;
|
||||||
|
taxRate: number;
|
||||||
|
reverseCharge: boolean;
|
||||||
|
bankId: number | null;
|
||||||
|
signaturePath: string | null;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = (v: unknown, fallback: string) => (typeof v === "string" ? v : fallback);
|
||||||
|
const num = (v: unknown, fallback: number) =>
|
||||||
|
typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
||||||
|
const bool = (v: unknown, fallback: boolean) => (typeof v === "boolean" ? v : fallback);
|
||||||
|
const nullableNum = (v: unknown, fallback: number | null) =>
|
||||||
|
v === null ? null : typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
||||||
|
|
||||||
|
function itemFromJson(raw: unknown, index: number): InvoiceItem {
|
||||||
|
const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
description: str(r.description, ""),
|
||||||
|
mode: (r.mode === "rate" ? "rate" : "fixed") as LineMode,
|
||||||
|
rate: num(r.rate, EMPTY_ITEM.rate),
|
||||||
|
unit: str(r.unit, EMPTY_ITEM.unit) as LineUnit,
|
||||||
|
quantity: num(r.quantity, EMPTY_ITEM.quantity),
|
||||||
|
amount: num(r.amount, EMPTY_ITEM.amount),
|
||||||
|
sortOrder: index,
|
||||||
|
hsnSac: str(r.hsnSac, ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDraftPayload(state: InvoiceFormState): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
invoiceDate: state.invoiceDate,
|
||||||
|
dueDate: state.dueDate,
|
||||||
|
clientId: state.clientId,
|
||||||
|
clientName: state.clientName,
|
||||||
|
clientAddress: state.clientAddress,
|
||||||
|
clientGstin: state.clientGstin,
|
||||||
|
poNumber: state.poNumber,
|
||||||
|
placeOfSupply: state.placeOfSupply,
|
||||||
|
saveClient: state.saveClient,
|
||||||
|
items: state.items.map((item, index) => ({ ...item, sortOrder: index })),
|
||||||
|
discount: state.discount,
|
||||||
|
taxRate: state.taxRate,
|
||||||
|
reverseCharge: state.reverseCharge,
|
||||||
|
bankId: state.bankId,
|
||||||
|
signaturePath: state.signaturePath,
|
||||||
|
notes: state.notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tolerant: any missing or wrongly typed field falls back to `defaults`. */
|
||||||
|
export function fromDraftPayload(json: unknown, defaults: InvoiceFormState): InvoiceFormState {
|
||||||
|
const r = (json && typeof json === "object" ? json : {}) as Record<string, unknown>;
|
||||||
|
const items = Array.isArray(r.items) && r.items.length > 0 ? r.items.map(itemFromJson) : defaults.items;
|
||||||
|
return {
|
||||||
|
invoiceDate: str(r.invoiceDate, defaults.invoiceDate),
|
||||||
|
dueDate: str(r.dueDate, defaults.dueDate),
|
||||||
|
clientId: nullableNum(r.clientId, defaults.clientId),
|
||||||
|
clientName: str(r.clientName, defaults.clientName),
|
||||||
|
clientAddress: str(r.clientAddress, defaults.clientAddress),
|
||||||
|
clientGstin: str(r.clientGstin, defaults.clientGstin),
|
||||||
|
poNumber: str(r.poNumber, defaults.poNumber),
|
||||||
|
placeOfSupply: str(r.placeOfSupply, defaults.placeOfSupply),
|
||||||
|
saveClient: bool(r.saveClient, defaults.saveClient),
|
||||||
|
items,
|
||||||
|
discount: num(r.discount, defaults.discount),
|
||||||
|
taxRate: num(r.taxRate, defaults.taxRate),
|
||||||
|
reverseCharge: bool(r.reverseCharge, defaults.reverseCharge),
|
||||||
|
bankId: nullableNum(r.bankId, defaults.bankId),
|
||||||
|
signaturePath:
|
||||||
|
r.signaturePath === null || typeof r.signaturePath === "string"
|
||||||
|
? r.signaturePath
|
||||||
|
: defaults.signaturePath,
|
||||||
|
notes: str(r.notes, defaults.notes),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -143,6 +143,11 @@ body {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Views stay mounted while hidden so their state survives navigation.
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.voiced-page {
|
.voiced-page {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
max-width: 1600px;
|
max-width: 1600px;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { formatAmount, formatDate } from "../lib/format";
|
|||||||
import { exportInvoicePdf } from "../lib/pdf";
|
import { exportInvoicePdf } from "../lib/pdf";
|
||||||
import type { InvoiceSummary, Settings } from "../lib/types";
|
import type { InvoiceSummary, Settings } from "../lib/types";
|
||||||
|
|
||||||
export default function InvoiceHistory({ settings }: { settings: Settings }) {
|
export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) {
|
||||||
const [rows, setRows] = useState<InvoiceSummary[]>([]);
|
const [rows, setRows] = useState<InvoiceSummary[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -39,9 +39,10 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The view stays mounted, so refetch every time it is shown again.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
if (active) void load();
|
||||||
}, []);
|
}, [active]);
|
||||||
|
|
||||||
const onExport = async (id: number) => {
|
const onExport = async (id: number) => {
|
||||||
setBusyId(id);
|
setBusyId(id);
|
||||||
|
|||||||
+594
-44
@@ -1,23 +1,45 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
ActionableNotification,
|
||||||
Button,
|
Button,
|
||||||
Column,
|
Column,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Grid,
|
Grid,
|
||||||
|
InlineLoading,
|
||||||
InlineNotification,
|
InlineNotification,
|
||||||
|
Modal,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
Select,
|
Select,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
Tag,
|
||||||
TextArea,
|
TextArea,
|
||||||
TextInput,
|
TextInput,
|
||||||
Toggle,
|
Toggle,
|
||||||
} from "@carbon/react";
|
} from "@carbon/react";
|
||||||
import { Add, Save, TrashCan } from "@carbon/icons-react";
|
import {
|
||||||
|
Add,
|
||||||
|
Copy,
|
||||||
|
DocumentAdd,
|
||||||
|
Download,
|
||||||
|
Keyboard,
|
||||||
|
Renew,
|
||||||
|
Save,
|
||||||
|
TrashCan,
|
||||||
|
} from "@carbon/icons-react";
|
||||||
|
import { confirm } from "@tauri-apps/plugin-dialog";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
|
import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
|
||||||
import type {
|
import type {
|
||||||
BankAccount,
|
BankAccount,
|
||||||
Client,
|
Client,
|
||||||
|
DraftSummary,
|
||||||
|
Invoice,
|
||||||
InvoiceInput,
|
InvoiceInput,
|
||||||
InvoiceItem,
|
InvoiceItem,
|
||||||
LineMode,
|
LineMode,
|
||||||
@@ -30,6 +52,8 @@ import { amountInWords } from "../lib/numberToWords";
|
|||||||
import { gstinFullError } from "../lib/validators";
|
import { gstinFullError } from "../lib/validators";
|
||||||
import { addDays, formatAmount, todayIso } from "../lib/format";
|
import { addDays, formatAmount, todayIso } from "../lib/format";
|
||||||
import { buildPdfProps, exportPdfFromProps } from "../lib/pdf";
|
import { buildPdfProps, exportPdfFromProps } from "../lib/pdf";
|
||||||
|
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
|
||||||
|
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
|
||||||
import { InvoicePreview } from "../components/InvoicePreview";
|
import { InvoicePreview } from "../components/InvoicePreview";
|
||||||
import { ImagePicker } from "../components/ImagePicker";
|
import { ImagePicker } from "../components/ImagePicker";
|
||||||
import type { InvoicePdfProps } from "../pdf/InvoiceDocument";
|
import type { InvoicePdfProps } from "../pdf/InvoiceDocument";
|
||||||
@@ -37,10 +61,43 @@ import type { InvoicePdfProps } from "../pdf/InvoiceDocument";
|
|||||||
interface NewInvoiceProps {
|
interface NewInvoiceProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
onSettingsChange: () => void;
|
onSettingsChange: () => void;
|
||||||
|
/** Whether this view is the one on screen. It stays mounted while hidden. */
|
||||||
|
active: boolean;
|
||||||
|
/** Ask the shell to show this view (used by Ctrl+N from another tab). */
|
||||||
|
onActivate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewInvoice({ settings }: NewInvoiceProps) {
|
const AUTOSAVE_MS = 1000;
|
||||||
|
|
||||||
|
const formatTime = (d: Date) => d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
const formatStamp = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("en-GB")} ${formatTime(d)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NewInvoice({ settings, active, onActivate }: NewInvoiceProps) {
|
||||||
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
|
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
|
||||||
|
const blankState = (): InvoiceFormState => {
|
||||||
|
const t = todayIso();
|
||||||
|
return {
|
||||||
|
invoiceDate: t,
|
||||||
|
dueDate: addDays(t, settings.paymentTermsDays),
|
||||||
|
clientId: null,
|
||||||
|
clientName: "",
|
||||||
|
clientAddress: "",
|
||||||
|
clientGstin: "",
|
||||||
|
poNumber: "",
|
||||||
|
placeOfSupply: settings.vendorStateCode,
|
||||||
|
saveClient: false,
|
||||||
|
items: [newItem()],
|
||||||
|
discount: 0,
|
||||||
|
taxRate: settings.defaultTaxRate,
|
||||||
|
reverseCharge: false,
|
||||||
|
bankId: settings.defaultBankId,
|
||||||
|
signaturePath: settings.signaturePath,
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
};
|
||||||
const today = todayIso();
|
const today = todayIso();
|
||||||
const [invoiceDate, setInvoiceDate] = useState(today);
|
const [invoiceDate, setInvoiceDate] = useState(today);
|
||||||
const [dueDate, setDueDate] = useState(addDays(today, settings.paymentTermsDays));
|
const [dueDate, setDueDate] = useState(addDays(today, settings.paymentTermsDays));
|
||||||
@@ -72,21 +129,43 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [saved, setSaved] = useState<string | null>(null);
|
|
||||||
|
// Issued invoice shown read-only after a successful issue. Its number is consumed, so the
|
||||||
|
// form never issues again until the user starts a new one.
|
||||||
|
const [issued, setIssued] = useState<Invoice | null>(null);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [exportedPath, setExportedPath] = useState<string | null>(null);
|
||||||
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
|
const locked = issued !== null;
|
||||||
|
|
||||||
|
// Drafts.
|
||||||
|
const [draftId, setDraftId] = useState<number | null>(null);
|
||||||
|
const [baseline, setBaseline] = useState<string | null>(null);
|
||||||
|
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
|
||||||
|
const [savedAt, setSavedAt] = useState<Date | null>(null);
|
||||||
|
const [draftSaving, setDraftSaving] = useState(false);
|
||||||
|
const [draftError, setDraftError] = useState<string | null>(null);
|
||||||
|
const [drafts, setDrafts] = useState<DraftSummary[]>([]);
|
||||||
|
const [noticeOpen, setNoticeOpen] = useState(false);
|
||||||
|
const [draftsOpen, setDraftsOpen] = useState(false);
|
||||||
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [next, clientList, bankList] = await Promise.all([
|
const [next, clientList, bankList, draftList] = await Promise.all([
|
||||||
api.peekNextInvoiceNumber(),
|
api.peekNextInvoiceNumber(),
|
||||||
api.listClients(),
|
api.listClients(),
|
||||||
api.listBanks(),
|
api.listBanks(),
|
||||||
|
api.listDrafts(),
|
||||||
]);
|
]);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setNumber(next);
|
setNumber(next);
|
||||||
setClients(clientList);
|
setClients(clientList);
|
||||||
setBanks(bankList);
|
setBanks(bankList);
|
||||||
|
setDrafts(draftList);
|
||||||
|
setNoticeOpen(draftList.length > 0);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (active) setError(String(e));
|
if (active) setError(String(e));
|
||||||
}
|
}
|
||||||
@@ -135,12 +214,128 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
const words = useMemo(() => amountInWords(totals.total), [totals.total]);
|
const words = useMemo(() => amountInWords(totals.total), [totals.total]);
|
||||||
const bank = useMemo(() => banks.find((b) => b.id === bankId) ?? null, [banks, bankId]);
|
const bank = useMemo(() => banks.find((b) => b.id === bankId) ?? null, [banks, bankId]);
|
||||||
|
|
||||||
|
const payload = useMemo(
|
||||||
|
() =>
|
||||||
|
toDraftPayload({
|
||||||
|
invoiceDate,
|
||||||
|
dueDate,
|
||||||
|
clientId,
|
||||||
|
clientName,
|
||||||
|
clientAddress,
|
||||||
|
clientGstin,
|
||||||
|
poNumber,
|
||||||
|
placeOfSupply,
|
||||||
|
saveClient,
|
||||||
|
items,
|
||||||
|
discount,
|
||||||
|
taxRate,
|
||||||
|
reverseCharge,
|
||||||
|
bankId,
|
||||||
|
signaturePath,
|
||||||
|
notes,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
invoiceDate,
|
||||||
|
dueDate,
|
||||||
|
clientId,
|
||||||
|
clientName,
|
||||||
|
clientAddress,
|
||||||
|
clientGstin,
|
||||||
|
poNumber,
|
||||||
|
placeOfSupply,
|
||||||
|
saveClient,
|
||||||
|
items,
|
||||||
|
discount,
|
||||||
|
taxRate,
|
||||||
|
reverseCharge,
|
||||||
|
bankId,
|
||||||
|
signaturePath,
|
||||||
|
notes,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const snapshot = useMemo(() => JSON.stringify(payload), [payload]);
|
||||||
|
|
||||||
|
// The untouched form is the baseline; anything else is dirty. A pristine form is never saved.
|
||||||
|
useEffect(() => {
|
||||||
|
setBaseline((current) => current ?? snapshot);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
const dirty = baseline !== null && snapshot !== baseline;
|
||||||
|
const unsaved = !locked && dirty && snapshot !== savedSnapshot;
|
||||||
|
|
||||||
|
const latest = useRef({ snapshot, payload });
|
||||||
|
latest.current = { snapshot, payload };
|
||||||
|
const draftIdRef = useRef<number | null>(null);
|
||||||
|
const savedRef = useRef<string | null>(null);
|
||||||
|
const issuedRef = useRef(false);
|
||||||
|
const chain = useRef<Promise<void>>(Promise.resolve());
|
||||||
|
|
||||||
|
const refreshDrafts = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setDrafts(await api.listDrafts());
|
||||||
|
} catch {
|
||||||
|
/* the list is a convenience; ignore */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshNumber = async () => {
|
||||||
|
try {
|
||||||
|
setNumber(await api.peekNextInvoiceNumber());
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Saves are serialised so a slow first save cannot race a second one into creating two drafts.
|
||||||
|
const persistDraft = useCallback((): Promise<void> => {
|
||||||
|
const run = async () => {
|
||||||
|
if (issuedRef.current) return;
|
||||||
|
const { snapshot: snap, payload: body } = latest.current;
|
||||||
|
if (snap === savedRef.current) return;
|
||||||
|
setDraftSaving(true);
|
||||||
|
try {
|
||||||
|
const id = await api.saveDraft(draftIdRef.current, body);
|
||||||
|
draftIdRef.current = id;
|
||||||
|
savedRef.current = snap;
|
||||||
|
setDraftId(id);
|
||||||
|
setSavedSnapshot(snap);
|
||||||
|
setSavedAt(new Date());
|
||||||
|
setDraftError(null);
|
||||||
|
} finally {
|
||||||
|
setDraftSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const next = chain.current.then(run);
|
||||||
|
chain.current = next.catch(() => {});
|
||||||
|
return next;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!unsaved) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
persistDraft()
|
||||||
|
.then(refreshDrafts)
|
||||||
|
.catch((e) => setDraftError(String(e)));
|
||||||
|
}, AUTOSAVE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [unsaved, snapshot, persistDraft, refreshDrafts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!unsaved) return;
|
||||||
|
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "";
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", onBeforeUnload);
|
||||||
|
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||||
|
}, [unsaved]);
|
||||||
|
|
||||||
const previewProps: InvoicePdfProps = {
|
const previewProps: InvoicePdfProps = {
|
||||||
vendor: settings,
|
vendor: settings,
|
||||||
bank,
|
bank,
|
||||||
logoDataUri: logoUri,
|
logoDataUri: logoUri,
|
||||||
signatureDataUri: signatureUri,
|
signatureDataUri: signatureUri,
|
||||||
number: number || "DRAFT",
|
number: issued?.number ?? (number || "DRAFT"),
|
||||||
invoiceDate,
|
invoiceDate,
|
||||||
dueDate,
|
dueDate,
|
||||||
clientName,
|
clientName,
|
||||||
@@ -177,35 +372,182 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
if (selected.stateCode) setPlaceOfSupply(selected.stateCode);
|
if (selected.stateCode) setPlaceOfSupply(selected.stateCode);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForNext = async () => {
|
const applyState = (s: InvoiceFormState) => {
|
||||||
setClientId(null);
|
setInvoiceDate(s.invoiceDate);
|
||||||
setClientName("");
|
setDueDate(s.dueDate);
|
||||||
setClientAddress("");
|
setClientId(s.clientId);
|
||||||
setClientGstin("");
|
setClientName(s.clientName);
|
||||||
setPoNumber("");
|
setClientAddress(s.clientAddress);
|
||||||
setSaveClient(false);
|
setClientGstin(s.clientGstin);
|
||||||
setItems([newItem()]);
|
setPoNumber(s.poNumber);
|
||||||
setDiscount(0);
|
setPlaceOfSupply(s.placeOfSupply);
|
||||||
setNotes("");
|
setSaveClient(s.saveClient);
|
||||||
setInvoiceDate(todayIso());
|
setItems(s.items);
|
||||||
setDueDate(addDays(todayIso(), settings.paymentTermsDays));
|
setDiscount(s.discount);
|
||||||
|
setTaxRate(s.taxRate);
|
||||||
|
setReverseCharge(s.reverseCharge);
|
||||||
|
setBankId(s.bankId);
|
||||||
|
setSignaturePath(s.signaturePath);
|
||||||
|
setNotes(s.notes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearIssued = () => {
|
||||||
|
issuedRef.current = false;
|
||||||
|
setIssued(null);
|
||||||
|
setExportedPath(null);
|
||||||
|
setExportError(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const detachDraft = () => {
|
||||||
|
draftIdRef.current = null;
|
||||||
|
savedRef.current = null;
|
||||||
|
setDraftId(null);
|
||||||
|
setSavedSnapshot(null);
|
||||||
|
setSavedAt(null);
|
||||||
|
setDraftError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
const blank = blankState();
|
||||||
|
applyState(blank);
|
||||||
|
setBaseline(JSON.stringify(toDraftPayload(blank)));
|
||||||
|
clearIssued();
|
||||||
|
detachDraft();
|
||||||
|
void refreshNumber();
|
||||||
|
api.listClients().then(setClients).catch(() => {});
|
||||||
|
void refreshDrafts();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keeps the work in a draft before it is replaced. Returns false if that was not possible
|
||||||
|
// and the user chose to keep the current form.
|
||||||
|
const parkCurrentWork = async (): Promise<boolean> => {
|
||||||
|
if (locked || !unsaved) return true;
|
||||||
try {
|
try {
|
||||||
setNumber(await api.peekNextInvoiceNumber());
|
await persistDraft();
|
||||||
setClients(await api.listClients());
|
return true;
|
||||||
} catch {
|
} catch (e) {
|
||||||
/* ignore */
|
return confirm(`The current form could not be saved as a draft (${String(e)}). Discard it?`, {
|
||||||
|
title: "Unsaved changes",
|
||||||
|
kind: "warning",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const newInvoice = async () => {
|
||||||
|
onActivate();
|
||||||
|
if (!(await parkCurrentWork())) return;
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const duplicateAsNew = () => {
|
||||||
|
const t = todayIso();
|
||||||
|
clearIssued();
|
||||||
|
detachDraft();
|
||||||
|
setInvoiceDate(t);
|
||||||
|
setDueDate(addDays(t, settings.paymentTermsDays));
|
||||||
|
setSaveClient(false);
|
||||||
|
void refreshNumber();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveDraftNow = async () => {
|
||||||
|
if (locked || !dirty) return;
|
||||||
|
try {
|
||||||
|
await persistDraft();
|
||||||
|
await refreshDrafts();
|
||||||
|
} catch (e) {
|
||||||
|
setDraftError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const discardDraft = async () => {
|
||||||
|
const ok = await confirm(
|
||||||
|
draftId !== null
|
||||||
|
? "Discard this draft and clear the form?"
|
||||||
|
: "Clear the form? Nothing has been saved as a draft.",
|
||||||
|
{ title: "Discard draft", kind: "warning" },
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
// Wait for any in-flight save so it cannot recreate the draft after the delete.
|
||||||
|
await chain.current;
|
||||||
|
const id = draftIdRef.current;
|
||||||
|
try {
|
||||||
|
if (id !== null) await api.deleteDraft(id);
|
||||||
|
} catch (e) {
|
||||||
|
setDraftError(String(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumeDraft = async (id: number) => {
|
||||||
|
if (id === draftIdRef.current) {
|
||||||
|
setDraftsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await parkCurrentWork())) return;
|
||||||
|
try {
|
||||||
|
const restored = fromDraftPayload(await api.getDraft(id), blankState());
|
||||||
|
if (restored.bankId !== null && !banks.some((b) => b.id === restored.bankId)) {
|
||||||
|
restored.bankId = null;
|
||||||
|
}
|
||||||
|
applyState(restored);
|
||||||
|
setBaseline(JSON.stringify(toDraftPayload(blankState())));
|
||||||
|
clearIssued();
|
||||||
|
const snap = JSON.stringify(toDraftPayload(restored));
|
||||||
|
draftIdRef.current = id;
|
||||||
|
savedRef.current = snap;
|
||||||
|
setDraftId(id);
|
||||||
|
setSavedSnapshot(snap);
|
||||||
|
setSavedAt(null);
|
||||||
|
setDraftError(null);
|
||||||
|
setNoticeOpen(false);
|
||||||
|
setDraftsOpen(false);
|
||||||
|
void refreshNumber();
|
||||||
|
void refreshDrafts();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDraft = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await api.deleteDraft(id);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
await refreshDrafts();
|
||||||
|
};
|
||||||
|
|
||||||
|
const runExport = async (invoice: Invoice) => {
|
||||||
|
setExporting(true);
|
||||||
|
setExportError(null);
|
||||||
|
try {
|
||||||
|
const props = await buildPdfProps(invoice, settings, bank);
|
||||||
|
const path = await exportPdfFromProps(props, invoice.number);
|
||||||
|
setExportedPath(path ?? null);
|
||||||
|
} catch (e) {
|
||||||
|
setExportError(String(e));
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSave = async () => {
|
const onSave = async () => {
|
||||||
|
if (locked || saving) return;
|
||||||
if (clientGstinError) {
|
if (clientGstinError) {
|
||||||
setError(`Client GSTIN: ${clientGstinError}`);
|
setError(`Client GSTIN: ${clientGstinError}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSaved(null);
|
setExportedPath(null);
|
||||||
|
setExportError(null);
|
||||||
|
let invoice: Invoice;
|
||||||
try {
|
try {
|
||||||
|
// Let a pending autosave land first so the issue call knows which draft to delete.
|
||||||
|
await chain.current;
|
||||||
|
issuedRef.current = true;
|
||||||
const input: InvoiceInput = {
|
const input: InvoiceInput = {
|
||||||
invoiceDate,
|
invoiceDate,
|
||||||
dueDate,
|
dueDate,
|
||||||
@@ -223,52 +565,161 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
signaturePath,
|
signaturePath,
|
||||||
notes,
|
notes,
|
||||||
saveClient,
|
saveClient,
|
||||||
draftId: null,
|
draftId: draftIdRef.current,
|
||||||
items: items.map((item, index) => ({ ...item, sortOrder: index })),
|
items: items.map((item, index) => ({ ...item, sortOrder: index })),
|
||||||
};
|
};
|
||||||
const invoice = await api.issueInvoice(input, {});
|
invoice = await api.issueInvoice(input, {});
|
||||||
const props = await buildPdfProps(invoice, settings, bank);
|
|
||||||
const path = await exportPdfFromProps(props, invoice.number);
|
|
||||||
if (path) setSaved(path);
|
|
||||||
await resetForNext();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
issuedRef.current = false;
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// The number is consumed from here on. A failed export must never lead to a second issue.
|
||||||
|
setIssued(invoice);
|
||||||
|
detachDraft();
|
||||||
|
api.listClients().then(setClients).catch(() => {});
|
||||||
|
void refreshDrafts();
|
||||||
|
await runExport(invoice);
|
||||||
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useShortcuts({
|
||||||
|
onNew: () => void newInvoice(),
|
||||||
|
onSaveDraft: () => active && void saveDraftNow(),
|
||||||
|
onIssue: () => active && !locked && void onSave(),
|
||||||
|
onAddLine: () => active && !locked && addItem(),
|
||||||
|
onHelp: () => active && setHelpOpen(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const otherDrafts = drafts.filter((d) => d.id !== draftId);
|
||||||
|
const busy = saving || exporting;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="voiced-page">
|
<>
|
||||||
|
<div className="voiced-page" hidden={!active}>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Column sm={4} md={8} lg={16}>
|
<Column sm={4} md={8} lg={16}>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", gap: "1rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", gap: "1rem", flexWrap: "wrap" }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="voiced-page__title">New invoice</h1>
|
<h1 className="voiced-page__title">{locked ? `Invoice ${issued.number}` : "New invoice"}</h1>
|
||||||
<p className="voiced-page__subtitle" style={{ marginBottom: 0 }}>
|
<p className="voiced-page__subtitle" style={{ marginBottom: 0 }}>
|
||||||
|
{locked ? (
|
||||||
|
"Issued and read-only. Use the actions below to continue."
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
Next number: <strong>{number || "—"}</strong>
|
Next number: <strong>{number || "—"}</strong>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="voiced-header-action">
|
{locked ? null : (
|
||||||
<Button kind="ghost" onClick={resetForNext} disabled={saving}>
|
<div className="voiced-header-action" style={{ flexWrap: "wrap" }}>
|
||||||
Clear
|
{draftSaving ? (
|
||||||
|
<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>
|
</Button>
|
||||||
<Button renderIcon={Save} onClick={onSave} disabled={saving}>
|
) : 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"}
|
{saving ? "Saving…" : "Save & export PDF"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<InlineNotification kind="error" lowContrast title="Could not save invoice" subtitle={error} />
|
<InlineNotification
|
||||||
|
kind="error"
|
||||||
|
lowContrast
|
||||||
|
title="Could not save invoice"
|
||||||
|
subtitle={error}
|
||||||
|
onCloseButtonClick={() => setError(null)}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{saved ? (
|
{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
|
<InlineNotification
|
||||||
kind="success"
|
kind="success"
|
||||||
lowContrast
|
lowContrast
|
||||||
title="Invoice saved"
|
hideCloseButton
|
||||||
subtitle={saved}
|
title={`Invoice ${issued.number} issued`}
|
||||||
onCloseButtonClick={() => setSaved(null)}
|
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}
|
) : null}
|
||||||
<div style={{ marginTop: "1.5rem" }} />
|
<div style={{ marginTop: "1.5rem" }} />
|
||||||
</Column>
|
</Column>
|
||||||
@@ -278,6 +729,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<Column sm={4} md={4} lg={5}>
|
<Column sm={4} md={4} lg={5}>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="invoiceDate"
|
id="invoiceDate"
|
||||||
|
disabled={locked}
|
||||||
type="date"
|
type="date"
|
||||||
labelText="Invoice date"
|
labelText="Invoice date"
|
||||||
value={invoiceDate}
|
value={invoiceDate}
|
||||||
@@ -290,6 +742,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<Column sm={4} md={4} lg={5}>
|
<Column sm={4} md={4} lg={5}>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="dueDate"
|
id="dueDate"
|
||||||
|
disabled={locked}
|
||||||
type="date"
|
type="date"
|
||||||
labelText={`Due date (net ${settings.paymentTermsDays} days)`}
|
labelText={`Due date (net ${settings.paymentTermsDays} days)`}
|
||||||
value={dueDate}
|
value={dueDate}
|
||||||
@@ -297,22 +750,25 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
<Column sm={4} md={8} lg={6}>
|
<Column sm={4} md={8} lg={6}>
|
||||||
<TextInput id="invoiceNumber" labelText="Invoice number" value={number} readOnly />
|
<TextInput id="invoiceNumber" labelText="Invoice number" value={issued?.number ?? number} readOnly />
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column sm={4} md={8} lg={8}>
|
<Column sm={4} md={8} lg={8}>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
id="savedClient"
|
id="savedClient"
|
||||||
|
disabled={locked}
|
||||||
titleText="Saved client (optional)"
|
titleText="Saved client (optional)"
|
||||||
label="Select a saved client to autofill"
|
label="Select a saved client to autofill"
|
||||||
items={clients}
|
items={clients}
|
||||||
itemToString={(item) => (item ? item.name : "")}
|
itemToString={(item) => (item ? item.name : "")}
|
||||||
|
selectedItem={clients.find((c) => c.id === clientId) ?? null}
|
||||||
onChange={({ selectedItem }) => selectedItem && applyClient(selectedItem)}
|
onChange={({ selectedItem }) => selectedItem && applyClient(selectedItem)}
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
<Column sm={4} md={8} lg={8}>
|
<Column sm={4} md={8} lg={8}>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="clientName"
|
id="clientName"
|
||||||
|
disabled={locked}
|
||||||
labelText="Client name"
|
labelText="Client name"
|
||||||
value={clientName}
|
value={clientName}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -324,6 +780,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<Column sm={4} md={8} lg={16}>
|
<Column sm={4} md={8} lg={16}>
|
||||||
<TextArea
|
<TextArea
|
||||||
id="clientAddress"
|
id="clientAddress"
|
||||||
|
disabled={locked}
|
||||||
labelText="Client address"
|
labelText="Client address"
|
||||||
rows={3}
|
rows={3}
|
||||||
value={clientAddress}
|
value={clientAddress}
|
||||||
@@ -333,6 +790,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<Column sm={4} md={4} lg={5}>
|
<Column sm={4} md={4} lg={5}>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="clientGstin"
|
id="clientGstin"
|
||||||
|
disabled={locked}
|
||||||
labelText="Client GSTIN (optional)"
|
labelText="Client GSTIN (optional)"
|
||||||
value={clientGstin}
|
value={clientGstin}
|
||||||
onChange={(e) => setClientGstin(e.target.value.toUpperCase())}
|
onChange={(e) => setClientGstin(e.target.value.toUpperCase())}
|
||||||
@@ -343,6 +801,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<Column sm={4} md={4} lg={5}>
|
<Column sm={4} md={4} lg={5}>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="poNumber"
|
id="poNumber"
|
||||||
|
disabled={locked}
|
||||||
labelText="PO number (optional)"
|
labelText="PO number (optional)"
|
||||||
value={poNumber}
|
value={poNumber}
|
||||||
onChange={(e) => setPoNumber(e.target.value)}
|
onChange={(e) => setPoNumber(e.target.value)}
|
||||||
@@ -351,6 +810,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<Column sm={4} md={8} lg={6}>
|
<Column sm={4} md={8} lg={6}>
|
||||||
<Select
|
<Select
|
||||||
id="placeOfSupply"
|
id="placeOfSupply"
|
||||||
|
disabled={locked}
|
||||||
labelText="Place of supply"
|
labelText="Place of supply"
|
||||||
value={placeOfSupply}
|
value={placeOfSupply}
|
||||||
onChange={(e) => setPlaceOfSupply(e.target.value)}
|
onChange={(e) => setPlaceOfSupply(e.target.value)}
|
||||||
@@ -363,6 +823,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<Column sm={4} md={8} lg={16}>
|
<Column sm={4} md={8} lg={16}>
|
||||||
<Toggle
|
<Toggle
|
||||||
id="saveClient"
|
id="saveClient"
|
||||||
|
disabled={locked}
|
||||||
labelText="Save this client for next time"
|
labelText="Save this client for next time"
|
||||||
toggled={saveClient}
|
toggled={saveClient}
|
||||||
onToggle={setSaveClient}
|
onToggle={setSaveClient}
|
||||||
@@ -388,6 +849,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<div style={{ flex: "3 1 240px" }}>
|
<div style={{ flex: "3 1 240px" }}>
|
||||||
<TextInput
|
<TextInput
|
||||||
id={`desc-${index}`}
|
id={`desc-${index}`}
|
||||||
|
disabled={locked}
|
||||||
labelText="Particulars / description"
|
labelText="Particulars / description"
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChange={(e) => updateItem(index, { description: e.target.value })}
|
onChange={(e) => updateItem(index, { description: e.target.value })}
|
||||||
@@ -397,6 +859,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<div style={{ flex: "0 1 120px" }}>
|
<div style={{ flex: "0 1 120px" }}>
|
||||||
<TextInput
|
<TextInput
|
||||||
id={`hsn-${index}`}
|
id={`hsn-${index}`}
|
||||||
|
disabled={locked}
|
||||||
labelText="HSN/SAC"
|
labelText="HSN/SAC"
|
||||||
value={item.hsnSac ?? ""}
|
value={item.hsnSac ?? ""}
|
||||||
onChange={(e) => updateItem(index, { hsnSac: e.target.value })}
|
onChange={(e) => updateItem(index, { hsnSac: e.target.value })}
|
||||||
@@ -406,6 +869,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<div style={{ flex: "0 1 150px" }}>
|
<div style={{ flex: "0 1 150px" }}>
|
||||||
<Select
|
<Select
|
||||||
id={`mode-${index}`}
|
id={`mode-${index}`}
|
||||||
|
disabled={locked}
|
||||||
labelText="Type"
|
labelText="Type"
|
||||||
value={item.mode}
|
value={item.mode}
|
||||||
onChange={(e) => updateItem(index, { mode: e.target.value as LineMode })}
|
onChange={(e) => updateItem(index, { mode: e.target.value as LineMode })}
|
||||||
@@ -419,6 +883,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<div style={{ flex: "0 1 130px" }}>
|
<div style={{ flex: "0 1 130px" }}>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
id={`rate-${index}`}
|
id={`rate-${index}`}
|
||||||
|
disabled={locked}
|
||||||
label="Rate (₹)"
|
label="Rate (₹)"
|
||||||
min={0}
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
@@ -429,6 +894,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<div style={{ flex: "0 1 140px" }}>
|
<div style={{ flex: "0 1 140px" }}>
|
||||||
<Select
|
<Select
|
||||||
id={`unit-${index}`}
|
id={`unit-${index}`}
|
||||||
|
disabled={locked}
|
||||||
labelText="Unit"
|
labelText="Unit"
|
||||||
value={item.unit}
|
value={item.unit}
|
||||||
onChange={(e) => updateItem(index, { unit: e.target.value as LineUnit })}
|
onChange={(e) => updateItem(index, { unit: e.target.value as LineUnit })}
|
||||||
@@ -441,6 +907,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<div style={{ flex: "0 1 110px" }}>
|
<div style={{ flex: "0 1 110px" }}>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
id={`qty-${index}`}
|
id={`qty-${index}`}
|
||||||
|
disabled={locked}
|
||||||
label="Quantity"
|
label="Quantity"
|
||||||
min={0}
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
@@ -453,6 +920,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<div style={{ flex: "0 1 150px" }}>
|
<div style={{ flex: "0 1 150px" }}>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
id={`amount-${index}`}
|
id={`amount-${index}`}
|
||||||
|
disabled={locked}
|
||||||
label="Amount (₹)"
|
label="Amount (₹)"
|
||||||
min={0}
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
@@ -471,11 +939,11 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
renderIcon={TrashCan}
|
renderIcon={TrashCan}
|
||||||
iconDescription="Remove line"
|
iconDescription="Remove line"
|
||||||
onClick={() => removeItem(index)}
|
onClick={() => removeItem(index)}
|
||||||
disabled={items.length === 1}
|
disabled={locked || items.length === 1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button kind="tertiary" size="sm" renderIcon={Add} onClick={addItem}>
|
<Button kind="tertiary" size="sm" renderIcon={Add} onClick={addItem} disabled={locked}>
|
||||||
Add line
|
Add line
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -484,6 +952,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<Column sm={4} md={4} lg={8}>
|
<Column sm={4} md={4} lg={8}>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
id="discount"
|
id="discount"
|
||||||
|
disabled={locked}
|
||||||
label="Discount (₹)"
|
label="Discount (₹)"
|
||||||
min={0}
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
@@ -503,6 +972,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<Column sm={4} md={4} lg={8}>
|
<Column sm={4} md={4} lg={8}>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
id="taxRate"
|
id="taxRate"
|
||||||
|
disabled={locked}
|
||||||
label="Tax rate (%)"
|
label="Tax rate (%)"
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
@@ -514,6 +984,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
<Column sm={4} md={8} lg={16}>
|
<Column sm={4} md={8} lg={16}>
|
||||||
<Toggle
|
<Toggle
|
||||||
id="reverseCharge"
|
id="reverseCharge"
|
||||||
|
disabled={locked}
|
||||||
labelText="Tax payable on reverse charge"
|
labelText="Tax payable on reverse charge"
|
||||||
toggled={reverseCharge}
|
toggled={reverseCharge}
|
||||||
onToggle={setReverseCharge}
|
onToggle={setReverseCharge}
|
||||||
@@ -525,6 +996,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
|
|
||||||
<Select
|
<Select
|
||||||
id="bankAccount"
|
id="bankAccount"
|
||||||
|
disabled={locked}
|
||||||
labelText="Bank account (Pay To)"
|
labelText="Bank account (Pay To)"
|
||||||
value={bankId ?? ""}
|
value={bankId ?? ""}
|
||||||
onChange={(e) => setBankId(e.target.value ? Number(e.target.value) : null)}
|
onChange={(e) => setBankId(e.target.value ? Number(e.target.value) : null)}
|
||||||
@@ -544,10 +1016,12 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
label="Signature for this invoice (optional)"
|
label="Signature for this invoice (optional)"
|
||||||
value={signaturePath}
|
value={signaturePath}
|
||||||
onChange={setSignaturePath}
|
onChange={setSignaturePath}
|
||||||
|
disabled={locked}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextArea
|
<TextArea
|
||||||
id="notes"
|
id="notes"
|
||||||
|
disabled={locked}
|
||||||
labelText="Notes (optional)"
|
labelText="Notes (optional)"
|
||||||
rows={2}
|
rows={2}
|
||||||
value={notes}
|
value={notes}
|
||||||
@@ -563,5 +1037,81 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
|||||||
</Column>
|
</Column>
|
||||||
</Grid>
|
</Grid>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={draftsOpen}
|
||||||
|
passiveModal
|
||||||
|
size="md"
|
||||||
|
modalHeading="Unfinished drafts"
|
||||||
|
onRequestClose={() => setDraftsOpen(false)}
|
||||||
|
>
|
||||||
|
<Table size="md" aria-label="Drafts">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeader>Client</TableHeader>
|
||||||
|
<TableHeader>Last edited</TableHeader>
|
||||||
|
<TableHeader>Actions</TableHeader>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{drafts.map((d) => (
|
||||||
|
<TableRow key={d.id}>
|
||||||
|
<TableCell>{d.clientName || "—"}</TableCell>
|
||||||
|
<TableCell>{formatStamp(d.updatedAt)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{d.id === draftId ? (
|
||||||
|
<Tag type="blue" size="sm">
|
||||||
|
Open now
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<div className="voiced-header-action">
|
||||||
|
<Button kind="ghost" size="sm" onClick={() => void resumeDraft(d.id)}>
|
||||||
|
Resume
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
kind="ghost"
|
||||||
|
size="sm"
|
||||||
|
hasIconOnly
|
||||||
|
renderIcon={TrashCan}
|
||||||
|
iconDescription="Delete draft"
|
||||||
|
onClick={() => void removeDraft(d.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{drafts.length === 0 ? <p style={{ marginTop: "1rem" }}>No drafts.</p> : null}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={helpOpen}
|
||||||
|
passiveModal
|
||||||
|
size="sm"
|
||||||
|
modalHeading="Keyboard shortcuts"
|
||||||
|
onRequestClose={() => setHelpOpen(false)}
|
||||||
|
>
|
||||||
|
<Table size="md" aria-label="Keyboard shortcuts">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeader>Keys</TableHeader>
|
||||||
|
<TableHeader>Action</TableHeader>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{SHORTCUT_LIST.map((row) => (
|
||||||
|
<TableRow key={row.keys}>
|
||||||
|
<TableCell>
|
||||||
|
<strong>{row.keys}</strong>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.action}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Column,
|
Column,
|
||||||
@@ -20,7 +20,7 @@ import { confirm } from "@tauri-apps/plugin-dialog";
|
|||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import type { InvoiceSeries } from "../lib/types";
|
import type { InvoiceSeries } from "../lib/types";
|
||||||
|
|
||||||
export default function SeriesSettings() {
|
export default function SeriesSettings({ active: visible }: { active: boolean }) {
|
||||||
const [active, setActive] = useState<InvoiceSeries | null>(null);
|
const [active, setActive] = useState<InvoiceSeries | null>(null);
|
||||||
const [all, setAll] = useState<InvoiceSeries[]>([]);
|
const [all, setAll] = useState<InvoiceSeries[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -31,14 +31,18 @@ export default function SeriesSettings() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const seeded = useRef(false);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const [current, list] = await Promise.all([api.getActiveSeries(), api.listSeries()]);
|
const [current, list] = await Promise.all([api.getActiveSeries(), api.listSeries()]);
|
||||||
setActive(current);
|
setActive(current);
|
||||||
setAll(list);
|
setAll(list);
|
||||||
if (current) {
|
// Only seed the form once, so a refetch on re-entry does not overwrite what was typed.
|
||||||
|
if (current && !seeded.current) {
|
||||||
setPrefix(current.prefix);
|
setPrefix(current.prefix);
|
||||||
setPadding(current.padding);
|
setPadding(current.padding);
|
||||||
|
seeded.current = true;
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -48,9 +52,10 @@ export default function SeriesSettings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The view stays mounted, so refetch every time it is shown again.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
if (visible) void load();
|
||||||
}, []);
|
}, [visible]);
|
||||||
|
|
||||||
const startNew = async () => {
|
const startNew = async () => {
|
||||||
const ok = await confirm(
|
const ok = await confirm(
|
||||||
|
|||||||
Reference in New Issue
Block a user