Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6efb498409 | ||
|
|
ae80922d03 |
+11
@@ -6,6 +6,17 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<title>Voiced</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ mkdir -p "$REPO"
|
|||||||
mv -f "$OUT"/${PKGVER}.*.xbps "$REPO/"
|
mv -f "$OUT"/${PKGVER}.*.xbps "$REPO/"
|
||||||
|
|
||||||
echo "==> Indexing local repository"
|
echo "==> Indexing local repository"
|
||||||
xbps-rindex -a "$REPO"/*.xbps
|
# -f re-adds a package whose version is already indexed. Without it a rebuild of the
|
||||||
|
# same version keeps the old checksum in the index and xbps-install rejects the new file.
|
||||||
|
xbps-rindex -f -a "$REPO"/*.xbps
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Package: $REPO/${PKGVER}.${ARCH}.xbps"
|
echo "Package: $REPO/${PKGVER}.${ARCH}.xbps"
|
||||||
|
|||||||
+14
-7
@@ -11,6 +11,7 @@ import Clients from "./views/Clients";
|
|||||||
import { ToastProvider, useToast } from "./components/ToastProvider";
|
import { ToastProvider, useToast } from "./components/ToastProvider";
|
||||||
import AppSettings from "./views/AppSettings";
|
import AppSettings from "./views/AppSettings";
|
||||||
import { requestSettingsTab } from "./lib/settingsTab";
|
import { requestSettingsTab } from "./lib/settingsTab";
|
||||||
|
import { applyDocumentTheme, cachedTheme, normalizeTheme } from "./lib/theme";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -46,9 +47,15 @@ function AppInner() {
|
|||||||
void refreshSettings();
|
void refreshSettings();
|
||||||
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Theme theme="g10">
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<div className="voiced-empty">
|
<div className="voiced-empty">
|
||||||
<Loading description="Loading Voiced" withOverlay={false} small />
|
<Loading description="Loading Voiced" withOverlay={false} small />
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +65,7 @@ function AppInner() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Theme theme="g10">
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<div className="voiced-page">
|
<div className="voiced-page">
|
||||||
<InlineNotification
|
<InlineNotification
|
||||||
kind="error"
|
kind="error"
|
||||||
@@ -74,11 +81,9 @@ function AppInner() {
|
|||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
const theme = settings.theme === "g100" ? "g100" : "g10";
|
|
||||||
|
|
||||||
if (!settings.onboarded) {
|
if (!settings.onboarded) {
|
||||||
return (
|
return (
|
||||||
<Theme theme={theme}>
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<Onboarding
|
<Onboarding
|
||||||
initial={settings}
|
initial={settings}
|
||||||
onDone={(next) => {
|
onDone={(next) => {
|
||||||
@@ -106,8 +111,8 @@ function AppInner() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Theme theme={theme}>
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}>
|
<AppShell view={view} onNavigate={setView} theme={theme} onToggleTheme={toggleTheme}>
|
||||||
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
|
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
|
||||||
<NewInvoice
|
<NewInvoice
|
||||||
settings={settings}
|
settings={settings}
|
||||||
@@ -116,6 +121,8 @@ function AppInner() {
|
|||||||
onActivate={() => setView("new")}
|
onActivate={() => setView("new")}
|
||||||
openDraftId={openDraftId}
|
openDraftId={openDraftId}
|
||||||
onDraftOpened={() => setOpenDraftId(null)}
|
onDraftOpened={() => setOpenDraftId(null)}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={toggleTheme}
|
||||||
/>
|
/>
|
||||||
<div hidden={view !== "invoices"}>
|
<div hidden={view !== "invoices"}>
|
||||||
<InvoiceHistory
|
<InvoiceHistory
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: A
|
|||||||
))}
|
))}
|
||||||
</HeaderNavigation>
|
</HeaderNavigation>
|
||||||
<HeaderGlobalBar>
|
<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} />}
|
{theme === "g100" ? <Light size={20} /> : <Asleep size={20} />}
|
||||||
</HeaderGlobalAction>
|
</HeaderGlobalAction>
|
||||||
</HeaderGlobalBar>
|
</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 ---------- */
|
/* ---------- App shell ---------- */
|
||||||
html,
|
// The Carbon theme tokens are also set on <html> (see lib/theme.ts), so the canvas follows the theme
|
||||||
body,
|
// even where the <Theme> zone does not reach (below short content, overscroll, window resize).
|
||||||
#root {
|
html {
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
|
background-color: var(--cds-background);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.cds--g100 {
|
||||||
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
background-color: var(--cds-background);
|
background-color: var(--cds-background);
|
||||||
color: var(--cds-text-primary);
|
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 {
|
.voiced-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+30
-106
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Column,
|
Column,
|
||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
type Settings,
|
type Settings,
|
||||||
type TaxType,
|
type TaxType,
|
||||||
} from "../lib/types";
|
} 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 { ImagePicker } from "../components/ImagePicker";
|
||||||
import { LogoBranding } from "../components/LogoBranding";
|
import { LogoBranding } from "../components/LogoBranding";
|
||||||
import DataBackupPanel from "../components/DataBackupPanel";
|
import DataBackupPanel from "../components/DataBackupPanel";
|
||||||
@@ -45,19 +45,9 @@ import ErpnextSettingsPanel from "../components/ErpnextSettingsPanel";
|
|||||||
import { FontOverrideTable } from "../components/FontOverrideTable";
|
import { FontOverrideTable } from "../components/FontOverrideTable";
|
||||||
import { useToast } from "../components/ToastProvider";
|
import { useToast } from "../components/ToastProvider";
|
||||||
import { SETTINGS_TABS, loadSettingsTab, saveSettingsTab } from "../lib/settingsTab";
|
import { SETTINGS_TABS, loadSettingsTab, saveSettingsTab } from "../lib/settingsTab";
|
||||||
|
import BankAccountModal from "../components/BankAccountModal";
|
||||||
import SeriesSettings from "./SeriesSettings";
|
import SeriesSettings from "./SeriesSettings";
|
||||||
|
|
||||||
const blankBank = (): BankAccount => ({
|
|
||||||
id: null,
|
|
||||||
label: "",
|
|
||||||
bankName: "",
|
|
||||||
accountName: "",
|
|
||||||
accountNo: "",
|
|
||||||
branch: "",
|
|
||||||
ifsc: "",
|
|
||||||
isDefault: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function AppSettings({
|
export default function AppSettings({
|
||||||
settings,
|
settings,
|
||||||
onSaved,
|
onSaved,
|
||||||
@@ -68,7 +58,9 @@ export default function AppSettings({
|
|||||||
const [form, setForm] = useState<Settings>({ ...settings });
|
const [form, setForm] = useState<Settings>({ ...settings });
|
||||||
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
||||||
const [banks, setBanks] = useState<BankAccount[]>([]);
|
const [banks, setBanks] = useState<BankAccount[]>([]);
|
||||||
|
const [bankModalOpen, setBankModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<BankAccount | null>(null);
|
const [editing, setEditing] = useState<BankAccount | null>(null);
|
||||||
|
const bankLauncher = useRef<HTMLElement | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
// One form state for every tab and one Save button, so edits survive switching tabs.
|
// 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]) =>
|
const set = <K extends keyof Settings>(key: K, value: Settings[K]) =>
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
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 registered = form.gstRegistration !== "unregistered";
|
||||||
const gstinProblem = vendorGstinError(
|
const gstinProblem = vendorGstinError(
|
||||||
form.gstRegistration,
|
form.gstRegistration,
|
||||||
@@ -123,22 +112,19 @@ export default function AppSettings({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveBank = async () => {
|
const openBankEditor = (bank: BankAccount | null, trigger: HTMLElement) => {
|
||||||
if (!editing) return;
|
bankLauncher.current = trigger;
|
||||||
setBusy(true);
|
setEditing(bank);
|
||||||
try {
|
setBankModalOpen(true);
|
||||||
const saved = await api.saveBank(editing);
|
};
|
||||||
|
|
||||||
|
const onBankSaved = (saved: BankAccount) => {
|
||||||
if (saved.isDefault || form.defaultBankId === null) {
|
if (saved.isDefault || form.defaultBankId === null) {
|
||||||
setForm((prev) => ({ ...prev, defaultBankId: saved.id }));
|
setForm((prev) => ({ ...prev, defaultBankId: saved.id }));
|
||||||
}
|
}
|
||||||
setEditing(null);
|
setBankModalOpen(false);
|
||||||
await loadBanks();
|
void loadBanks();
|
||||||
toast.success("Bank account saved", "Click Save settings to keep a changed default account.");
|
toast.info("Click Save settings to keep a changed default account.");
|
||||||
} catch (e) {
|
|
||||||
toast.error("Could not save bank account", String(e));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeBank = async (bank: BankAccount) => {
|
const removeBank = async (bank: BankAccount) => {
|
||||||
@@ -306,12 +292,7 @@ export default function AppSettings({
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<section className="voiced-stack" style={{ marginBottom: "2rem" }}>
|
<section className="voiced-stack" style={{ marginBottom: "2rem" }}>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
||||||
<h3>Bank accounts</h3>
|
<h3>Bank accounts</h3>
|
||||||
<Button kind="tertiary" size="sm" renderIcon={Add} onClick={() => setEditing(blankBank())}>
|
|
||||||
Add account
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{banks.length > 0 ? (
|
{banks.length > 0 ? (
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
@@ -336,7 +317,7 @@ export default function AppSettings({
|
|||||||
<TableCell>{bank.isDefault ? "Yes" : "No"}</TableCell>
|
<TableCell>{bank.isDefault ? "Yes" : "No"}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="voiced-header-action">
|
<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
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -358,78 +339,21 @@ export default function AppSettings({
|
|||||||
<div className="voiced-empty">No bank accounts yet.</div>
|
<div className="voiced-empty">No bank accounts yet.</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editing ? (
|
<Button
|
||||||
<div style={{ border: "1px solid var(--cds-border-subtle-01)", padding: "1rem" }}>
|
kind="ghost"
|
||||||
<Grid narrow>
|
size="sm"
|
||||||
<Column sm={4} md={4} lg={6}>
|
renderIcon={Add}
|
||||||
<TextInput
|
onClick={(e: MouseEvent<HTMLElement>) => openBankEditor(null, e.currentTarget)}
|
||||||
id="b-label"
|
>
|
||||||
labelText="Label"
|
Add bank account
|
||||||
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>
|
</Button>
|
||||||
<Button kind="ghost" size="sm" onClick={() => setEditing(null)}>
|
<BankAccountModal
|
||||||
Cancel
|
open={bankModalOpen}
|
||||||
</Button>
|
bank={editing}
|
||||||
</div>
|
launcherRef={bankLauncher}
|
||||||
</div>
|
onClose={() => setBankModalOpen(false)}
|
||||||
) : null}
|
onSaved={onBankSaved}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import { withUserFonts } from "../lib/fontImport";
|
|||||||
import { ImagePicker } from "../components/ImagePicker";
|
import { ImagePicker } from "../components/ImagePicker";
|
||||||
import { loadLogoImages, type LogoImages } from "../lib/logo";
|
import { loadLogoImages, type LogoImages } from "../lib/logo";
|
||||||
import ClientModal from "../components/ClientModal";
|
import ClientModal from "../components/ClientModal";
|
||||||
|
import BankAccountModal from "../components/BankAccountModal";
|
||||||
import { useToast } from "../components/ToastProvider";
|
import { useToast } from "../components/ToastProvider";
|
||||||
import { itemFromPreset } from "../lib/presets";
|
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). */
|
/** A draft to open as soon as the form is shown (set by "Duplicate" on the invoice page). */
|
||||||
openDraftId?: number | null;
|
openDraftId?: number | null;
|
||||||
onDraftOpened?: () => void;
|
onDraftOpened?: () => void;
|
||||||
|
/** Current theme ("g10" | "g100") and its single toggle handler, owned by App. */
|
||||||
|
theme?: string;
|
||||||
|
onToggleTheme?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTOSAVE_MS = 1000;
|
const AUTOSAVE_MS = 1000;
|
||||||
@@ -98,7 +102,7 @@ const formatStamp = (iso: string) => {
|
|||||||
return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("en-GB")} ${formatTime(d)}`;
|
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 toast = useToast();
|
||||||
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
|
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
|
||||||
const blankState = (): InvoiceFormState => {
|
const blankState = (): InvoiceFormState => {
|
||||||
@@ -131,6 +135,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
const [presets, setPresets] = useState<ItemPreset[]>([]);
|
const [presets, setPresets] = useState<ItemPreset[]>([]);
|
||||||
const [clientModalOpen, setClientModalOpen] = useState(false);
|
const [clientModalOpen, setClientModalOpen] = useState(false);
|
||||||
const newClientButton = useRef<HTMLButtonElement | null>(null);
|
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 [clientId, setClientId] = useState<number | null>(null);
|
||||||
const [clientName, setClientName] = useState("");
|
const [clientName, setClientName] = useState("");
|
||||||
const [clientAddress, setClientAddress] = 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.
|
// 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 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="voiced-page" hidden={!active}>
|
<div className="voiced-page" hidden={!active}>
|
||||||
@@ -696,8 +714,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{locked ? null : (
|
{locked ? themeToggle : (
|
||||||
<div className="voiced-header-action" style={{ flexWrap: "wrap" }}>
|
<div className="voiced-header-action" style={{ flexWrap: "wrap" }}>
|
||||||
|
{themeToggle}
|
||||||
{draftSaving ? (
|
{draftSaving ? (
|
||||||
<InlineLoading status="active" description="Saving draft…" />
|
<InlineLoading status="active" description="Saving draft…" />
|
||||||
) : savedAt && !unsaved ? (
|
) : savedAt && !unsaved ? (
|
||||||
@@ -1094,6 +1113,16 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
<Button
|
||||||
|
ref={newBankButton}
|
||||||
|
kind="ghost"
|
||||||
|
size="sm"
|
||||||
|
renderIcon={Add}
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => setBankModalOpen(true)}
|
||||||
|
>
|
||||||
|
Create bank account
|
||||||
|
</Button>
|
||||||
|
|
||||||
<ImagePicker
|
<ImagePicker
|
||||||
kind="signature"
|
kind="signature"
|
||||||
@@ -1206,6 +1235,17 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<BankAccountModal
|
||||||
|
open={bankModalOpen}
|
||||||
|
bank={null}
|
||||||
|
launcherRef={newBankButton}
|
||||||
|
onClose={() => setBankModalOpen(false)}
|
||||||
|
onSaved={(saved) => {
|
||||||
|
setBankModalOpen(false);
|
||||||
|
setBankId(saved.id);
|
||||||
|
api.listBanks().then(setBanks).catch(() => {});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ClientModal
|
<ClientModal
|
||||||
open={clientModalOpen}
|
open={clientModalOpen}
|
||||||
client={null}
|
client={null}
|
||||||
|
|||||||
Reference in New Issue
Block a user