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:
@@ -1,6 +1,6 @@
|
||||
# Voiced
|
||||
|
||||
An offline-first GST invoicing desktop app for **Test Vendor**, built by **Bongbetic**.
|
||||
An offline-first GST invoicing desktop app built by **Bongbetic**.
|
||||
|
||||
Voiced turns a few details into a clean, print-ready PDF invoice. It keeps a running invoice-number series in a
|
||||
local database, remembers your vendor, client and bank details, and works without an account or a network
|
||||
|
||||
Vendored
+1
-1
@@ -74,7 +74,7 @@ Everything can be built with View backgroundColor and borders. Svg is only neede
|
||||
|
||||
## Mapping to Voiced fields
|
||||
|
||||
- Logo slot: vendor logo (Image maxHeight 46, maxWidth ~110, objectFit contain). If the logo is a wordmark (e.g. public/brand/sample-wordmark-logo.png), hide the text lockup.
|
||||
- Logo slot: vendor logo (Image maxHeight 46, maxWidth ~110, objectFit contain). If the logo is a wordmark (e.g. src/pdf/testing/fixtures/sample-wordmark-logo.png), hide the text lockup.
|
||||
- Brand lockup: vendorName (SemiBold 15.4 uppercase, wraps, right-aligned).
|
||||
- 'INVOICE': show 'TAX INVOICE' when taxType != none, else 'INVOICE'.
|
||||
- Meta lines: 'Invoice No: {number}', 'Invoice Date: {date}', NEW 'Due Date: {dueDate}' (3rd line, same 18.68 pitch), optional 'PO No: {poNumber}'.
|
||||
|
||||
@@ -116,7 +116,7 @@ async function main() {
|
||||
const { computeLogoPlacement, slotFor } = await loadPlacement();
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
const sampleRaw = await loadImage(readFileSync(path.join(ROOT, "public/brand/sample-wordmark-logo.png")));
|
||||
const sampleRaw = await loadImage(readFileSync(path.join(ROOT, "src/pdf/testing/fixtures/sample-wordmark-logo.png")));
|
||||
const sampleCanvas = createCanvas(sampleRaw.width, sampleRaw.height);
|
||||
sampleCanvas.getContext("2d").drawImage(sampleRaw, 0, 0);
|
||||
const syn = syntheticLogos(createCanvas);
|
||||
|
||||
@@ -940,7 +940,7 @@ mod tests {
|
||||
let bank_id: i64 = conn.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0)).unwrap();
|
||||
let inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap();
|
||||
let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).unwrap();
|
||||
assert_eq!(bank["bankName"], "State Bank of India");
|
||||
assert_eq!(bank["bankName"], "Test Bank");
|
||||
let err = issue(&mut conn, input(json!({"bankAccountId": 9999}))).unwrap_err();
|
||||
assert!(err.contains("bank account"), "{err}");
|
||||
}
|
||||
|
||||
+25
-35
@@ -436,8 +436,8 @@ fn prune_backups(backup_dir: &Path, keep: usize) -> std::io::Result<()> {
|
||||
}
|
||||
|
||||
/// Insert the first-run defaults the very first time the app is opened.
|
||||
/// Vendor details are pre-filled from the owner's existing invoice. The vendor
|
||||
/// has no GSTIN yet, so the defaults describe an unregistered supplier (no tax).
|
||||
/// The defaults are neutral: no vendor, bank or tax identity is pre-filled, so the setup wizard
|
||||
/// starts blank. The vendor is an unregistered supplier (no tax) until the wizard says otherwise.
|
||||
fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||
let settings_count: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?;
|
||||
@@ -447,38 +447,8 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||
(id, vendor_name, vendor_address, vendor_email, vendor_phone,
|
||||
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
|
||||
default_tax_type, payment_terms_days, currency, onboarded, theme)
|
||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'none', 30, 'INR', 0, 'g10')"#,
|
||||
params![
|
||||
"Test Vendor",
|
||||
"1 Test Road, Bangalore - 560001",
|
||||
"vendor@example.com",
|
||||
"9000000000",
|
||||
"ABCDE1234F",
|
||||
"",
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
let bank_count: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM bank_accounts", [], |r| r.get(0))?;
|
||||
if bank_count == 0 {
|
||||
conn.execute(
|
||||
r#"INSERT INTO bank_accounts
|
||||
(label, bank_name, account_name, account_no, branch, ifsc, is_default)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)"#,
|
||||
params![
|
||||
"Primary",
|
||||
"State Bank of India",
|
||||
"Test Vendor",
|
||||
"000000000000",
|
||||
"000001",
|
||||
"TEST0000001",
|
||||
],
|
||||
)?;
|
||||
let bank_id = conn.last_insert_rowid();
|
||||
conn.execute(
|
||||
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
|
||||
params![bank_id],
|
||||
VALUES (1, '', '', '', '', '', '', '', 18, 'none', 30, 'INR', 0, 'g10')"#,
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -497,6 +467,25 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sample vendor and bank for command-logic unit tests (production seeds none). Fake values only.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn seed_test_fixture(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
r#"UPDATE app_settings SET vendor_name = 'Test Vendor', vendor_address = '1 Test Road, Bangalore - 560001',
|
||||
vendor_email = 'vendor@example.com', vendor_phone = '9000000000', vendor_pan = 'ABCDE1234F',
|
||||
vendor_state_code = '29' WHERE id = 1"#,
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
r#"INSERT INTO bank_accounts (label, bank_name, account_name, account_no, branch, ifsc, is_default)
|
||||
VALUES ('Primary', 'Test Bank', 'Test Vendor', '000000000000', '000001', 'TEST0000001', 1)"#,
|
||||
[],
|
||||
)?;
|
||||
let bank_id = conn.last_insert_rowid();
|
||||
conn.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![bank_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A migrated, seeded in-memory database for unit tests of the command logic.
|
||||
#[cfg(test)]
|
||||
pub fn open_in_memory() -> Result<Connection, DbError> {
|
||||
@@ -504,6 +493,7 @@ pub fn open_in_memory() -> Result<Connection, DbError> {
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
migrations().to_latest(&mut conn)?;
|
||||
seed(&conn)?;
|
||||
seed_test_fixture(&conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
@@ -726,7 +716,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
.unwrap();
|
||||
assert_eq!(tax_type, "none");
|
||||
assert_eq!(gst_reg, "unregistered");
|
||||
assert_eq!(name, "Test Vendor");
|
||||
assert_eq!(name, "");
|
||||
assert!(backups_in(&backups).is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ fn clean_alpha(img: &mut RgbaImage) {
|
||||
}
|
||||
|
||||
/// Keep 64 levels per channel (steps of about 4/255, invisible in print). Anti-aliasing and encoder
|
||||
/// noise of +-1 otherwise makes flat colours incompressible: the Test Vendor logo drops from 149 KB to 75 KB.
|
||||
/// noise of +-1 otherwise makes flat colours incompressible: a sample wordmark logo drops from 149 KB to 75 KB.
|
||||
fn quantize_colour(img: &mut RgbaImage) {
|
||||
for p in img.pixels_mut() {
|
||||
for c in 0..3 {
|
||||
@@ -535,7 +535,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sample_logo_is_trimmed_and_small() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/brand/sample-wordmark-logo.png");
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/pdf/testing/fixtures/sample-wordmark-logo.png");
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
let out = process_logo(&bytes).unwrap();
|
||||
let m = &out.metrics;
|
||||
|
||||
@@ -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
@@ -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}"`;
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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")} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user