Start the setup wizard blank and add a Run setup again button

The first-run seed no longer inserts the original vendor's name, address, contact
details, PAN or bank account; the wizard now starts empty and requires a name and
state. Settings > Data gets a Run setup again button that clears the onboarded flag.
Re-running the wizard keeps the active invoice series unless the prefix or padding
changes. The sample logo moves out of public/ so it no longer ships in the app.
This commit is contained in:
2026-10-05 09:39:02 +05:30
parent 47c188a6cd
commit edcb3d2b13
11 changed files with 109 additions and 56 deletions
+38 -3
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
import { open, save } from "@tauri-apps/plugin-dialog";
import { confirm, open, save } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import type { Settings } from "../lib/types";
import {
backupFileName,
ensureZipExtension,
@@ -20,8 +21,15 @@ type RestoreStep = "confirm" | "staging" | "ready";
const joinPath = (dir: string, name: string) =>
!dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
/** Settings > Data: manual backup, restore (staged, applied after a restart) and the automatic daily backup. */
export default function DataBackupPanel() {
interface DataBackupPanelProps {
/** The saved settings; "Run setup again" writes them back with the wizard flag cleared. */
settings: Settings;
/** Called after the flag is cleared, so the app reloads settings and shows the wizard. */
onRerunSetup: () => void;
}
/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup and re-running setup. */
export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPanelProps) {
const toast = useToast();
const [status, setStatus] = useState<BackupStatus | null>(null);
const [backingUp, setBackingUp] = useState(false);
@@ -32,6 +40,20 @@ export default function DataBackupPanel() {
const [restarting, setRestarting] = useState(false);
useReturnFocus(restorePath !== null);
const rerunSetup = async () => {
const ok = await confirm(
"Open the setup wizard again? Your invoices, clients and backups are kept, and the wizard starts with your current details.",
{ title: "Run setup again", kind: "warning" },
);
if (!ok) return;
try {
await api.saveSettings({ ...settings, onboarded: false });
onRerunSetup();
} catch (e) {
toast.error("Could not start setup", String(e));
}
};
const refresh = useCallback(async () => {
try {
setStatus(await api.getBackupStatus());
@@ -229,6 +251,19 @@ export default function DataBackupPanel() {
</div>
</Tile>
<Tile>
<h2 className="voiced-section-title">Setup wizard</h2>
<p className="voiced-muted">
Run the first-time setup again to change your business details, logo, bank account and numbering in one go. Existing
invoices, clients and backups are not touched, and invoice numbering carries on unless you change the prefix.
</p>
<div className="voiced-actions">
<Button size="md" kind="tertiary" onClick={() => void rerunSetup()}>
Run setup again
</Button>
</div>
</Tile>
<Modal
open={restorePath !== null}
danger={step === "confirm"}
+26 -4
View File
@@ -7,7 +7,8 @@ import type { BankAccount, Invoice, InvoiceInput, Settings } from "./types";
/**
* The end-to-end invoice flow through the real IPC, run against the fresh database the self-test
* launches with (it seeds the unregistered vendor, a default bank and an active series).
* launches with. The fresh database is neutral (no vendor, no bank), so the first step provisions a
* fake vendor and bank the way the setup wizard would.
*/
const today = () => {
@@ -28,11 +29,32 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
let bank!: BankAccount;
let invoice: Invoice | null = null;
const loaded = await log.run("e2e: get settings and default bank", async () => {
const loaded = await log.run("e2e: provision vendor and bank, get settings", async () => {
s = await api.getSettings();
const banks = await api.listBanks();
let banks = await api.listBanks();
if (banks.length === 0) {
const saved = await api.saveBank({
id: null,
label: "Primary",
bankName: "Self-test Bank",
accountName: "Self-test Vendor",
accountNo: "000000000000",
branch: "000001",
ifsc: "TEST0000001",
isDefault: true,
});
s = await api.saveSettings({
...s,
vendorName: s.vendorName || "Self-test Vendor",
vendorAddress: s.vendorAddress || "1 Test Road, Bengaluru - 560001",
vendorStateCode: s.vendorStateCode || "29",
defaultBankId: saved.id,
onboarded: true,
});
banks = await api.listBanks();
}
const found = banks.find((b) => b.id === s.defaultBankId) ?? banks[0];
if (!found) throw new Error("the fresh database has no bank account");
if (!found) throw new Error("no bank account after provisioning");
bank = found;
return `${s.vendorName}, ${s.gstRegistration}, state ${s.vendorStateCode}, bank "${bank.label}"`;
});
+1 -1
View File
@@ -166,7 +166,7 @@ export function fixtureLogo(kind: LogoKind): LogoInput | null {
if (!hit) {
if (kind === "real") {
// 2172 x 724: the untrimmed Test Vendor logo, a 3:1 aspect.
const png = readFileSync(path.resolve(process.cwd(), "public/brand/sample-wordmark-logo.png"));
const png = readFileSync(path.resolve(process.cwd(), "src/pdf/testing/fixtures/sample-wordmark-logo.png"));
const [width, height] = [png.readUInt32BE(16), png.readUInt32BE(20)];
hit = { dataUri: dataUri(png), width, height, aspect: width / height, density: 0.21, kind: "wordmark", includesName: true };
} else if (kind === "knockout") {
+1 -1
View File
@@ -440,7 +440,7 @@ export default function AppSettings({
</section>
</TabPanel>
<TabPanel>
<DataBackupPanel />
<DataBackupPanel settings={settings} onRerunSetup={onSaved} />
</TabPanel>
<TabPanel>
<ErpnextSettingsPanel active={tab === SETTINGS_TABS.findIndex((t) => t.id === "integrations")} />
+12 -6
View File
@@ -46,6 +46,8 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
const [bank, setBank] = useState<BankAccount>(bankers());
const [prefix, setPrefix] = useState(`INV/${new Date().getFullYear()}-`);
const [padding, setPadding] = useState(3);
// The series that is active when the wizard opens. A re-run keeps it (and its counter) unless changed.
const [loadedSeries, setLoadedSeries] = useState<{ prefix: string; padding: number } | null>(null);
useEffect(() => {
let active = true;
@@ -58,6 +60,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
if (series) {
setPrefix(series.prefix);
setPadding(series.padding);
setLoadedSeries({ prefix: series.prefix, padding: series.padding });
}
} catch {
/* first run – defaults are fine */
@@ -89,7 +92,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
phoneError(form.vendorPhone) ||
gstinProblem,
),
[form.vendorPan, form.vendorEmail, form.vendorPhone, gstinProblem],
[form.vendorName, form.vendorStateCode, form.vendorPan, form.vendorEmail, form.vendorPhone, gstinProblem],
);
const finish = async () => {
@@ -97,10 +100,13 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
setError(null);
try {
const savedBank = await api.saveBank({ ...bank, isDefault: true });
await api.startNewSeries(prefix.trim() || `INV/${new Date().getFullYear()}-`, padding);
const wantedPrefix = prefix.trim() || `INV/${new Date().getFullYear()}-`;
if (!loadedSeries || loadedSeries.prefix !== wantedPrefix || loadedSeries.padding !== padding) {
await api.startNewSeries(wantedPrefix, padding);
}
const next: Settings = {
...form,
vendorName: form.vendorName.trim() || "Vendor",
vendorName: form.vendorName.trim(),
defaultBankId: savedBank.id,
onboarded: true,
};
@@ -120,8 +126,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
<Grid narrow>
<Column sm={4} md={8} lg={12}>
<p className="voiced-muted" style={{ marginBottom: "1rem" }}>
These details are printed on every invoice. They are pre-filled from your existing invoice —
adjust anything that has changed.
These details are printed on every invoice. You can change them later in Settings.
</p>
</Column>
<Column sm={4} md={8} lg={12}>
@@ -207,6 +212,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
value={form.vendorStateCode}
onChange={(e) => set("vendorStateCode", e.target.value)}
>
<SelectItem value="" text="Choose your state" disabled hidden />
{INDIAN_STATES.map((s) => (
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
))}
@@ -392,7 +398,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
{saving ? "Saving…" : "Finish setup"}
</Button>
) : (
<Button onClick={() => setStep((s) => s + 1)}>Next</Button>
<Button disabled={step === 0 && businessInvalid} onClick={() => setStep((s) => s + 1)}>Next</Button>
)}
</div>
</div>