- 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.
159 lines
4.7 KiB
TypeScript
159 lines
4.7 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { InlineNotification, Loading, Theme } from "@carbon/react";
|
|
import { api } from "./lib/api";
|
|
import type { Settings } from "./lib/types";
|
|
import { AppShell, type AppView } from "./components/AppShell";
|
|
import Onboarding from "./views/Onboarding";
|
|
import NewInvoice from "./views/NewInvoice";
|
|
import InvoiceHistory from "./views/InvoiceHistory";
|
|
import InvoiceDetail from "./views/InvoiceDetail";
|
|
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 (
|
|
<ToastProvider>
|
|
<AppInner />
|
|
</ToastProvider>
|
|
);
|
|
}
|
|
|
|
function AppInner() {
|
|
const toast = useToast();
|
|
const [settings, setSettings] = useState<Settings | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [view, setView] = useState<AppView>("new");
|
|
const [detailId, setDetailId] = useState<number | null>(null);
|
|
// A draft made by "Duplicate" that the New invoice form should open once it is shown.
|
|
const [openDraftId, setOpenDraftId] = useState<number | null>(null);
|
|
|
|
const refreshSettings = useCallback(async () => {
|
|
try {
|
|
const next = await api.getSettings();
|
|
setSettings(next);
|
|
setError(null);
|
|
} catch (e) {
|
|
setError(String(e));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
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={theme} className="voiced-theme">
|
|
<div className="voiced-empty">
|
|
<Loading description="Loading Voiced" withOverlay={false} small />
|
|
</div>
|
|
</Theme>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<Theme theme={theme} className="voiced-theme">
|
|
<div className="voiced-page">
|
|
<InlineNotification
|
|
kind="error"
|
|
lowContrast
|
|
title="Could not start Voiced"
|
|
subtitle={error}
|
|
hideCloseButton
|
|
/>
|
|
</div>
|
|
</Theme>
|
|
);
|
|
}
|
|
|
|
if (!settings) return null;
|
|
|
|
if (!settings.onboarded) {
|
|
return (
|
|
<Theme theme={theme} className="voiced-theme">
|
|
<Onboarding
|
|
initial={settings}
|
|
onDone={(next) => {
|
|
setSettings(next);
|
|
setView("new");
|
|
}}
|
|
/>
|
|
</Theme>
|
|
);
|
|
}
|
|
|
|
const openIntegrations = () => {
|
|
requestSettingsTab("integrations");
|
|
setView("settings");
|
|
};
|
|
|
|
const toggleTheme = async () => {
|
|
const next: Settings = { ...settings, theme: settings.theme === "g100" ? "g10" : "g100" };
|
|
setSettings(next);
|
|
try {
|
|
await api.saveSettings(next);
|
|
} catch (e) {
|
|
toast.error("Could not save the theme", String(e));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<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}
|
|
onSettingsChange={refreshSettings}
|
|
active={view === "new"}
|
|
onActivate={() => setView("new")}
|
|
openDraftId={openDraftId}
|
|
onDraftOpened={() => setOpenDraftId(null)}
|
|
theme={theme}
|
|
onToggleTheme={toggleTheme}
|
|
/>
|
|
<div hidden={view !== "invoices"}>
|
|
<InvoiceHistory
|
|
settings={settings}
|
|
active={view === "invoices"}
|
|
onOpenSettings={openIntegrations}
|
|
onOpen={(id) => {
|
|
setDetailId(id);
|
|
setView("invoice");
|
|
}}
|
|
/>
|
|
</div>
|
|
{view === "invoice" && detailId !== null ? (
|
|
<InvoiceDetail
|
|
key={detailId}
|
|
invoiceId={detailId}
|
|
settings={settings}
|
|
onBack={() => setView("invoices")}
|
|
onOpenSettings={openIntegrations}
|
|
onDuplicated={(draftId) => {
|
|
setOpenDraftId(draftId);
|
|
setView("new");
|
|
}}
|
|
/>
|
|
) : null}
|
|
<div hidden={view !== "clients"}>
|
|
<Clients active={view === "clients"} />
|
|
</div>
|
|
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
|
|
</AppShell>
|
|
</Theme>
|
|
);
|
|
}
|