Fix dark theme fill, theme toggle visibility and bank account creation
- The Carbon theme class is now also set on html, with a themed background and a full-height root, so the dark theme fills the whole window instead of stopping at the content height. An inline script in index.html applies the saved theme before first paint. - The header theme button gets a tooltip, inline padding and a max width so it is no longer clipped at the window edge. New Invoice also gets a labelled Dark mode toggle wired to the same handler. - A shared BankAccountModal replaces the inline editor in Settings and adds a Create bank account button under the bank selector on New Invoice, which selects the new account on save. Settings keeps one Add bank account button under the table. Bank name and account number are now required.
This commit is contained in:
+11
@@ -6,6 +6,17 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Voiced</title>
|
||||
<script>
|
||||
// Apply the last-used theme before first paint so a dark theme never flashes white.
|
||||
(function () {
|
||||
var t = "g10";
|
||||
try {
|
||||
t = localStorage.getItem("voiced.theme") === "g100" ? "g100" : "g10";
|
||||
} catch (e) {}
|
||||
document.documentElement.classList.add("cds--" + t);
|
||||
document.documentElement.setAttribute("data-carbon-theme", t);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+14
-7
@@ -11,6 +11,7 @@ import Clients from "./views/Clients";
|
||||
import { ToastProvider, useToast } from "./components/ToastProvider";
|
||||
import AppSettings from "./views/AppSettings";
|
||||
import { requestSettingsTab } from "./lib/settingsTab";
|
||||
import { applyDocumentTheme, cachedTheme, normalizeTheme } from "./lib/theme";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -46,9 +47,15 @@ function AppInner() {
|
||||
void refreshSettings();
|
||||
}, [refreshSettings]);
|
||||
|
||||
// Before settings load (and on the error screen) use the cached theme so the window never flashes white.
|
||||
const theme = settings ? normalizeTheme(settings.theme) : cachedTheme();
|
||||
useEffect(() => {
|
||||
applyDocumentTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Theme theme="g10">
|
||||
<Theme theme={theme} className="voiced-theme">
|
||||
<div className="voiced-empty">
|
||||
<Loading description="Loading Voiced" withOverlay={false} small />
|
||||
</div>
|
||||
@@ -58,7 +65,7 @@ function AppInner() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Theme theme="g10">
|
||||
<Theme theme={theme} className="voiced-theme">
|
||||
<div className="voiced-page">
|
||||
<InlineNotification
|
||||
kind="error"
|
||||
@@ -74,11 +81,9 @@ function AppInner() {
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
const theme = settings.theme === "g100" ? "g100" : "g10";
|
||||
|
||||
if (!settings.onboarded) {
|
||||
return (
|
||||
<Theme theme={theme}>
|
||||
<Theme theme={theme} className="voiced-theme">
|
||||
<Onboarding
|
||||
initial={settings}
|
||||
onDone={(next) => {
|
||||
@@ -106,8 +111,8 @@ function AppInner() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Theme theme={theme}>
|
||||
<AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}>
|
||||
<Theme theme={theme} className="voiced-theme">
|
||||
<AppShell view={view} onNavigate={setView} theme={theme} onToggleTheme={toggleTheme}>
|
||||
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
|
||||
<NewInvoice
|
||||
settings={settings}
|
||||
@@ -116,6 +121,8 @@ function AppInner() {
|
||||
onActivate={() => setView("new")}
|
||||
openDraftId={openDraftId}
|
||||
onDraftOpened={() => setOpenDraftId(null)}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
/>
|
||||
<div hidden={view !== "invoices"}>
|
||||
<InvoiceHistory
|
||||
|
||||
@@ -61,7 +61,11 @@ export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: A
|
||||
))}
|
||||
</HeaderNavigation>
|
||||
<HeaderGlobalBar>
|
||||
<HeaderGlobalAction aria-label="Toggle colour theme" onClick={onToggleTheme}>
|
||||
<HeaderGlobalAction
|
||||
aria-label={theme === "g100" ? "Switch to light theme" : "Switch to dark theme"}
|
||||
tooltipAlignment="end"
|
||||
onClick={onToggleTheme}
|
||||
>
|
||||
{theme === "g100" ? <Light size={20} /> : <Asleep size={20} />}
|
||||
</HeaderGlobalAction>
|
||||
</HeaderGlobalBar>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
import { Column, Grid, Modal, TextInput, Toggle } from "@carbon/react";
|
||||
import { api } from "../lib/api";
|
||||
import type { BankAccount } from "../lib/types";
|
||||
import { ifscError } from "../lib/validators";
|
||||
import { useToast } from "./ToastProvider";
|
||||
|
||||
export const blankBank = (): BankAccount => ({
|
||||
id: null,
|
||||
label: "",
|
||||
bankName: "",
|
||||
accountName: "",
|
||||
accountNo: "",
|
||||
branch: "",
|
||||
ifsc: "",
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
interface BankAccountModalProps {
|
||||
open: boolean;
|
||||
/** The account to edit; null opens a blank one. */
|
||||
bank: BankAccount | null;
|
||||
onClose: () => void;
|
||||
onSaved: (bank: BankAccount) => void;
|
||||
/** Focus returns here when the modal closes. */
|
||||
launcherRef?: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export default function BankAccountModal({ open, bank, onClose, onSaved, launcherRef }: BankAccountModalProps) {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState<BankAccount>(blankBank());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setForm(bank ? { ...bank } : blankBank());
|
||||
setBusy(false);
|
||||
setTouched(false);
|
||||
}, [open, bank]);
|
||||
|
||||
const set = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const bankNameProblem = form.bankName.trim() ? undefined : "Bank name is required";
|
||||
const accountNoProblem = form.accountNo.trim() ? undefined : "Account number is required";
|
||||
const ifscProblem = ifscError(form.ifsc);
|
||||
const blocking = bankNameProblem ?? accountNoProblem ?? ifscProblem;
|
||||
|
||||
const submit = async () => {
|
||||
setTouched(true);
|
||||
if (blocking) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const saved = await api.saveBank({
|
||||
...form,
|
||||
bankName: form.bankName.trim(),
|
||||
accountNo: form.accountNo.trim(),
|
||||
ifsc: form.ifsc.trim().toUpperCase(),
|
||||
});
|
||||
toast.success(form.id === null ? "Bank account added" : "Bank account saved", saved.bankName);
|
||||
onSaved(saved);
|
||||
} catch (e) {
|
||||
toast.error("Could not save bank account", String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
modalHeading={bank?.id ? "Edit bank account" : "New bank account"}
|
||||
primaryButtonText={busy ? "Saving…" : "Save account"}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={busy}
|
||||
onRequestClose={onClose}
|
||||
onRequestSubmit={() => void submit()}
|
||||
launcherButtonRef={launcherRef}
|
||||
size="md"
|
||||
>
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="bm-label"
|
||||
labelText="Label (optional)"
|
||||
value={form.label}
|
||||
onChange={(e) => set("label", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="bm-bankName"
|
||||
labelText="Bank name"
|
||||
value={form.bankName}
|
||||
onChange={(e) => set("bankName", e.target.value)}
|
||||
invalid={touched && Boolean(bankNameProblem)}
|
||||
invalidText={bankNameProblem}
|
||||
data-modal-primary-focus
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="bm-accountName"
|
||||
labelText="Account name"
|
||||
value={form.accountName}
|
||||
onChange={(e) => set("accountName", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="bm-accountNo"
|
||||
labelText="Account number"
|
||||
value={form.accountNo}
|
||||
onChange={(e) => set("accountNo", e.target.value)}
|
||||
invalid={touched && Boolean(accountNoProblem)}
|
||||
invalidText={accountNoProblem}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="bm-branch"
|
||||
labelText="Branch"
|
||||
value={form.branch}
|
||||
onChange={(e) => set("branch", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="bm-ifsc"
|
||||
labelText="IFSC"
|
||||
value={form.ifsc}
|
||||
onChange={(e) => set("ifsc", e.target.value.toUpperCase())}
|
||||
invalid={Boolean(ifscProblem)}
|
||||
invalidText={ifscProblem}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<Toggle
|
||||
id="bm-default"
|
||||
labelText="Use as default account"
|
||||
toggled={form.isDefault}
|
||||
onToggle={(value) => set("isDefault", value)}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export type ThemeName = "g10" | "g100";
|
||||
|
||||
const STORAGE_KEY = "voiced.theme";
|
||||
|
||||
export const normalizeTheme = (value: string | null | undefined): ThemeName => (value === "g100" ? "g100" : "g10");
|
||||
|
||||
/** The theme last applied on this machine; used before settings load so there is no light flash. */
|
||||
export function cachedTheme(): ThemeName {
|
||||
try {
|
||||
return normalizeTheme(localStorage.getItem(STORAGE_KEY));
|
||||
} catch {
|
||||
return "g10";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the Carbon theme tokens on <html> as well as on the <Theme> zone, so the page canvas, the
|
||||
* overscroll area and any window area below the content follow the chosen theme.
|
||||
* index.html runs the same class switch before React mounts.
|
||||
*/
|
||||
export function applyDocumentTheme(theme: ThemeName): void {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("cds--g10", "cds--g100");
|
||||
root.classList.add(`cds--${theme}`);
|
||||
root.dataset.carbonTheme = theme;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
} catch {
|
||||
/* storage may be blocked; the class is already applied */
|
||||
}
|
||||
}
|
||||
+35
-4
@@ -100,18 +100,49 @@
|
||||
}
|
||||
|
||||
/* ---------- App shell ---------- */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
// The Carbon theme tokens are also set on <html> (see lib/theme.ts), so the canvas follows the theme
|
||||
// even where the <Theme> zone does not reach (below short content, overscroll, window resize).
|
||||
html {
|
||||
min-height: 100%;
|
||||
background-color: var(--cds-background);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html.cds--g100 {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background-color: var(--cds-background);
|
||||
color: var(--cds-text-primary);
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// The theme zone fills the window, so the dark theme covers it entirely.
|
||||
.voiced-theme {
|
||||
flex: 1 0 auto;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background-color: var(--cds-background);
|
||||
color: var(--cds-text-primary);
|
||||
}
|
||||
|
||||
// Keep the header actions inside the window edges.
|
||||
.cds--header {
|
||||
box-sizing: border-box;
|
||||
max-width: 100vw;
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
|
||||
.voiced-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+30
-106
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
import {
|
||||
Button,
|
||||
Column,
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
type Settings,
|
||||
type TaxType,
|
||||
} from "../lib/types";
|
||||
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
|
||||
import { emailError, panError, phoneError, vendorGstinError } from "../lib/validators";
|
||||
import { ImagePicker } from "../components/ImagePicker";
|
||||
import { LogoBranding } from "../components/LogoBranding";
|
||||
import DataBackupPanel from "../components/DataBackupPanel";
|
||||
@@ -45,19 +45,9 @@ import ErpnextSettingsPanel from "../components/ErpnextSettingsPanel";
|
||||
import { FontOverrideTable } from "../components/FontOverrideTable";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
import { SETTINGS_TABS, loadSettingsTab, saveSettingsTab } from "../lib/settingsTab";
|
||||
import BankAccountModal from "../components/BankAccountModal";
|
||||
import SeriesSettings from "./SeriesSettings";
|
||||
|
||||
const blankBank = (): BankAccount => ({
|
||||
id: null,
|
||||
label: "",
|
||||
bankName: "",
|
||||
accountName: "",
|
||||
accountNo: "",
|
||||
branch: "",
|
||||
ifsc: "",
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
export default function AppSettings({
|
||||
settings,
|
||||
onSaved,
|
||||
@@ -68,7 +58,9 @@ export default function AppSettings({
|
||||
const [form, setForm] = useState<Settings>({ ...settings });
|
||||
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
||||
const [banks, setBanks] = useState<BankAccount[]>([]);
|
||||
const [bankModalOpen, setBankModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<BankAccount | null>(null);
|
||||
const bankLauncher = useRef<HTMLElement | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
// One form state for every tab and one Save button, so edits survive switching tabs.
|
||||
@@ -94,9 +86,6 @@ export default function AppSettings({
|
||||
const set = <K extends keyof Settings>(key: K, value: Settings[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const setBank = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
|
||||
setEditing((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||
|
||||
const registered = form.gstRegistration !== "unregistered";
|
||||
const gstinProblem = vendorGstinError(
|
||||
form.gstRegistration,
|
||||
@@ -123,22 +112,19 @@ export default function AppSettings({
|
||||
}
|
||||
};
|
||||
|
||||
const saveBank = async () => {
|
||||
if (!editing) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const saved = await api.saveBank(editing);
|
||||
const openBankEditor = (bank: BankAccount | null, trigger: HTMLElement) => {
|
||||
bankLauncher.current = trigger;
|
||||
setEditing(bank);
|
||||
setBankModalOpen(true);
|
||||
};
|
||||
|
||||
const onBankSaved = (saved: BankAccount) => {
|
||||
if (saved.isDefault || form.defaultBankId === null) {
|
||||
setForm((prev) => ({ ...prev, defaultBankId: saved.id }));
|
||||
}
|
||||
setEditing(null);
|
||||
await loadBanks();
|
||||
toast.success("Bank account saved", "Click Save settings to keep a changed default account.");
|
||||
} catch (e) {
|
||||
toast.error("Could not save bank account", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setBankModalOpen(false);
|
||||
void loadBanks();
|
||||
toast.info("Click Save settings to keep a changed default account.");
|
||||
};
|
||||
|
||||
const removeBank = async (bank: BankAccount) => {
|
||||
@@ -306,12 +292,7 @@ export default function AppSettings({
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<section className="voiced-stack" style={{ marginBottom: "2rem" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h3>Bank accounts</h3>
|
||||
<Button kind="tertiary" size="sm" renderIcon={Add} onClick={() => setEditing(blankBank())}>
|
||||
Add account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{banks.length > 0 ? (
|
||||
<TableContainer>
|
||||
@@ -336,7 +317,7 @@ export default function AppSettings({
|
||||
<TableCell>{bank.isDefault ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="voiced-header-action">
|
||||
<Button kind="ghost" size="sm" onClick={() => setEditing({ ...bank })}>
|
||||
<Button kind="ghost" size="sm" onClick={(e: MouseEvent<HTMLElement>) => openBankEditor({ ...bank }, e.currentTarget)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
@@ -358,78 +339,21 @@ export default function AppSettings({
|
||||
<div className="voiced-empty">No bank accounts yet.</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<div style={{ border: "1px solid var(--cds-border-subtle-01)", padding: "1rem" }}>
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="b-label"
|
||||
labelText="Label"
|
||||
value={editing.label}
|
||||
onChange={(e) => setBank("label", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="b-bankName"
|
||||
labelText="Bank name"
|
||||
value={editing.bankName}
|
||||
onChange={(e) => setBank("bankName", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="b-accountName"
|
||||
labelText="Account name"
|
||||
value={editing.accountName}
|
||||
onChange={(e) => setBank("accountName", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="b-accountNo"
|
||||
labelText="Account number"
|
||||
value={editing.accountNo}
|
||||
onChange={(e) => setBank("accountNo", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="b-branch"
|
||||
labelText="Branch"
|
||||
value={editing.branch}
|
||||
onChange={(e) => setBank("branch", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="b-ifsc"
|
||||
labelText="IFSC"
|
||||
value={editing.ifsc}
|
||||
onChange={(e) => setBank("ifsc", e.target.value.toUpperCase())}
|
||||
invalid={Boolean(ifscError(editing.ifsc))}
|
||||
invalidText={ifscError(editing.ifsc)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<Toggle
|
||||
id="b-default"
|
||||
labelText="Use as default account"
|
||||
toggled={editing.isDefault}
|
||||
onToggle={(value) => setBank("isDefault", value)}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
<div className="voiced-header-action" style={{ marginTop: "1rem" }}>
|
||||
<Button size="sm" onClick={saveBank} disabled={busy}>
|
||||
{busy ? "Saving…" : "Save account"}
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={Add}
|
||||
onClick={(e: MouseEvent<HTMLElement>) => openBankEditor(null, e.currentTarget)}
|
||||
>
|
||||
Add bank account
|
||||
</Button>
|
||||
<Button kind="ghost" size="sm" onClick={() => setEditing(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<BankAccountModal
|
||||
open={bankModalOpen}
|
||||
bank={editing}
|
||||
launcherRef={bankLauncher}
|
||||
onClose={() => setBankModalOpen(false)}
|
||||
onSaved={onBankSaved}
|
||||
/>
|
||||
</section>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
|
||||
@@ -72,6 +72,7 @@ import { withUserFonts } from "../lib/fontImport";
|
||||
import { ImagePicker } from "../components/ImagePicker";
|
||||
import { loadLogoImages, type LogoImages } from "../lib/logo";
|
||||
import ClientModal from "../components/ClientModal";
|
||||
import BankAccountModal from "../components/BankAccountModal";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
import { itemFromPreset } from "../lib/presets";
|
||||
|
||||
@@ -88,6 +89,9 @@ interface NewInvoiceProps {
|
||||
/** A draft to open as soon as the form is shown (set by "Duplicate" on the invoice page). */
|
||||
openDraftId?: number | null;
|
||||
onDraftOpened?: () => void;
|
||||
/** Current theme ("g10" | "g100") and its single toggle handler, owned by App. */
|
||||
theme?: string;
|
||||
onToggleTheme?: () => void;
|
||||
}
|
||||
|
||||
const AUTOSAVE_MS = 1000;
|
||||
@@ -98,7 +102,7 @@ const formatStamp = (iso: string) => {
|
||||
return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("en-GB")} ${formatTime(d)}`;
|
||||
};
|
||||
|
||||
export default function NewInvoice({ settings, onSettingsChange, active, onActivate, openDraftId = null, onDraftOpened }: NewInvoiceProps) {
|
||||
export default function NewInvoice({ settings, onSettingsChange, active, onActivate, openDraftId = null, onDraftOpened, theme, onToggleTheme }: NewInvoiceProps) {
|
||||
const toast = useToast();
|
||||
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
|
||||
const blankState = (): InvoiceFormState => {
|
||||
@@ -131,6 +135,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
const [presets, setPresets] = useState<ItemPreset[]>([]);
|
||||
const [clientModalOpen, setClientModalOpen] = useState(false);
|
||||
const newClientButton = useRef<HTMLButtonElement | null>(null);
|
||||
const [bankModalOpen, setBankModalOpen] = useState(false);
|
||||
const newBankButton = useRef<HTMLButtonElement | null>(null);
|
||||
const [clientId, setClientId] = useState<number | null>(null);
|
||||
const [clientName, setClientName] = useState("");
|
||||
const [clientAddress, setClientAddress] = useState("");
|
||||
@@ -678,6 +684,18 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
// Yield the shared render worker to the export; the model changes back when it is done and re-renders.
|
||||
const pdf = useInvoicePdf({ model: busy ? null : previewModel, prefs: activePrefs });
|
||||
|
||||
const themeToggle = onToggleTheme ? (
|
||||
<Toggle
|
||||
id="themeToggle"
|
||||
size="sm"
|
||||
labelText="Dark mode"
|
||||
labelA="Off"
|
||||
labelB="On"
|
||||
toggled={theme === "g100"}
|
||||
onToggle={() => onToggleTheme()}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="voiced-page" hidden={!active}>
|
||||
@@ -696,8 +714,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{locked ? null : (
|
||||
{locked ? themeToggle : (
|
||||
<div className="voiced-header-action" style={{ flexWrap: "wrap" }}>
|
||||
{themeToggle}
|
||||
{draftSaving ? (
|
||||
<InlineLoading status="active" description="Saving draft…" />
|
||||
) : savedAt && !unsaved ? (
|
||||
@@ -1094,6 +1113,16 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
/>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
ref={newBankButton}
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={Add}
|
||||
disabled={locked}
|
||||
onClick={() => setBankModalOpen(true)}
|
||||
>
|
||||
Create bank account
|
||||
</Button>
|
||||
|
||||
<ImagePicker
|
||||
kind="signature"
|
||||
@@ -1206,6 +1235,17 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Modal>
|
||||
<BankAccountModal
|
||||
open={bankModalOpen}
|
||||
bank={null}
|
||||
launcherRef={newBankButton}
|
||||
onClose={() => setBankModalOpen(false)}
|
||||
onSaved={(saved) => {
|
||||
setBankModalOpen(false);
|
||||
setBankId(saved.id);
|
||||
api.listBanks().then(setBanks).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
<ClientModal
|
||||
open={clientModalOpen}
|
||||
client={null}
|
||||
|
||||
Reference in New Issue
Block a user