Add toasts, Settings tabs, Clients page and item presets (Phase E1)
- Global Carbon toast stack replaces scattered inline notices; errors persist until closed. - Settings is split into eight tabs with one Save and a remembered last tab; Numbering embeds the series settings. - New Clients page with a DataTable, shared add/edit modal, structured addresses, GST category (informational), per-client notes and payment terms. The composed address string is kept for back-compat. Deleting a client warns about invoice count; issued invoices keep their own snapshot. - Migration M7 adds the client columns and item_presets; existing clients with a GSTIN are backfilled to registered_regular. - New invoice form: client ComboBox with type-ahead and inline create, client defaults applied on selection, and an Add from preset control. - Rust tests for M7 (fresh and v6 upgrade), client validation and CRUD, presets CRUD; vitest for the toast store and new helpers. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
This commit is contained in:
+79
-18
@@ -2,6 +2,7 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } fro
|
||||
import {
|
||||
ActionableNotification,
|
||||
Button,
|
||||
ComboBox,
|
||||
Column,
|
||||
Dropdown,
|
||||
Grid,
|
||||
@@ -37,6 +38,7 @@ import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
|
||||
import type {
|
||||
BankAccount,
|
||||
Client,
|
||||
ItemPreset,
|
||||
DraftSummary,
|
||||
Invoice,
|
||||
InvoiceInput,
|
||||
@@ -68,6 +70,9 @@ import { useUserFonts } from "../hooks/useUserFonts";
|
||||
import { withUserFonts } from "../lib/fontImport";
|
||||
import { ImagePicker } from "../components/ImagePicker";
|
||||
import { loadLogoImages, type LogoImages } from "../lib/logo";
|
||||
import ClientModal from "../components/ClientModal";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
import { itemFromPreset } from "../lib/presets";
|
||||
|
||||
// pdf.js is only needed once a preview exists, so it loads on demand.
|
||||
const PdfPreview = lazy(() => import("../components/PdfPreview"));
|
||||
@@ -90,6 +95,7 @@ const formatStamp = (iso: string) => {
|
||||
};
|
||||
|
||||
export default function NewInvoice({ settings, onSettingsChange, active, onActivate }: NewInvoiceProps) {
|
||||
const toast = useToast();
|
||||
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
|
||||
const blankState = (): InvoiceFormState => {
|
||||
const t = todayIso();
|
||||
@@ -118,6 +124,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
const [number, setNumber] = useState("");
|
||||
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [presets, setPresets] = useState<ItemPreset[]>([]);
|
||||
const [clientModalOpen, setClientModalOpen] = useState(false);
|
||||
const newClientButton = useRef<HTMLButtonElement | null>(null);
|
||||
const [clientId, setClientId] = useState<number | null>(null);
|
||||
const [clientName, setClientName] = useState("");
|
||||
const [clientAddress, setClientAddress] = useState("");
|
||||
@@ -188,7 +197,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
setDrafts(draftList);
|
||||
setNoticeOpen(draftList.length > 0);
|
||||
} catch (e) {
|
||||
if (active) setError(String(e));
|
||||
if (active) toast.error("Could not load invoice data", String(e));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -196,6 +205,13 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The view stays mounted, so pick up clients and presets edited on the Clients page when it is shown again.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
api.listClients().then(setClients).catch(() => {});
|
||||
api.listItemPresets().then(setPresets).catch(() => {});
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!settings.logoPath) {
|
||||
@@ -333,6 +349,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
// A failed autosave is reported once per failure streak (the message clears on the next success).
|
||||
useEffect(() => {
|
||||
if (draftError) toast.warning("Draft not saved", draftError);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draftError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!unsaved) return;
|
||||
const timer = setTimeout(() => {
|
||||
@@ -390,6 +412,15 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
setClientGstin(selected.gstin);
|
||||
setPoNumber(selected.poNumber);
|
||||
if (selected.stateCode) setPlaceOfSupply(selected.stateCode);
|
||||
// Client defaults fill gaps only: typed notes are never overwritten.
|
||||
if (selected.defaultNotes && !notes.trim()) setNotes(selected.defaultNotes);
|
||||
if (selected.paymentTermsDays !== null) setDueDate(addDays(invoiceDate, selected.paymentTermsDays));
|
||||
};
|
||||
|
||||
const addPreset = (preset: ItemPreset) => {
|
||||
const line = itemFromPreset(preset, { hsnSac: settings.defaultHsnSac });
|
||||
const isBlank = (i: InvoiceItem) => !i.description.trim() && !i.amount && !i.rate;
|
||||
setItems((prev) => (prev.length === 1 && isBlank(prev[0]) ? [line] : [...prev, line]));
|
||||
};
|
||||
|
||||
const applyState = (s: InvoiceFormState) => {
|
||||
@@ -527,7 +558,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
void refreshNumber();
|
||||
void refreshDrafts();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not open the draft", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -535,7 +566,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
try {
|
||||
await api.deleteDraft(id);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not delete the draft", String(e));
|
||||
}
|
||||
await refreshDrafts();
|
||||
};
|
||||
@@ -596,7 +627,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
outcome = await issueAndArchive(input, activePrefs, createExportDeps({ settings, bank }));
|
||||
} catch (e) {
|
||||
issuedRef.current = false;
|
||||
setError(String(e));
|
||||
toast.error("Could not issue the invoice", String(e));
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
@@ -681,20 +712,11 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
<InlineNotification
|
||||
kind="error"
|
||||
lowContrast
|
||||
title="Could not save invoice"
|
||||
title="Fix this before issuing"
|
||||
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
|
||||
@@ -766,16 +788,30 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
</Column>
|
||||
|
||||
<Column sm={4} md={8} lg={8}>
|
||||
<Dropdown
|
||||
<ComboBox
|
||||
id="savedClient"
|
||||
disabled={locked}
|
||||
titleText="Saved client (optional)"
|
||||
label="Select a saved client to autofill"
|
||||
placeholder="Type to search saved clients"
|
||||
items={clients}
|
||||
itemToString={(item) => (item ? item.name : "")}
|
||||
itemToString={(item: Client | null) => (item ? item.name : "")}
|
||||
selectedItem={clients.find((c) => c.id === clientId) ?? null}
|
||||
onChange={({ selectedItem }) => selectedItem && applyClient(selectedItem)}
|
||||
shouldFilterItem={({ item, inputValue }: { item: Client; inputValue: string | null }) => {
|
||||
const q = (inputValue ?? "").trim().toLowerCase();
|
||||
return !q || item.name.toLowerCase().includes(q) || item.gstin.toLowerCase().includes(q);
|
||||
}}
|
||||
onChange={({ selectedItem }: { selectedItem?: Client | null }) => selectedItem && applyClient(selectedItem)}
|
||||
/>
|
||||
<Button
|
||||
ref={newClientButton}
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={Add}
|
||||
disabled={locked}
|
||||
onClick={() => setClientModalOpen(true)}
|
||||
>
|
||||
Create client
|
||||
</Button>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={8}>
|
||||
<TextInput
|
||||
@@ -845,6 +881,20 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
|
||||
<div>
|
||||
<h3 style={{ marginBottom: "0.75rem" }}>Line items</h3>
|
||||
{presets.length > 0 ? (
|
||||
<div style={{ maxWidth: "24rem", marginBottom: "0.75rem" }}>
|
||||
<Dropdown
|
||||
id="addFromPreset"
|
||||
disabled={locked}
|
||||
titleText="Add from preset"
|
||||
label="Choose a saved item"
|
||||
items={presets}
|
||||
itemToString={(item: ItemPreset | null) => (item ? item.description : "")}
|
||||
selectedItem={null}
|
||||
onChange={({ selectedItem }: { selectedItem?: ItemPreset | null }) => selectedItem && addPreset(selectedItem)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
@@ -1134,6 +1184,17 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Modal>
|
||||
<ClientModal
|
||||
open={clientModalOpen}
|
||||
client={null}
|
||||
launcherRef={newClientButton}
|
||||
onClose={() => setClientModalOpen(false)}
|
||||
onSaved={(saved) => {
|
||||
setClientModalOpen(false);
|
||||
applyClient(saved);
|
||||
api.listClients().then(setClients).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user