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.
109 lines
3.0 KiB
TypeScript
109 lines
3.0 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 SeriesSettings from "./views/SeriesSettings";
|
|
import AppSettings from "./views/AppSettings";
|
|
|
|
export default function App() {
|
|
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 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]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<Theme theme="g10">
|
|
<div className="voiced-empty">
|
|
<Loading description="Loading Voiced" withOverlay={false} small />
|
|
</div>
|
|
</Theme>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<Theme theme="g10">
|
|
<div className="voiced-page">
|
|
<InlineNotification
|
|
kind="error"
|
|
lowContrast
|
|
title="Could not start Voiced"
|
|
subtitle={error}
|
|
hideCloseButton
|
|
/>
|
|
</div>
|
|
</Theme>
|
|
);
|
|
}
|
|
|
|
if (!settings) return null;
|
|
|
|
const theme = settings.theme === "g100" ? "g100" : "g10";
|
|
|
|
if (!settings.onboarded) {
|
|
return (
|
|
<Theme theme={theme}>
|
|
<Onboarding
|
|
initial={settings}
|
|
onDone={(next) => {
|
|
setSettings(next);
|
|
setView("new");
|
|
}}
|
|
/>
|
|
</Theme>
|
|
);
|
|
}
|
|
|
|
const toggleTheme = async () => {
|
|
const next: Settings = { ...settings, theme: settings.theme === "g100" ? "g10" : "g100" };
|
|
setSettings(next);
|
|
try {
|
|
await api.saveSettings(next);
|
|
} catch (e) {
|
|
setError(String(e));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Theme theme={theme}>
|
|
<AppShell view={view} onNavigate={setView} theme={settings.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")}
|
|
/>
|
|
<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} />}
|
|
</AppShell>
|
|
</Theme>
|
|
);
|
|
}
|