Initial commit: Voiced invoice desktop app
Offline-first invoicing app for Arun P, built by Bongbetic. - Tauri v2 (Rust) + React/TypeScript + IBM Carbon Design System UI - SQLite (rusqlite) storage with a persistent invoice-number series - Guided first-run setup, pre-filled vendor/bank details - Fixed-amount or rate x quantity line items (per sec/min/hour/session/unit) - GST as CGST+SGST or IGST, Indian amount-in-words - Optional signature, with a computer-generated note as fallback - Print-ready PDF export via @react-pdf/renderer - Windows cross-compile via cargo-xwin (NSIS installer) Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
+98
@@ -0,0 +1,98 @@
|
||||
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}>
|
||||
{view === "new" && <NewInvoice settings={settings} onSettingsChange={refreshSettings} />}
|
||||
{view === "invoices" && <InvoiceHistory settings={settings} />}
|
||||
{view === "series" && <SeriesSettings />}
|
||||
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
|
||||
</AppShell>
|
||||
</Theme>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Content,
|
||||
Header,
|
||||
HeaderGlobalAction,
|
||||
HeaderGlobalBar,
|
||||
HeaderMenuItem,
|
||||
HeaderName,
|
||||
HeaderNavigation,
|
||||
SkipToContent,
|
||||
} from "@carbon/react";
|
||||
import { Asleep, Light } from "@carbon/icons-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type AppView = "new" | "invoices" | "series" | "settings";
|
||||
|
||||
const NAV: { id: AppView; label: string }[] = [
|
||||
{ id: "new", label: "New invoice" },
|
||||
{ id: "invoices", label: "Invoices" },
|
||||
{ id: "series", label: "Series" },
|
||||
{ id: "settings", label: "Settings" },
|
||||
];
|
||||
|
||||
interface AppShellProps {
|
||||
view: AppView;
|
||||
onNavigate: (view: AppView) => void;
|
||||
theme: string;
|
||||
onToggleTheme: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: AppShellProps) {
|
||||
const wordmark =
|
||||
theme === "g100" ? "/brand/bongbetic-wordmark-light.png" : "/brand/bongbetic-wordmark-dark.png";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header aria-label="Voiced">
|
||||
<SkipToContent />
|
||||
<HeaderName href="#" prefix="">
|
||||
<span className="voiced-brand">
|
||||
<img className="voiced-brand__logo" src={wordmark} alt="Bongbetic" />
|
||||
<span className="voiced-brand__divider" aria-hidden="true" />
|
||||
<span className="voiced-brand__product">Voiced</span>
|
||||
</span>
|
||||
</HeaderName>
|
||||
<HeaderNavigation aria-label="Voiced navigation">
|
||||
{NAV.map((item) => (
|
||||
<HeaderMenuItem
|
||||
key={item.id}
|
||||
isCurrentPage={view === item.id}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onNavigate(item.id);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</HeaderMenuItem>
|
||||
))}
|
||||
</HeaderNavigation>
|
||||
<HeaderGlobalBar>
|
||||
<HeaderGlobalAction aria-label="Toggle colour theme" onClick={onToggleTheme}>
|
||||
{theme === "g100" ? <Light size={20} /> : <Asleep size={20} />}
|
||||
</HeaderGlobalAction>
|
||||
</HeaderGlobalBar>
|
||||
</Header>
|
||||
<Content id="main-content">{children}</Content>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, InlineLoading } from "@carbon/react";
|
||||
import { TrashCan, Upload } from "@carbon/icons-react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
interface ImagePickerProps {
|
||||
kind: string;
|
||||
label: string;
|
||||
value: string | null;
|
||||
onChange: (path: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** Pick an image, copy it into the app's asset store and show a preview. */
|
||||
export function ImagePicker({ kind, label, value, onChange, disabled }: ImagePickerProps) {
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!value) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
api
|
||||
.readAssetDataUri(value)
|
||||
.then((uri) => active && setPreview(uri))
|
||||
.catch(() => active && setPreview(null));
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
const pick = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp"] }],
|
||||
});
|
||||
if (typeof selected !== "string") return;
|
||||
setBusy(true);
|
||||
const stored = await api.importAsset(selected, kind);
|
||||
onChange(stored);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setError(null);
|
||||
// Detach only — the file may be shared with other invoices/settings.
|
||||
onChange(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="voiced-stack">
|
||||
<div className="voiced-muted">{label}</div>
|
||||
{preview ? (
|
||||
<img
|
||||
src={preview}
|
||||
alt={label}
|
||||
style={{ maxHeight: 64, maxWidth: 220, objectFit: "contain", background: "#fff", padding: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<div className="voiced-muted">{busy ? <InlineLoading description="Importing" /> : "No file selected"}</div>
|
||||
)}
|
||||
<div className="voiced-header-action">
|
||||
<Button
|
||||
kind="tertiary"
|
||||
size="sm"
|
||||
renderIcon={Upload}
|
||||
onClick={pick}
|
||||
disabled={disabled || busy}
|
||||
>
|
||||
Choose image
|
||||
</Button>
|
||||
{value ? (
|
||||
<Button kind="ghost" size="sm" renderIcon={TrashCan} onClick={clear} disabled={disabled}>
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? <span className="voiced-muted">{error}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { UNIT_LABELS } from "../lib/types";
|
||||
import { computeLineAmount } from "../lib/invoice";
|
||||
import { formatAmount, formatDate } from "../lib/format";
|
||||
import type { InvoicePdfProps } from "../pdf/InvoiceDocument";
|
||||
|
||||
const money = (n: number) => `₹${formatAmount(n)}`;
|
||||
|
||||
/** On-screen preview that mirrors the exported PDF layout. */
|
||||
export function InvoicePreview(props: InvoicePdfProps) {
|
||||
const {
|
||||
vendor,
|
||||
bank,
|
||||
logoDataUri,
|
||||
signatureDataUri,
|
||||
number,
|
||||
invoiceDate,
|
||||
dueDate,
|
||||
clientName,
|
||||
clientAddress,
|
||||
clientGstin,
|
||||
poNumber,
|
||||
placeOfSupplyStateCode,
|
||||
items,
|
||||
subtotal,
|
||||
discount,
|
||||
taxType,
|
||||
taxRate,
|
||||
cgst,
|
||||
sgst,
|
||||
igst,
|
||||
total,
|
||||
amountInWords,
|
||||
notes,
|
||||
} = props;
|
||||
|
||||
const isTaxInvoice = taxType !== "none";
|
||||
const contact = [vendor.vendorEmail, vendor.vendorPhone].filter(Boolean).join(" · ");
|
||||
|
||||
return (
|
||||
<div className="inv-preview-wrap">
|
||||
<div className="inv-paper">
|
||||
<div className="inv-top">
|
||||
<div className="inv-vendor">
|
||||
{logoDataUri ? <img className="inv-vendor__logo" src={logoDataUri} alt="" /> : null}
|
||||
<div>
|
||||
<h2 className="inv-vendor__name">{vendor.vendorName || "Vendor"}</h2>
|
||||
<p className="inv-vendor__line">{vendor.vendorAddress}</p>
|
||||
{contact ? <p className="inv-vendor__line">{contact}</p> : null}
|
||||
{vendor.vendorPan ? <p className="inv-vendor__line">PAN: {vendor.vendorPan}</p> : null}
|
||||
<p className="inv-vendor__line">GSTIN: {vendor.vendorGstin || "NA"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inv-meta">
|
||||
<h3 className="inv-meta__title">{isTaxInvoice ? "Tax Invoice" : "Invoice"}</h3>
|
||||
<div className="inv-meta__row">
|
||||
<span className="inv-meta__label">Invoice No.</span>
|
||||
<span className="inv-meta__value">{number}</span>
|
||||
</div>
|
||||
<div className="inv-meta__row">
|
||||
<span className="inv-meta__label">Date</span>
|
||||
<span className="inv-meta__value">{formatDate(invoiceDate)}</span>
|
||||
</div>
|
||||
{dueDate ? (
|
||||
<div className="inv-meta__row">
|
||||
<span className="inv-meta__label">Due Date</span>
|
||||
<span className="inv-meta__value">{formatDate(dueDate)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inv-split">
|
||||
<div className="inv-block" style={{ maxWidth: "60%" }}>
|
||||
<p className="inv-block__label">Billed To</p>
|
||||
<div className="inv-block__strong">{clientName || "—"}</div>
|
||||
{clientAddress ? <div style={{ whiteSpace: "pre-line" }}>{clientAddress}</div> : null}
|
||||
{clientGstin ? <div>GSTIN: {clientGstin}</div> : null}
|
||||
{placeOfSupplyStateCode ? <div>Place of Supply: {placeOfSupplyStateCode}</div> : null}
|
||||
</div>
|
||||
{poNumber ? (
|
||||
<div className="inv-block">
|
||||
<p className="inv-block__label">PO Number</p>
|
||||
<div className="inv-block__strong">{poNumber}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<table className="inv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th className="num">Unit Price</th>
|
||||
<th className="num">Qty</th>
|
||||
<th className="num">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
<tr key={item.id ?? index}>
|
||||
<td>{item.description || "—"}</td>
|
||||
<td className="num">
|
||||
{item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "—"}
|
||||
</td>
|
||||
<td className="num">{item.mode === "rate" ? item.quantity : 1}</td>
|
||||
<td className="num">{money(computeLineAmount(item))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="inv-totals">
|
||||
<div className="inv-totals__table">
|
||||
<div className="inv-totals__row">
|
||||
<span>Subtotal</span>
|
||||
<span>{money(subtotal)}</span>
|
||||
</div>
|
||||
{discount > 0 ? (
|
||||
<div className="inv-totals__row">
|
||||
<span>Discount</span>
|
||||
<span>- {money(discount)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{taxType === "cgst_sgst" ? (
|
||||
<>
|
||||
<div className="inv-totals__row">
|
||||
<span>CGST ({(taxRate / 2).toFixed(2)}%)</span>
|
||||
<span>{money(cgst)}</span>
|
||||
</div>
|
||||
<div className="inv-totals__row">
|
||||
<span>SGST ({(taxRate / 2).toFixed(2)}%)</span>
|
||||
<span>{money(sgst)}</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{taxType === "igst" ? (
|
||||
<div className="inv-totals__row">
|
||||
<span>IGST ({taxRate.toFixed(2)}%)</span>
|
||||
<span>{money(igst)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="inv-totals__row inv-totals__row--grand">
|
||||
<span>Total</span>
|
||||
<span>{money(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="inv-words">Amount in words: {amountInWords}</p>
|
||||
|
||||
{notes ? (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<p className="inv-block__label">Notes</p>
|
||||
<div style={{ whiteSpace: "pre-line" }}>{notes}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="inv-bottom">
|
||||
<div className="inv-block" style={{ maxWidth: "55%" }}>
|
||||
{bank ? (
|
||||
<>
|
||||
<p className="inv-block__label">Pay To</p>
|
||||
<div className="inv-block__strong">{bank.accountName || vendor.vendorName}</div>
|
||||
<div>{bank.bankName}</div>
|
||||
{bank.accountNo ? <div>Account No.: {bank.accountNo}</div> : null}
|
||||
{bank.branch ? <div>Branch: {bank.branch}</div> : null}
|
||||
{bank.ifsc ? <div>IFSC: {bank.ifsc}</div> : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="inv-signature">
|
||||
{signatureDataUri ? (
|
||||
<>
|
||||
<img className="inv-signature__img" src={signatureDataUri} alt="Signature" />
|
||||
<div className="inv-signature__name">{vendor.vendorName}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="inv-signature__name">{vendor.vendorName}</div>
|
||||
<p className="inv-fineprint">Computer-generated invoice; no signature is required.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inv-footer">
|
||||
<span>{vendor.vendorName}</span>
|
||||
<span>Generated with Voiced — a Bongbetic product</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
BankAccount,
|
||||
Client,
|
||||
Invoice,
|
||||
InvoiceInput,
|
||||
InvoiceSeries,
|
||||
InvoiceSummary,
|
||||
Settings,
|
||||
} from "./types";
|
||||
|
||||
export const api = {
|
||||
getSettings: () => invoke<Settings>("get_settings"),
|
||||
saveSettings: (settings: Settings) => invoke<Settings>("save_settings", { settings }),
|
||||
|
||||
listBanks: () => invoke<BankAccount[]>("list_banks"),
|
||||
saveBank: (bank: BankAccount) => invoke<BankAccount>("save_bank", { bank }),
|
||||
deleteBank: (id: number) => invoke<void>("delete_bank", { id }),
|
||||
|
||||
getActiveSeries: () => invoke<InvoiceSeries | null>("get_active_series"),
|
||||
listSeries: () => invoke<InvoiceSeries[]>("list_series"),
|
||||
startNewSeries: (prefix: string, padding: number) =>
|
||||
invoke<InvoiceSeries>("start_new_series", { prefix, padding }),
|
||||
|
||||
listClients: () => invoke<Client[]>("list_clients"),
|
||||
saveClient: (client: Client) => invoke<Client>("save_client", { client }),
|
||||
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
||||
|
||||
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
|
||||
createInvoice: (input: InvoiceInput) => invoke<Invoice>("create_invoice", { input }),
|
||||
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
|
||||
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
|
||||
deleteInvoice: (id: number) => invoke<void>("delete_invoice", { id }),
|
||||
|
||||
importAsset: (sourcePath: string, kind: string) =>
|
||||
invoke<string>("import_asset", { sourcePath, kind }),
|
||||
saveAssetBytes: (kind: string, fileName: string, dataBase64: string) =>
|
||||
invoke<string>("save_asset_bytes", { kind, fileName, dataBase64 }),
|
||||
readAssetDataUri: (path: string) => invoke<string>("read_asset_data_uri", { path }),
|
||||
removeAsset: (path: string) => invoke<void>("remove_asset", { path }),
|
||||
saveBinaryFile: (path: string, dataBase64: string) =>
|
||||
invoke<void>("save_binary_file", { path, dataBase64 }),
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
const inr = new Intl.NumberFormat("en-IN", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
/** Indian-grouped number, e.g. 25000 -> "25,000.00" */
|
||||
export function formatAmount(amount: number): string {
|
||||
const value = Number.isFinite(amount) ? amount : 0;
|
||||
return inr.format(value);
|
||||
}
|
||||
|
||||
/** Currency string with the rupee symbol, e.g. "₹25,000.00" */
|
||||
export function formatCurrency(amount: number, symbol = "₹"): string {
|
||||
return `${symbol}${formatAmount(amount)}`;
|
||||
}
|
||||
|
||||
/** "2024-06-13" -> "13 Jun 2024" (falls back to the raw value if unparseable). */
|
||||
export function formatDate(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const date = new Date(`${iso}T00:00:00`);
|
||||
if (Number.isNaN(date.getTime())) return iso;
|
||||
return date.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
/** Today as an ISO date (YYYY-MM-DD) in local time. */
|
||||
export function todayIso(): string {
|
||||
const now = new Date();
|
||||
const offset = now.getTimezoneOffset();
|
||||
return new Date(now.getTime() - offset * 60_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** ISO date `days` from `iso` (defaults to today). */
|
||||
export function addDays(iso: string, days: number): string {
|
||||
const base = iso ? new Date(`${iso}T00:00:00`) : new Date();
|
||||
base.setDate(base.getDate() + (Number.isFinite(days) ? days : 0));
|
||||
const offset = base.getTimezoneOffset();
|
||||
return new Date(base.getTime() - offset * 60_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function round2(n: number): number {
|
||||
return Math.round((Number.isFinite(n) ? n : 0) * 100) / 100;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { round2 } from "./format";
|
||||
import type { InvoiceItem, TaxType } from "./types";
|
||||
|
||||
/** Amount for a single line: a fixed total, or rate × quantity. */
|
||||
export function computeLineAmount(item: InvoiceItem): number {
|
||||
if (item.mode === "rate") {
|
||||
return round2((item.rate || 0) * (item.quantity || 0));
|
||||
}
|
||||
return round2(item.amount || 0);
|
||||
}
|
||||
|
||||
export interface Totals {
|
||||
subtotal: number;
|
||||
discount: number;
|
||||
taxable: number;
|
||||
cgst: number;
|
||||
sgst: number;
|
||||
igst: number;
|
||||
taxTotal: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function computeTotals(
|
||||
items: InvoiceItem[],
|
||||
discount: number,
|
||||
taxType: TaxType,
|
||||
taxRate: number,
|
||||
): Totals {
|
||||
const subtotal = round2(items.reduce((sum, item) => sum + computeLineAmount(item), 0));
|
||||
const discountValue = round2(Math.min(Math.max(discount || 0, 0), subtotal));
|
||||
const taxable = round2(subtotal - discountValue);
|
||||
|
||||
let cgst = 0;
|
||||
let sgst = 0;
|
||||
let igst = 0;
|
||||
if (taxType === "cgst_sgst") {
|
||||
cgst = round2((taxable * (taxRate / 2)) / 100);
|
||||
sgst = round2((taxable * (taxRate / 2)) / 100);
|
||||
} else if (taxType === "igst") {
|
||||
igst = round2((taxable * taxRate) / 100);
|
||||
}
|
||||
const taxTotal = round2(cgst + sgst + igst);
|
||||
|
||||
return {
|
||||
subtotal,
|
||||
discount: discountValue,
|
||||
taxable,
|
||||
cgst,
|
||||
sgst,
|
||||
igst,
|
||||
taxTotal,
|
||||
total: round2(taxable + taxTotal),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
const ONES = [
|
||||
"",
|
||||
"One",
|
||||
"Two",
|
||||
"Three",
|
||||
"Four",
|
||||
"Five",
|
||||
"Six",
|
||||
"Seven",
|
||||
"Eight",
|
||||
"Nine",
|
||||
"Ten",
|
||||
"Eleven",
|
||||
"Twelve",
|
||||
"Thirteen",
|
||||
"Fourteen",
|
||||
"Fifteen",
|
||||
"Sixteen",
|
||||
"Seventeen",
|
||||
"Eighteen",
|
||||
"Nineteen",
|
||||
];
|
||||
|
||||
const TENS = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"];
|
||||
|
||||
function twoDigits(n: number): string {
|
||||
if (n < 20) return ONES[n];
|
||||
const tens = Math.floor(n / 10);
|
||||
const ones = n % 10;
|
||||
return TENS[tens] + (ones ? " " + ONES[ones] : "");
|
||||
}
|
||||
|
||||
function threeDigits(n: number): string {
|
||||
const hundreds = Math.floor(n / 100);
|
||||
const rest = n % 100;
|
||||
let out = "";
|
||||
if (hundreds > 0) out += ONES[hundreds] + " Hundred";
|
||||
if (rest > 0) out += (out ? " " : "") + twoDigits(rest);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whole-number to words using the Indian numbering system (thousand, lakh, crore). */
|
||||
function wholeToWords(n: number): string {
|
||||
if (n === 0) return "Zero";
|
||||
const parts: string[] = [];
|
||||
|
||||
const crore = Math.floor(n / 10000000);
|
||||
let rest = n % 10000000;
|
||||
if (crore > 0) parts.push(`${wholeToWords(crore)} Crore`);
|
||||
|
||||
const lakh = Math.floor(rest / 100000);
|
||||
rest %= 100000;
|
||||
if (lakh > 0) parts.push(`${twoDigits(lakh)} Lakh`);
|
||||
|
||||
const thousand = Math.floor(rest / 1000);
|
||||
rest %= 1000;
|
||||
if (thousand > 0) parts.push(`${twoDigits(thousand)} Thousand`);
|
||||
|
||||
if (rest > 0) parts.push(threeDigits(rest));
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** e.g. 25000 -> "Indian Rupees Twenty Five Thousand Only" */
|
||||
export function amountInWords(amount: number, currency = "Indian Rupees"): string {
|
||||
const value = Number.isFinite(amount) ? Math.abs(amount) : 0;
|
||||
const rupees = Math.floor(value);
|
||||
const paise = Math.round((value - rupees) * 100);
|
||||
|
||||
let out = "";
|
||||
if (currency) out += `${currency} `;
|
||||
out += wholeToWords(rupees);
|
||||
if (paise > 0) out += ` and ${twoDigits(paise)} Paise`;
|
||||
return `${out} Only`;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { pdf } from "@react-pdf/renderer";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { InvoiceDocument, type InvoicePdfProps } from "../pdf/InvoiceDocument";
|
||||
import { api } from "./api";
|
||||
import type { BankAccount, Invoice, Settings } from "./types";
|
||||
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result ?? "");
|
||||
resolve(result.substring(result.indexOf(",") + 1));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildPdfProps(
|
||||
invoice: Invoice,
|
||||
settings: Settings,
|
||||
bank: BankAccount | null,
|
||||
): Promise<InvoicePdfProps> {
|
||||
const logoDataUri = settings.logoPath ? await api.readAssetDataUri(settings.logoPath) : null;
|
||||
const signatureDataUri = invoice.signaturePath
|
||||
? await api.readAssetDataUri(invoice.signaturePath)
|
||||
: null;
|
||||
|
||||
let resolvedBank = bank;
|
||||
if (!resolvedBank && invoice.bankSnapshot) {
|
||||
try {
|
||||
resolvedBank = JSON.parse(invoice.bankSnapshot) as BankAccount;
|
||||
} catch {
|
||||
resolvedBank = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
vendor: settings,
|
||||
bank: resolvedBank,
|
||||
logoDataUri,
|
||||
signatureDataUri,
|
||||
number: invoice.number,
|
||||
invoiceDate: invoice.invoiceDate,
|
||||
dueDate: invoice.dueDate,
|
||||
clientName: invoice.clientName,
|
||||
clientAddress: invoice.clientAddress,
|
||||
clientGstin: invoice.clientGstin,
|
||||
poNumber: invoice.poNumber,
|
||||
placeOfSupplyStateCode: invoice.placeOfSupplyStateCode,
|
||||
items: invoice.items,
|
||||
subtotal: invoice.subtotal,
|
||||
discount: invoice.discount,
|
||||
taxType: invoice.taxType,
|
||||
taxRate: invoice.taxRate,
|
||||
cgst: invoice.cgstAmount,
|
||||
sgst: invoice.sgstAmount,
|
||||
igst: invoice.igstAmount,
|
||||
total: invoice.total,
|
||||
amountInWords: invoice.amountInWords,
|
||||
notes: invoice.notes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Render props straight to a PDF blob. */
|
||||
export async function renderPdfBlob(props: InvoicePdfProps): Promise<Blob> {
|
||||
return pdf(<InvoiceDocument {...props} />).toBlob();
|
||||
}
|
||||
|
||||
/** Render a PDF and prompt the user for a save location. */
|
||||
export async function exportPdfFromProps(
|
||||
props: InvoicePdfProps,
|
||||
fileName: string,
|
||||
): Promise<string | null> {
|
||||
const blob = await renderPdfBlob(props);
|
||||
const dataBase64 = await blobToBase64(blob);
|
||||
const suggested = fileName.replace(/[^A-Za-z0-9._-]+/g, "-") || "invoice";
|
||||
const path = await save({
|
||||
defaultPath: `${suggested}.pdf`,
|
||||
filters: [{ name: "PDF document", extensions: ["pdf"] }],
|
||||
});
|
||||
if (!path) return null;
|
||||
await api.saveBinaryFile(path, dataBase64);
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function exportInvoicePdf(
|
||||
invoice: Invoice,
|
||||
settings: Settings,
|
||||
bank: BankAccount | null,
|
||||
): Promise<string | null> {
|
||||
const props = await buildPdfProps(invoice, settings, bank);
|
||||
return exportPdfFromProps(props, invoice.number);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
export type TaxType = "none" | "cgst_sgst" | "igst";
|
||||
export type LineMode = "fixed" | "rate";
|
||||
export type LineUnit = "second" | "minute" | "hour" | "session" | "unit";
|
||||
|
||||
export interface Settings {
|
||||
vendorName: string;
|
||||
vendorAddress: string;
|
||||
vendorEmail: string;
|
||||
vendorPhone: string;
|
||||
vendorPan: string;
|
||||
vendorGstin: string;
|
||||
vendorStateCode: string;
|
||||
logoPath: string | null;
|
||||
signaturePath: string | null;
|
||||
defaultBankId: number | null;
|
||||
defaultTaxRate: number;
|
||||
defaultTaxType: TaxType;
|
||||
paymentTermsDays: number;
|
||||
currency: string;
|
||||
onboarded: boolean;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
export interface BankAccount {
|
||||
id: number | null;
|
||||
label: string;
|
||||
bankName: string;
|
||||
accountName: string;
|
||||
accountNo: string;
|
||||
branch: string;
|
||||
ifsc: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface InvoiceSeries {
|
||||
id: number;
|
||||
prefix: string;
|
||||
padding: number;
|
||||
nextNumber: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
nextInvoiceNumber: string;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
id: number | null;
|
||||
name: string;
|
||||
address: string;
|
||||
gstin: string;
|
||||
stateCode: string;
|
||||
poNumber: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface InvoiceItem {
|
||||
id?: number | null;
|
||||
description: string;
|
||||
mode: LineMode;
|
||||
rate: number;
|
||||
unit: LineUnit;
|
||||
quantity: number;
|
||||
amount: number;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface InvoiceInput {
|
||||
invoiceDate: string;
|
||||
dueDate: string;
|
||||
clientId: number | null;
|
||||
clientName: string;
|
||||
clientAddress: string;
|
||||
clientGstin: string;
|
||||
poNumber: string;
|
||||
placeOfSupplyStateCode: string;
|
||||
subtotal: number;
|
||||
discount: number;
|
||||
taxType: TaxType;
|
||||
taxRate: number;
|
||||
cgstAmount: number;
|
||||
sgstAmount: number;
|
||||
igstAmount: number;
|
||||
total: number;
|
||||
amountInWords: string;
|
||||
bankAccountId: number | null;
|
||||
bankSnapshot: string;
|
||||
signaturePath: string | null;
|
||||
notes: string;
|
||||
saveClient: boolean;
|
||||
items: InvoiceItem[];
|
||||
}
|
||||
|
||||
export interface Invoice extends Omit<InvoiceInput, "saveClient"> {
|
||||
id: number;
|
||||
number: string;
|
||||
seriesId: number | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
items: InvoiceItem[];
|
||||
}
|
||||
|
||||
export interface InvoiceSummary {
|
||||
id: number;
|
||||
number: string;
|
||||
invoiceDate: string;
|
||||
dueDate: string;
|
||||
clientName: string;
|
||||
total: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const EMPTY_ITEM: InvoiceItem = {
|
||||
description: "",
|
||||
mode: "fixed",
|
||||
rate: 0,
|
||||
unit: "unit",
|
||||
quantity: 1,
|
||||
amount: 0,
|
||||
sortOrder: 0,
|
||||
};
|
||||
|
||||
export const INDIAN_STATES: { code: string; name: string }[] = [
|
||||
{ code: "01", name: "Jammu & Kashmir" },
|
||||
{ code: "02", name: "Himachal Pradesh" },
|
||||
{ code: "03", name: "Punjab" },
|
||||
{ code: "04", name: "Chandigarh" },
|
||||
{ code: "05", name: "Uttarakhand" },
|
||||
{ code: "06", name: "Haryana" },
|
||||
{ code: "07", name: "Delhi" },
|
||||
{ code: "08", name: "Rajasthan" },
|
||||
{ code: "09", name: "Uttar Pradesh" },
|
||||
{ code: "10", name: "Bihar" },
|
||||
{ code: "11", name: "Sikkim" },
|
||||
{ code: "12", name: "Arunachal Pradesh" },
|
||||
{ code: "13", name: "Nagaland" },
|
||||
{ code: "14", name: "Manipur" },
|
||||
{ code: "15", name: "Mizoram" },
|
||||
{ code: "16", name: "Tripura" },
|
||||
{ code: "17", name: "Meghalaya" },
|
||||
{ code: "18", name: "Assam" },
|
||||
{ code: "19", name: "West Bengal" },
|
||||
{ code: "20", name: "Jharkhand" },
|
||||
{ code: "21", name: "Odisha" },
|
||||
{ code: "22", name: "Chhattisgarh" },
|
||||
{ code: "23", name: "Madhya Pradesh" },
|
||||
{ code: "24", name: "Gujarat" },
|
||||
{ code: "26", name: "Dadra & Nagar Haveli and Daman & Diu" },
|
||||
{ code: "27", name: "Maharashtra" },
|
||||
{ code: "29", name: "Karnataka" },
|
||||
{ code: "30", name: "Goa" },
|
||||
{ code: "31", name: "Lakshadweep" },
|
||||
{ code: "32", name: "Kerala" },
|
||||
{ code: "33", name: "Tamil Nadu" },
|
||||
{ code: "34", name: "Puducherry" },
|
||||
{ code: "35", name: "Andaman & Nicobar Islands" },
|
||||
{ code: "36", name: "Telangana" },
|
||||
{ code: "37", name: "Andhra Pradesh" },
|
||||
{ code: "38", name: "Ladakh" },
|
||||
];
|
||||
|
||||
export const UNIT_LABELS: Record<LineUnit, string> = {
|
||||
second: "per second",
|
||||
minute: "per minute",
|
||||
hour: "per hour",
|
||||
session: "per session",
|
||||
unit: "per unit",
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
const PAN_RE = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
|
||||
const IFSC_RE = /^[A-Z]{4}0[A-Z0-9]{6}$/;
|
||||
const GSTIN_RE = /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/;
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const PHONE_RE = /^[0-9+\-()\s]{6,16}$/;
|
||||
|
||||
const norm = (v: string) => v.trim().toUpperCase();
|
||||
|
||||
export function panError(value: string): string | undefined {
|
||||
if (!value.trim()) return undefined;
|
||||
return PAN_RE.test(norm(value)) ? undefined : "Enter a valid PAN, e.g. CPOPP7388K";
|
||||
}
|
||||
|
||||
export function ifscError(value: string): string | undefined {
|
||||
if (!value.trim()) return undefined;
|
||||
return IFSC_RE.test(norm(value)) ? undefined : "Enter a valid IFSC, e.g. SBIN0017060";
|
||||
}
|
||||
|
||||
export function gstinError(value: string): string | undefined {
|
||||
const v = norm(value);
|
||||
if (!v || v === "NA") return undefined;
|
||||
return GSTIN_RE.test(v) ? undefined : "Enter a valid 15-character GSTIN";
|
||||
}
|
||||
|
||||
export function emailError(value: string): string | undefined {
|
||||
if (!value.trim()) return undefined;
|
||||
return EMAIL_RE.test(value.trim()) ? undefined : "Enter a valid email address";
|
||||
}
|
||||
|
||||
export function phoneError(value: string): string | undefined {
|
||||
if (!value.trim()) return undefined;
|
||||
return PHONE_RE.test(value.trim()) ? undefined : "Enter a valid phone number";
|
||||
}
|
||||
|
||||
export function accountError(value: string): string | undefined {
|
||||
if (!value.trim()) return undefined;
|
||||
return /^[0-9]{6,20}$/.test(value.trim()) ? undefined : "Account number should be 6–20 digits";
|
||||
}
|
||||
|
||||
export const isPan = (v: string) => PAN_RE.test(norm(v));
|
||||
export const isIfsc = (v: string) => IFSC_RE.test(norm(v));
|
||||
export const isGstin = (v: string) => GSTIN_RE.test(norm(v));
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles/carbon.scss";
|
||||
|
||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,324 @@
|
||||
import { Document, Font, Image, Page, StyleSheet, Text, View } from "@react-pdf/renderer";
|
||||
import { UNIT_LABELS, type BankAccount, type InvoiceItem, type Settings, type TaxType } from "../lib/types";
|
||||
import { computeLineAmount } from "../lib/invoice";
|
||||
|
||||
Font.register({
|
||||
family: "IBM Plex Sans",
|
||||
fonts: [
|
||||
{ src: "/fonts/IBMPlexSans-Light.woff", fontWeight: 300 },
|
||||
{ src: "/fonts/IBMPlexSans-Regular.woff", fontWeight: 400 },
|
||||
{ src: "/fonts/IBMPlexSans-Italic.woff", fontWeight: 400, fontStyle: "italic" },
|
||||
{ src: "/fonts/IBMPlexSans-Medium.woff", fontWeight: 500 },
|
||||
{ src: "/fonts/IBMPlexSans-SemiBold.woff", fontWeight: 600 },
|
||||
{ src: "/fonts/IBMPlexSans-Bold.woff", fontWeight: 700 },
|
||||
],
|
||||
});
|
||||
|
||||
Font.register({
|
||||
family: "IBM Plex Mono",
|
||||
fonts: [
|
||||
{ src: "/fonts/IBMPlexMono-Regular.woff", fontWeight: 400 },
|
||||
{ src: "/fonts/IBMPlexMono-SemiBold.woff", fontWeight: 600 },
|
||||
],
|
||||
});
|
||||
|
||||
const amountFmt = new Intl.NumberFormat("en-IN", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const money = (n: number) => `${"\u20B9"}${amountFmt.format(Number.isFinite(n) ? n : 0)}`;
|
||||
|
||||
const dateFmt = (iso: string) => {
|
||||
if (!iso) return "\u2014";
|
||||
const d = new Date(`${iso}T00:00:00`);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
|
||||
};
|
||||
|
||||
export interface InvoicePdfProps {
|
||||
vendor: Settings;
|
||||
bank: BankAccount | null;
|
||||
logoDataUri: string | null;
|
||||
signatureDataUri: string | null;
|
||||
number: string;
|
||||
invoiceDate: string;
|
||||
dueDate: string;
|
||||
clientName: string;
|
||||
clientAddress: string;
|
||||
clientGstin: string;
|
||||
poNumber: string;
|
||||
placeOfSupplyStateCode: string;
|
||||
items: InvoiceItem[];
|
||||
subtotal: number;
|
||||
discount: number;
|
||||
taxType: TaxType;
|
||||
taxRate: number;
|
||||
cgst: number;
|
||||
sgst: number;
|
||||
igst: number;
|
||||
total: number;
|
||||
amountInWords: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
fontFamily: "IBM Plex Sans",
|
||||
fontSize: 9.5,
|
||||
color: "#161616",
|
||||
paddingTop: 40,
|
||||
paddingBottom: 48,
|
||||
paddingHorizontal: 40,
|
||||
lineHeight: 1.45,
|
||||
},
|
||||
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" },
|
||||
vendor: { flexDirection: "row", maxWidth: "58%" },
|
||||
logo: { maxHeight: 54, maxWidth: 170, marginRight: 14, objectFit: "contain" },
|
||||
vendorName: { fontSize: 18, fontWeight: 600, marginBottom: 3 },
|
||||
vendorLine: { color: "#393939" },
|
||||
meta: { textAlign: "right", maxWidth: "40%" },
|
||||
title: { fontSize: 18, fontWeight: 600, letterSpacing: 1.5, textTransform: "uppercase", marginBottom: 8 },
|
||||
metaRow: { flexDirection: "row", justifyContent: "space-between", gap: 14 },
|
||||
metaLabel: { color: "#525252" },
|
||||
metaValue: { fontWeight: 600 },
|
||||
divider: { borderBottomWidth: 1, borderBottomColor: "#e0e0e0", marginTop: 16, marginBottom: 16 },
|
||||
split: { flexDirection: "row", justifyContent: "space-between", gap: 24 },
|
||||
blockLabel: { fontSize: 7.5, letterSpacing: 1, textTransform: "uppercase", color: "#525252", marginBottom: 3 },
|
||||
strong: { fontWeight: 600 },
|
||||
block: { maxWidth: "48%" },
|
||||
table: { marginTop: 22, borderTopWidth: 1.4, borderTopColor: "#161616" },
|
||||
tr: { flexDirection: "row", borderBottomWidth: 0.6, borderBottomColor: "#e0e0e0", paddingVertical: 6 },
|
||||
th: { fontSize: 7.5, letterSpacing: 0.8, textTransform: "uppercase", color: "#525252", paddingBottom: 4 },
|
||||
colDesc: { flex: 5 },
|
||||
colRate: { flex: 2, textAlign: "right" },
|
||||
colQty: { flex: 2, textAlign: "right" },
|
||||
colTotal: { flex: 2, textAlign: "right" },
|
||||
totalsWrap: { flexDirection: "row", justifyContent: "flex-end", marginTop: 12 },
|
||||
totals: { width: 250 },
|
||||
totalsRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2.5 },
|
||||
grand: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
borderTopWidth: 1.4,
|
||||
borderTopColor: "#161616",
|
||||
marginTop: 4,
|
||||
paddingTop: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
},
|
||||
words: { marginTop: 12, fontStyle: "italic" },
|
||||
bottom: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-end", marginTop: 34 },
|
||||
signature: { textAlign: "right", minWidth: 180 },
|
||||
signatureImg: { maxHeight: 58, maxWidth: 190, objectFit: "contain", marginLeft: "auto" },
|
||||
signatureName: {
|
||||
marginTop: 4,
|
||||
paddingTop: 3,
|
||||
borderTopWidth: 0.8,
|
||||
borderTopColor: "#8d8d8d",
|
||||
fontWeight: 600,
|
||||
},
|
||||
fineprint: { fontSize: 7.5, color: "#6f6f6f", textAlign: "right", marginTop: 6, maxWidth: 220 },
|
||||
footer: {
|
||||
position: "absolute",
|
||||
bottom: 24,
|
||||
left: 40,
|
||||
right: 40,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
borderTopWidth: 0.6,
|
||||
borderTopColor: "#e0e0e0",
|
||||
paddingTop: 6,
|
||||
fontSize: 7.5,
|
||||
color: "#6f6f6f",
|
||||
},
|
||||
});
|
||||
|
||||
export function InvoiceDocument(props: InvoicePdfProps) {
|
||||
const {
|
||||
vendor,
|
||||
bank,
|
||||
logoDataUri,
|
||||
signatureDataUri,
|
||||
number,
|
||||
invoiceDate,
|
||||
dueDate,
|
||||
clientName,
|
||||
clientAddress,
|
||||
clientGstin,
|
||||
poNumber,
|
||||
placeOfSupplyStateCode,
|
||||
items,
|
||||
subtotal,
|
||||
discount,
|
||||
taxType,
|
||||
taxRate,
|
||||
cgst,
|
||||
sgst,
|
||||
igst,
|
||||
total,
|
||||
amountInWords,
|
||||
notes,
|
||||
} = props;
|
||||
|
||||
const isTaxInvoice = taxType !== "none";
|
||||
const contact = [vendor.vendorEmail, vendor.vendorPhone].filter(Boolean).join(" \u00B7 ");
|
||||
|
||||
return (
|
||||
<Document title={`${isTaxInvoice ? "Tax Invoice" : "Invoice"} ${number}`} author={vendor.vendorName}>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.vendor}>
|
||||
{logoDataUri ? <Image style={styles.logo} src={logoDataUri} /> : null}
|
||||
<View>
|
||||
<Text style={styles.vendorName}>{vendor.vendorName || "Vendor"}</Text>
|
||||
<Text style={styles.vendorLine}>{vendor.vendorAddress}</Text>
|
||||
{contact ? <Text style={styles.vendorLine}>{contact}</Text> : null}
|
||||
{vendor.vendorPan ? <Text style={styles.vendorLine}>PAN: {vendor.vendorPan}</Text> : null}
|
||||
<Text style={styles.vendorLine}>GSTIN: {vendor.vendorGstin || "NA"}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.meta}>
|
||||
<Text style={styles.title}>{isTaxInvoice ? "Tax Invoice" : "Invoice"}</Text>
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>Invoice No.</Text>
|
||||
<Text style={styles.metaValue}>{number}</Text>
|
||||
</View>
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>Date</Text>
|
||||
<Text style={styles.metaValue}>{dateFmt(invoiceDate)}</Text>
|
||||
</View>
|
||||
{dueDate ? (
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>Due Date</Text>
|
||||
<Text style={styles.metaValue}>{dateFmt(dueDate)}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.split}>
|
||||
<View style={styles.block}>
|
||||
<Text style={styles.blockLabel}>Billed To</Text>
|
||||
<Text style={styles.strong}>{clientName || "\u2014"}</Text>
|
||||
{clientAddress ? <Text>{clientAddress}</Text> : null}
|
||||
{clientGstin ? <Text>GSTIN: {clientGstin}</Text> : null}
|
||||
{placeOfSupplyStateCode ? <Text>Place of Supply: {placeOfSupplyStateCode}</Text> : null}
|
||||
</View>
|
||||
<View style={styles.block}>
|
||||
{poNumber ? (
|
||||
<>
|
||||
<Text style={styles.blockLabel}>PO Number</Text>
|
||||
<Text style={styles.strong}>{poNumber}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.table}>
|
||||
<View style={[styles.tr, { borderBottomColor: "#161616" }]}>
|
||||
<Text style={[styles.th, styles.colDesc]}>Description</Text>
|
||||
<Text style={[styles.th, styles.colRate]}>Unit Price</Text>
|
||||
<Text style={[styles.th, styles.colQty]}>Qty</Text>
|
||||
<Text style={[styles.th, styles.colTotal]}>Total</Text>
|
||||
</View>
|
||||
{items.map((item, i) => (
|
||||
<View style={styles.tr} key={item.id ?? i}>
|
||||
<Text style={styles.colDesc}>{item.description || "\u2014"}</Text>
|
||||
<Text style={styles.colRate}>
|
||||
{item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "\u2014"}
|
||||
</Text>
|
||||
<Text style={styles.colQty}>{item.mode === "rate" ? item.quantity : 1}</Text>
|
||||
<Text style={styles.colTotal}>{money(computeLineAmount(item))}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.totalsWrap}>
|
||||
<View style={styles.totals}>
|
||||
<View style={styles.totalsRow}>
|
||||
<Text>Subtotal</Text>
|
||||
<Text>{money(subtotal)}</Text>
|
||||
</View>
|
||||
{discount > 0 ? (
|
||||
<View style={styles.totalsRow}>
|
||||
<Text>Discount</Text>
|
||||
<Text>- {money(discount)}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{taxType === "cgst_sgst" ? (
|
||||
<>
|
||||
<View style={styles.totalsRow}>
|
||||
<Text>CGST ({(taxRate / 2).toFixed(2)}%)</Text>
|
||||
<Text>{money(cgst)}</Text>
|
||||
</View>
|
||||
<View style={styles.totalsRow}>
|
||||
<Text>SGST ({(taxRate / 2).toFixed(2)}%)</Text>
|
||||
<Text>{money(sgst)}</Text>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
{taxType === "igst" ? (
|
||||
<View style={styles.totalsRow}>
|
||||
<Text>IGST ({taxRate.toFixed(2)}%)</Text>
|
||||
<Text>{money(igst)}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.grand}>
|
||||
<Text>Total</Text>
|
||||
<Text>{money(total)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.words}>Amount in words: {amountInWords}</Text>
|
||||
|
||||
{notes ? (
|
||||
<View style={{ marginTop: 14 }}>
|
||||
<Text style={styles.blockLabel}>Notes</Text>
|
||||
<Text>{notes}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.bottom}>
|
||||
<View style={[styles.block, { maxWidth: "55%" }]}>
|
||||
{bank ? (
|
||||
<>
|
||||
<Text style={styles.blockLabel}>Pay To</Text>
|
||||
<Text style={styles.strong}>{bank.accountName || vendor.vendorName}</Text>
|
||||
<Text>{bank.bankName}</Text>
|
||||
{bank.accountNo ? <Text>Account No.: {bank.accountNo}</Text> : null}
|
||||
{bank.branch ? <Text>Branch: {bank.branch}</Text> : null}
|
||||
{bank.ifsc ? <Text>IFSC: {bank.ifsc}</Text> : null}
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.signature}>
|
||||
{signatureDataUri ? (
|
||||
<>
|
||||
<Image style={styles.signatureImg} src={signatureDataUri} />
|
||||
<Text style={styles.signatureName}>{vendor.vendorName}</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.signatureName}>{vendor.vendorName}</Text>
|
||||
<Text style={styles.fineprint}>
|
||||
Computer-generated invoice; no signature is required.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer} fixed>
|
||||
<Text>{vendor.vendorName}</Text>
|
||||
<Text>{"Generated with Voiced \u2014 a Bongbetic product"}</Text>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
// Voiced — Carbon Design System, compiled with self-hosted IBM Plex for offline use.
|
||||
// Font-face emission from Carbon is disabled so we can serve the fonts ourselves
|
||||
// instead of pulling them from IBM's CDN (the app must work with no network).
|
||||
@use '@carbon/react' with (
|
||||
$css--font-face: false,
|
||||
$use-akamai-cdn: false
|
||||
);
|
||||
|
||||
/* ---------- IBM Plex (bundled in /public/fonts) ---------- */
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('/fonts/IBMPlexSans-Light.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('/fonts/IBMPlexSans-LightItalic.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/IBMPlexSans-Regular.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/IBMPlexSans-Italic.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/IBMPlexSans-Medium.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/IBMPlexSans-MediumItalic.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/IBMPlexSans-SemiBold.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/IBMPlexSans-SemiBoldItalic.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/fonts/IBMPlexSans-Bold.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/fonts/IBMPlexSans-BoldItalic.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/IBMPlexMono-Regular.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/IBMPlexMono-Medium.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/IBMPlexMono-SemiBold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* ---------- App shell ---------- */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--cds-background);
|
||||
color: var(--cds-text-primary);
|
||||
}
|
||||
|
||||
.voiced-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.voiced-brand__logo {
|
||||
height: 1.25rem;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.voiced-brand__divider {
|
||||
width: 1px;
|
||||
height: 1.25rem;
|
||||
background: var(--cds-border-subtle-01);
|
||||
}
|
||||
|
||||
.voiced-brand__product {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.voiced-header-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.voiced-page {
|
||||
padding: 2rem;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.voiced-page__title {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.voiced-page__subtitle {
|
||||
color: var(--cds-text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.voiced-stack > * + * {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.voiced-row-gap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.voiced-muted {
|
||||
color: var(--cds-text-secondary);
|
||||
}
|
||||
|
||||
.voiced-empty {
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--cds-text-secondary);
|
||||
}
|
||||
|
||||
/* ---------- Invoice preview ("paper") ---------- */
|
||||
.inv-preview-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.inv-paper {
|
||||
background: #ffffff;
|
||||
color: #161616;
|
||||
width: 100%;
|
||||
max-width: 820px;
|
||||
padding: 40px 44px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
font-family: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inv-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.inv-vendor {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.inv-vendor__logo {
|
||||
max-height: 56px;
|
||||
max-width: 220px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.inv-vendor__name {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.inv-vendor__line {
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.inv-meta {
|
||||
text-align: right;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.inv-meta__title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.inv-meta__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.inv-meta__label {
|
||||
color: #525252;
|
||||
}
|
||||
|
||||
.inv-meta__value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inv-split {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.inv-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.inv-block__label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #525252;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.inv-block__strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inv-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.inv-table th {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #525252;
|
||||
border-bottom: 1.5px solid #161616;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.inv-table td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.inv-table .num {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inv-totals {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.inv-totals__table {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.inv-totals__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.inv-totals__row--grand {
|
||||
border-top: 1.5px solid #161616;
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inv-words {
|
||||
margin-top: 16px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.inv-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-top: 32px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.inv-signature {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.inv-signature__img {
|
||||
max-height: 64px;
|
||||
max-width: 220px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.inv-signature__name {
|
||||
margin-top: 6px;
|
||||
font-weight: 600;
|
||||
border-top: 1px solid #8d8d8d;
|
||||
padding-top: 4px;
|
||||
display: inline-block;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.inv-fineprint {
|
||||
margin-top: 8px;
|
||||
font-size: 10px;
|
||||
color: #6f6f6f;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.inv-footer {
|
||||
margin-top: 28px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
font-size: 10px;
|
||||
color: #6f6f6f;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.inv-paper {
|
||||
box-shadow: none;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Column,
|
||||
Grid,
|
||||
InlineNotification,
|
||||
NumberInput,
|
||||
RadioButton,
|
||||
RadioButtonGroup,
|
||||
Select,
|
||||
SelectItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TextArea,
|
||||
TextInput,
|
||||
Toggle,
|
||||
} from "@carbon/react";
|
||||
import { Add, Save, TrashCan } from "@carbon/icons-react";
|
||||
import { confirm } from "@tauri-apps/plugin-dialog";
|
||||
import { api } from "../lib/api";
|
||||
import { INDIAN_STATES, type BankAccount, type Settings, type TaxType } from "../lib/types";
|
||||
import { emailError, ifscError, panError, phoneError } from "../lib/validators";
|
||||
import { ImagePicker } from "../components/ImagePicker";
|
||||
|
||||
const blankBank = (): BankAccount => ({
|
||||
id: null,
|
||||
label: "",
|
||||
bankName: "",
|
||||
accountName: "",
|
||||
accountNo: "",
|
||||
branch: "",
|
||||
ifsc: "",
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
export default function AppSettings({
|
||||
settings,
|
||||
onSaved,
|
||||
}: {
|
||||
settings: Settings;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<Settings>({ ...settings });
|
||||
const [banks, setBanks] = useState<BankAccount[]>([]);
|
||||
const [editing, setEditing] = useState<BankAccount | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const loadBanks = async () => {
|
||||
try {
|
||||
setBanks(await api.listBanks());
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadBanks();
|
||||
}, []);
|
||||
|
||||
const set = <K extends keyof Settings>(key: K, value: Settings[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const setBank = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
|
||||
setEditing((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||
|
||||
const saveAll = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await api.saveSettings(form);
|
||||
setNotice("Settings saved.");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveBank = async () => {
|
||||
if (!editing) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await api.saveBank(editing);
|
||||
if (saved.isDefault || form.defaultBankId === null) {
|
||||
setForm((prev) => ({ ...prev, defaultBankId: saved.id }));
|
||||
}
|
||||
setEditing(null);
|
||||
await loadBanks();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeBank = async (bank: BankAccount) => {
|
||||
if (bank.id === null) return;
|
||||
const ok = await confirm(`Delete bank account “${bank.bankName}”?`, {
|
||||
title: "Delete bank account",
|
||||
kind: "warning",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.deleteBank(bank.id);
|
||||
if (form.defaultBankId === bank.id) set("defaultBankId", null);
|
||||
await loadBanks();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<h1 className="voiced-page__title">Settings</h1>
|
||||
<p className="voiced-page__subtitle">Vendor details, defaults and bank accounts.</p>
|
||||
|
||||
{error ? (
|
||||
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
|
||||
) : null}
|
||||
{notice ? (
|
||||
<InlineNotification
|
||||
kind="success"
|
||||
lowContrast
|
||||
title="Saved"
|
||||
subtitle={notice}
|
||||
onCloseButtonClick={() => setNotice(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Vendor details</h3>
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<TextInput
|
||||
id="s-vendorName"
|
||||
labelText="Name on invoice"
|
||||
value={form.vendorName}
|
||||
onChange={(e) => set("vendorName", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<TextArea
|
||||
id="s-vendorAddress"
|
||||
labelText="Address"
|
||||
rows={3}
|
||||
value={form.vendorAddress}
|
||||
onChange={(e) => set("vendorAddress", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="s-vendorEmail"
|
||||
labelText="Email"
|
||||
value={form.vendorEmail}
|
||||
onChange={(e) => set("vendorEmail", e.target.value)}
|
||||
invalid={Boolean(emailError(form.vendorEmail))}
|
||||
invalidText={emailError(form.vendorEmail)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="s-vendorPhone"
|
||||
labelText="Phone"
|
||||
value={form.vendorPhone}
|
||||
onChange={(e) => set("vendorPhone", e.target.value)}
|
||||
invalid={Boolean(phoneError(form.vendorPhone))}
|
||||
invalidText={phoneError(form.vendorPhone)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="s-vendorPan"
|
||||
labelText="PAN"
|
||||
value={form.vendorPan}
|
||||
onChange={(e) => set("vendorPan", e.target.value.toUpperCase())}
|
||||
invalid={Boolean(panError(form.vendorPan))}
|
||||
invalidText={panError(form.vendorPan)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="s-vendorGstin"
|
||||
labelText="GSTIN (blank if unregistered)"
|
||||
value={form.vendorGstin}
|
||||
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<Select
|
||||
id="s-vendorState"
|
||||
labelText="State of supply"
|
||||
value={form.vendorStateCode}
|
||||
onChange={(e) => set("vendorStateCode", e.target.value)}
|
||||
>
|
||||
{INDIAN_STATES.map((s) => (
|
||||
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
|
||||
))}
|
||||
</Select>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={8}>
|
||||
<ImagePicker
|
||||
kind="logo"
|
||||
label="Vendor logo"
|
||||
value={form.logoPath}
|
||||
onChange={(path) => set("logoPath", path)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={8}>
|
||||
<ImagePicker
|
||||
kind="signature"
|
||||
label="Default signature"
|
||||
value={form.signaturePath}
|
||||
onChange={(path) => set("signaturePath", path)}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
</section>
|
||||
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Defaults</h3>
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<NumberInput
|
||||
id="s-taxRate"
|
||||
label="Default tax rate (%)"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
value={form.defaultTaxRate}
|
||||
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<NumberInput
|
||||
id="s-terms"
|
||||
label="Payment terms (days)"
|
||||
min={0}
|
||||
max={365}
|
||||
value={form.paymentTermsDays}
|
||||
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={6}>
|
||||
<Select
|
||||
id="s-theme"
|
||||
labelText="Theme"
|
||||
value={form.theme}
|
||||
onChange={(e) => set("theme", e.target.value)}
|
||||
>
|
||||
<SelectItem value="g10" text="Carbon light (g10)" />
|
||||
<SelectItem value="g100" text="Carbon dark (g100)" />
|
||||
</Select>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<RadioButtonGroup
|
||||
legendText="Default tax treatment"
|
||||
name="s-taxType"
|
||||
valueSelected={form.defaultTaxType}
|
||||
onChange={(value) => set("defaultTaxType", value as TaxType)}
|
||||
>
|
||||
<RadioButton id="s-tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
|
||||
<RadioButton id="s-tt-igst" labelText="IGST (inter-state)" value="igst" />
|
||||
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
|
||||
</RadioButtonGroup>
|
||||
</Column>
|
||||
</Grid>
|
||||
</section>
|
||||
|
||||
<section className="voiced-stack" style={{ marginBottom: "2rem" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h3>Bank accounts</h3>
|
||||
<Button kind="tertiary" size="sm" renderIcon={Add} onClick={() => setEditing(blankBank())}>
|
||||
Add account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{banks.length > 0 ? (
|
||||
<TableContainer>
|
||||
<Table aria-label="Bank accounts">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Bank</TableHeader>
|
||||
<TableHeader>Account name</TableHeader>
|
||||
<TableHeader>Account no.</TableHeader>
|
||||
<TableHeader>IFSC</TableHeader>
|
||||
<TableHeader>Default</TableHeader>
|
||||
<TableHeader>Actions</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{banks.map((bank) => (
|
||||
<TableRow key={bank.id ?? bank.bankName}>
|
||||
<TableCell>{bank.bankName}</TableCell>
|
||||
<TableCell>{bank.accountName}</TableCell>
|
||||
<TableCell>{bank.accountNo}</TableCell>
|
||||
<TableCell>{bank.ifsc}</TableCell>
|
||||
<TableCell>{bank.isDefault ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="voiced-header-action">
|
||||
<Button kind="ghost" size="sm" onClick={() => setEditing({ ...bank })}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={TrashCan}
|
||||
onClick={() => removeBank(bank)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<div className="voiced-empty">No bank accounts yet.</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<div style={{ border: "1px solid var(--cds-border-subtle-01)", padding: "1rem" }}>
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="b-label"
|
||||
labelText="Label"
|
||||
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 kind="ghost" size="sm" onClick={() => setEditing(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<Button renderIcon={Save} onClick={saveAll} disabled={busy}>
|
||||
{busy ? "Saving…" : "Save settings"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
InlineLoading,
|
||||
InlineNotification,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@carbon/react";
|
||||
import { Download, TrashCan } from "@carbon/icons-react";
|
||||
import { confirm } from "@tauri-apps/plugin-dialog";
|
||||
import { api } from "../lib/api";
|
||||
import { formatAmount, formatDate } from "../lib/format";
|
||||
import { exportInvoicePdf } from "../lib/pdf";
|
||||
import type { InvoiceSummary, Settings } from "../lib/types";
|
||||
|
||||
export default function InvoiceHistory({ settings }: { settings: Settings }) {
|
||||
const [rows, setRows] = useState<InvoiceSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setRows(await api.listInvoices());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const onExport = async (id: number) => {
|
||||
setBusyId(id);
|
||||
setError(null);
|
||||
try {
|
||||
const invoice = await api.getInvoice(id);
|
||||
await exportInvoicePdf(invoice, settings, null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (row: InvoiceSummary) => {
|
||||
const ok = await confirm(`Delete invoice ${row.number}? This cannot be undone.`, {
|
||||
title: "Delete invoice",
|
||||
kind: "warning",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.deleteInvoice(row.id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<h1 className="voiced-page__title">Invoices</h1>
|
||||
<p className="voiced-page__subtitle">Every invoice you have issued, newest first.</p>
|
||||
|
||||
{error ? (
|
||||
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<InlineLoading description="Loading invoices" />
|
||||
) : rows.length === 0 ? (
|
||||
<div className="voiced-empty">No invoices yet. Create one from “New invoice”.</div>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table aria-label="Invoices">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Number</TableHeader>
|
||||
<TableHeader>Date</TableHeader>
|
||||
<TableHeader>Client</TableHeader>
|
||||
<TableHeader>Total</TableHeader>
|
||||
<TableHeader>Status</TableHeader>
|
||||
<TableHeader>Actions</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>{row.number}</TableCell>
|
||||
<TableCell>{formatDate(row.invoiceDate)}</TableCell>
|
||||
<TableCell>{row.clientName || "—"}</TableCell>
|
||||
<TableCell>₹{formatAmount(row.total)}</TableCell>
|
||||
<TableCell>{row.status}</TableCell>
|
||||
<TableCell>
|
||||
<div className="voiced-header-action">
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={Download}
|
||||
onClick={() => onExport(row.id)}
|
||||
disabled={busyId === row.id}
|
||||
>
|
||||
{busyId === row.id ? "Exporting…" : "PDF"}
|
||||
</Button>
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={TrashCan}
|
||||
onClick={() => onDelete(row)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Column,
|
||||
Dropdown,
|
||||
Grid,
|
||||
InlineNotification,
|
||||
NumberInput,
|
||||
RadioButton,
|
||||
RadioButtonGroup,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea,
|
||||
TextInput,
|
||||
Toggle,
|
||||
} from "@carbon/react";
|
||||
import { Add, Save, TrashCan } from "@carbon/icons-react";
|
||||
import { api } from "../lib/api";
|
||||
import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
|
||||
import type {
|
||||
BankAccount,
|
||||
Client,
|
||||
InvoiceInput,
|
||||
InvoiceItem,
|
||||
LineMode,
|
||||
LineUnit,
|
||||
Settings,
|
||||
TaxType,
|
||||
} from "../lib/types";
|
||||
import { computeLineAmount, computeTotals } from "../lib/invoice";
|
||||
import { amountInWords } from "../lib/numberToWords";
|
||||
import { addDays, formatAmount, todayIso } from "../lib/format";
|
||||
import { buildPdfProps, exportPdfFromProps } from "../lib/pdf";
|
||||
import { InvoicePreview } from "../components/InvoicePreview";
|
||||
import { ImagePicker } from "../components/ImagePicker";
|
||||
import type { InvoicePdfProps } from "../pdf/InvoiceDocument";
|
||||
|
||||
interface NewInvoiceProps {
|
||||
settings: Settings;
|
||||
onSettingsChange: () => void;
|
||||
}
|
||||
|
||||
export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
const today = todayIso();
|
||||
const [invoiceDate, setInvoiceDate] = useState(today);
|
||||
const [dueDate, setDueDate] = useState(addDays(today, settings.paymentTermsDays));
|
||||
const [number, setNumber] = useState("");
|
||||
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [clientId, setClientId] = useState<number | null>(null);
|
||||
const [clientName, setClientName] = useState("");
|
||||
const [clientAddress, setClientAddress] = useState("");
|
||||
const [clientGstin, setClientGstin] = useState("");
|
||||
const [poNumber, setPoNumber] = useState("");
|
||||
const [placeOfSupply, setPlaceOfSupply] = useState(settings.vendorStateCode);
|
||||
const [saveClient, setSaveClient] = useState(false);
|
||||
|
||||
const [items, setItems] = useState<InvoiceItem[]>([{ ...EMPTY_ITEM }]);
|
||||
const [discount, setDiscount] = useState(0);
|
||||
|
||||
const [taxesApplicable, setTaxesApplicable] = useState(settings.defaultTaxType !== "none");
|
||||
const [taxType, setTaxType] = useState<TaxType>(
|
||||
settings.defaultTaxType === "none" ? "cgst_sgst" : settings.defaultTaxType,
|
||||
);
|
||||
const [taxRate, setTaxRate] = useState(settings.defaultTaxRate);
|
||||
|
||||
const [banks, setBanks] = useState<BankAccount[]>([]);
|
||||
const [bankId, setBankId] = useState<number | null>(settings.defaultBankId);
|
||||
const [signaturePath, setSignaturePath] = useState<string | null>(settings.signaturePath);
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const [logoUri, setLogoUri] = useState<string | null>(null);
|
||||
const [signatureUri, setSignatureUri] = useState<string | null>(null);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const [next, clientList, bankList] = await Promise.all([
|
||||
api.peekNextInvoiceNumber(),
|
||||
api.listClients(),
|
||||
api.listBanks(),
|
||||
]);
|
||||
if (!active) return;
|
||||
setNumber(next);
|
||||
setClients(clientList);
|
||||
setBanks(bankList);
|
||||
} catch (e) {
|
||||
if (active) setError(String(e));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!settings.logoPath) {
|
||||
setLogoUri(null);
|
||||
return;
|
||||
}
|
||||
api.readAssetDataUri(settings.logoPath).then((uri) => active && setLogoUri(uri)).catch(() => {});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [settings.logoPath]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!signaturePath) {
|
||||
setSignatureUri(null);
|
||||
return;
|
||||
}
|
||||
api.readAssetDataUri(signaturePath).then((uri) => active && setSignatureUri(uri)).catch(() => {});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [signaturePath]);
|
||||
|
||||
const effectiveTaxType: TaxType = taxesApplicable ? taxType : "none";
|
||||
const totals = useMemo(
|
||||
() => computeTotals(items, discount, effectiveTaxType, taxRate),
|
||||
[items, discount, effectiveTaxType, taxRate],
|
||||
);
|
||||
const words = useMemo(() => amountInWords(totals.total), [totals.total]);
|
||||
const bank = useMemo(() => banks.find((b) => b.id === bankId) ?? null, [banks, bankId]);
|
||||
|
||||
const previewProps: InvoicePdfProps = {
|
||||
vendor: settings,
|
||||
bank,
|
||||
logoDataUri: logoUri,
|
||||
signatureDataUri: signatureUri,
|
||||
number: number || "DRAFT",
|
||||
invoiceDate,
|
||||
dueDate,
|
||||
clientName,
|
||||
clientAddress,
|
||||
clientGstin,
|
||||
poNumber,
|
||||
placeOfSupplyStateCode: placeOfSupply,
|
||||
items,
|
||||
subtotal: totals.subtotal,
|
||||
discount: totals.discount,
|
||||
taxType: effectiveTaxType,
|
||||
taxRate,
|
||||
cgst: totals.cgst,
|
||||
sgst: totals.sgst,
|
||||
igst: totals.igst,
|
||||
total: totals.total,
|
||||
amountInWords: words,
|
||||
notes,
|
||||
};
|
||||
|
||||
const updateItem = (index: number, patch: Partial<InvoiceItem>) =>
|
||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
|
||||
const addItem = () => setItems((prev) => [...prev, { ...EMPTY_ITEM }]);
|
||||
const removeItem = (index: number) =>
|
||||
setItems((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== index)));
|
||||
|
||||
const applyClient = (selected: Client) => {
|
||||
setClientId(selected.id);
|
||||
setClientName(selected.name);
|
||||
setClientAddress(selected.address);
|
||||
setClientGstin(selected.gstin);
|
||||
setPoNumber(selected.poNumber);
|
||||
if (selected.stateCode) setPlaceOfSupply(selected.stateCode);
|
||||
};
|
||||
|
||||
const resetForNext = async () => {
|
||||
setClientId(null);
|
||||
setClientName("");
|
||||
setClientAddress("");
|
||||
setClientGstin("");
|
||||
setPoNumber("");
|
||||
setSaveClient(false);
|
||||
setItems([{ ...EMPTY_ITEM }]);
|
||||
setDiscount(0);
|
||||
setNotes("");
|
||||
setInvoiceDate(todayIso());
|
||||
setDueDate(addDays(todayIso(), settings.paymentTermsDays));
|
||||
try {
|
||||
setNumber(await api.peekNextInvoiceNumber());
|
||||
setClients(await api.listClients());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(null);
|
||||
try {
|
||||
const input: InvoiceInput = {
|
||||
invoiceDate,
|
||||
dueDate,
|
||||
clientId,
|
||||
clientName,
|
||||
clientAddress,
|
||||
clientGstin,
|
||||
poNumber,
|
||||
placeOfSupplyStateCode: placeOfSupply,
|
||||
subtotal: totals.subtotal,
|
||||
discount: totals.discount,
|
||||
taxType: effectiveTaxType,
|
||||
taxRate,
|
||||
cgstAmount: totals.cgst,
|
||||
sgstAmount: totals.sgst,
|
||||
igstAmount: totals.igst,
|
||||
total: totals.total,
|
||||
amountInWords: words,
|
||||
bankAccountId: bankId,
|
||||
bankSnapshot: bank ? JSON.stringify(bank) : "",
|
||||
signaturePath,
|
||||
notes,
|
||||
saveClient,
|
||||
items: items.map((item, index) => ({
|
||||
...item,
|
||||
sortOrder: index,
|
||||
amount: computeLineAmount(item),
|
||||
})),
|
||||
};
|
||||
const invoice = await api.createInvoice(input);
|
||||
const props = await buildPdfProps(invoice, settings, bank);
|
||||
const path = await exportPdfFromProps(props, invoice.number);
|
||||
if (path) setSaved(path);
|
||||
await resetForNext();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<Grid>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", gap: "1rem", flexWrap: "wrap" }}>
|
||||
<div>
|
||||
<h1 className="voiced-page__title">New invoice</h1>
|
||||
<p className="voiced-page__subtitle" style={{ marginBottom: 0 }}>
|
||||
Next number: <strong>{number || "—"}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="voiced-header-action">
|
||||
<Button kind="ghost" onClick={resetForNext} disabled={saving}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button renderIcon={Save} onClick={onSave} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save & export PDF"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<InlineNotification kind="error" lowContrast title="Could not save invoice" subtitle={error} />
|
||||
) : null}
|
||||
{saved ? (
|
||||
<InlineNotification
|
||||
kind="success"
|
||||
lowContrast
|
||||
title="Invoice saved"
|
||||
subtitle={saved}
|
||||
onCloseButtonClick={() => setSaved(null)}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ marginTop: "1.5rem" }} />
|
||||
</Column>
|
||||
|
||||
<Column sm={4} md={8} lg={9} className="voiced-stack">
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<TextInput
|
||||
id="invoiceDate"
|
||||
type="date"
|
||||
labelText="Invoice date"
|
||||
value={invoiceDate}
|
||||
onChange={(e) => {
|
||||
setInvoiceDate(e.target.value);
|
||||
setDueDate(addDays(e.target.value, settings.paymentTermsDays));
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<TextInput
|
||||
id="dueDate"
|
||||
type="date"
|
||||
labelText={`Due date (net ${settings.paymentTermsDays} days)`}
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={6}>
|
||||
<TextInput id="invoiceNumber" labelText="Invoice number" value={number} readOnly />
|
||||
</Column>
|
||||
|
||||
<Column sm={4} md={8} lg={8}>
|
||||
<Dropdown
|
||||
id="savedClient"
|
||||
titleText="Saved client (optional)"
|
||||
label="Select a saved client to autofill"
|
||||
items={clients}
|
||||
itemToString={(item) => (item ? item.name : "")}
|
||||
onChange={({ selectedItem }) => selectedItem && applyClient(selectedItem)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={8}>
|
||||
<TextInput
|
||||
id="clientName"
|
||||
labelText="Client name"
|
||||
value={clientName}
|
||||
onChange={(e) => {
|
||||
setClientName(e.target.value);
|
||||
setClientId(null);
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<TextArea
|
||||
id="clientAddress"
|
||||
labelText="Client address"
|
||||
rows={3}
|
||||
value={clientAddress}
|
||||
onChange={(e) => setClientAddress(e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<TextInput
|
||||
id="clientGstin"
|
||||
labelText="Client GSTIN (optional)"
|
||||
value={clientGstin}
|
||||
onChange={(e) => setClientGstin(e.target.value.toUpperCase())}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<TextInput
|
||||
id="poNumber"
|
||||
labelText="PO number (optional)"
|
||||
value={poNumber}
|
||||
onChange={(e) => setPoNumber(e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={6}>
|
||||
<Select
|
||||
id="placeOfSupply"
|
||||
labelText="Place of supply"
|
||||
value={placeOfSupply}
|
||||
onChange={(e) => setPlaceOfSupply(e.target.value)}
|
||||
>
|
||||
{INDIAN_STATES.map((s) => (
|
||||
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
|
||||
))}
|
||||
</Select>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<Toggle
|
||||
id="saveClient"
|
||||
labelText="Save this client for next time"
|
||||
toggled={saveClient}
|
||||
onToggle={setSaveClient}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
|
||||
<div>
|
||||
<h3 style={{ marginBottom: "0.75rem" }}>Line items</h3>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.5rem",
|
||||
alignItems: "flex-end",
|
||||
padding: "0.75rem",
|
||||
border: "1px solid var(--cds-border-subtle-01)",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: "3 1 240px" }}>
|
||||
<TextInput
|
||||
id={`desc-${index}`}
|
||||
labelText="Particulars / description"
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(index, { description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: "0 1 150px" }}>
|
||||
<Select
|
||||
id={`mode-${index}`}
|
||||
labelText="Type"
|
||||
value={item.mode}
|
||||
onChange={(e) => updateItem(index, { mode: e.target.value as LineMode })}
|
||||
>
|
||||
<SelectItem value="fixed" text="Fixed amount" />
|
||||
<SelectItem value="rate" text="Rate × quantity" />
|
||||
</Select>
|
||||
</div>
|
||||
{item.mode === "rate" ? (
|
||||
<>
|
||||
<div style={{ flex: "0 1 130px" }}>
|
||||
<NumberInput
|
||||
id={`rate-${index}`}
|
||||
label="Rate (₹)"
|
||||
min={0}
|
||||
step={1}
|
||||
value={item.rate}
|
||||
onChange={(_e, { value }) => updateItem(index, { rate: Number(value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: "0 1 140px" }}>
|
||||
<Select
|
||||
id={`unit-${index}`}
|
||||
labelText="Unit"
|
||||
value={item.unit}
|
||||
onChange={(e) => updateItem(index, { unit: e.target.value as LineUnit })}
|
||||
>
|
||||
{Object.entries(UNIT_LABELS).map(([value, text]) => (
|
||||
<SelectItem key={value} value={value} text={text} />
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div style={{ flex: "0 1 110px" }}>
|
||||
<NumberInput
|
||||
id={`qty-${index}`}
|
||||
label="Quantity"
|
||||
min={0}
|
||||
step={1}
|
||||
value={item.quantity}
|
||||
onChange={(_e, { value }) => updateItem(index, { quantity: Number(value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ flex: "0 1 150px" }}>
|
||||
<NumberInput
|
||||
id={`amount-${index}`}
|
||||
label="Amount (₹)"
|
||||
min={0}
|
||||
step={1}
|
||||
value={item.amount}
|
||||
onChange={(_e, { value }) => updateItem(index, { amount: Number(value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: "0 0 auto", paddingBottom: "0.4rem" }}>
|
||||
<strong>₹{formatAmount(computeLineAmount(item))}</strong>
|
||||
</div>
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
hasIconOnly
|
||||
renderIcon={TrashCan}
|
||||
iconDescription="Remove line"
|
||||
onClick={() => removeItem(index)}
|
||||
disabled={items.length === 1}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button kind="tertiary" size="sm" renderIcon={Add} onClick={addItem}>
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<NumberInput
|
||||
id="discount"
|
||||
label="Discount (₹)"
|
||||
min={0}
|
||||
step={1}
|
||||
value={discount}
|
||||
onChange={(_e, { value }) => setDiscount(Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<Toggle
|
||||
id="taxes"
|
||||
labelText="Taxes applicable"
|
||||
toggled={taxesApplicable}
|
||||
onToggle={setTaxesApplicable}
|
||||
/>
|
||||
</Column>
|
||||
{taxesApplicable ? (
|
||||
<>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<RadioButtonGroup
|
||||
legendText="Tax treatment"
|
||||
name="taxType"
|
||||
valueSelected={taxType}
|
||||
onChange={(value) => setTaxType(value as TaxType)}
|
||||
>
|
||||
<RadioButton id="t-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
|
||||
<RadioButton id="t-igst" labelText="IGST (inter-state)" value="igst" />
|
||||
</RadioButtonGroup>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<NumberInput
|
||||
id="taxRate"
|
||||
label="Tax rate (%)"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
value={taxRate}
|
||||
onChange={(_e, { value }) => setTaxRate(Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
</>
|
||||
) : null}
|
||||
</Grid>
|
||||
|
||||
<Select
|
||||
id="bankAccount"
|
||||
labelText="Bank account (Pay To)"
|
||||
value={bankId ?? ""}
|
||||
onChange={(e) => setBankId(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
<SelectItem value="" text="No bank details" />
|
||||
{banks.map((b) => (
|
||||
<SelectItem
|
||||
key={b.id ?? 0}
|
||||
value={String(b.id)}
|
||||
text={`${b.bankName} · ${b.accountNo}`}
|
||||
/>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<ImagePicker
|
||||
kind="signature"
|
||||
label="Signature for this invoice (optional)"
|
||||
value={signaturePath}
|
||||
onChange={setSignaturePath}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
id="notes"
|
||||
labelText="Notes (optional)"
|
||||
rows={2}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
|
||||
<Column sm={4} md={8} lg={7}>
|
||||
<div style={{ position: "sticky", top: "3rem" }}>
|
||||
<h3 style={{ marginBottom: "0.75rem" }}>Preview</h3>
|
||||
<InvoicePreview {...previewProps} />
|
||||
</div>
|
||||
</Column>
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Column,
|
||||
Grid,
|
||||
InlineNotification,
|
||||
NumberInput,
|
||||
ProgressIndicator,
|
||||
ProgressStep,
|
||||
RadioButton,
|
||||
RadioButtonGroup,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea,
|
||||
TextInput,
|
||||
} from "@carbon/react";
|
||||
import { api } from "../lib/api";
|
||||
import type { BankAccount, Settings, TaxType } from "../lib/types";
|
||||
import { INDIAN_STATES } from "../lib/types";
|
||||
import { emailError, ifscError, panError, phoneError } from "../lib/validators";
|
||||
import { ImagePicker } from "../components/ImagePicker";
|
||||
|
||||
interface OnboardingProps {
|
||||
initial: Settings;
|
||||
onDone: (settings: Settings) => void;
|
||||
}
|
||||
|
||||
const bankers = (): BankAccount => ({
|
||||
id: null,
|
||||
label: "Primary",
|
||||
bankName: "",
|
||||
accountName: "",
|
||||
accountNo: "",
|
||||
branch: "",
|
||||
ifsc: "",
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
export default function Onboarding({ initial, onDone }: OnboardingProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [form, setForm] = useState<Settings>({ ...initial });
|
||||
const [bank, setBank] = useState<BankAccount>(bankers());
|
||||
const [prefix, setPrefix] = useState(`AP/${new Date().getFullYear()}-`);
|
||||
const [padding, setPadding] = useState(3);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const [banks, series] = await Promise.all([api.listBanks(), api.getActiveSeries()]);
|
||||
if (!active) return;
|
||||
const preferred = banks.find((b) => b.id === initial.defaultBankId) ?? banks[0];
|
||||
if (preferred) setBank(preferred);
|
||||
if (series) {
|
||||
setPrefix(series.prefix);
|
||||
setPadding(series.padding);
|
||||
}
|
||||
} catch {
|
||||
/* first run – defaults are fine */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [initial.defaultBankId]);
|
||||
|
||||
const set = <K extends keyof Settings>(key: K, value: Settings[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const bankSet = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
|
||||
setBank((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const businessInvalid = useMemo(
|
||||
() => Boolean(panError(form.vendorPan) || emailError(form.vendorEmail) || phoneError(form.vendorPhone)),
|
||||
[form.vendorPan, form.vendorEmail, form.vendorPhone],
|
||||
);
|
||||
|
||||
const finish = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const savedBank = await api.saveBank({ ...bank, isDefault: true });
|
||||
await api.startNewSeries(prefix.trim() || `AP/${new Date().getFullYear()}-`, padding);
|
||||
const next: Settings = {
|
||||
...form,
|
||||
vendorName: form.vendorName.trim() || "Vendor",
|
||||
defaultBankId: savedBank.id,
|
||||
onboarded: true,
|
||||
};
|
||||
const saved = await api.saveSettings(next);
|
||||
onDone(saved);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
label: "Business",
|
||||
content: (
|
||||
<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.
|
||||
</p>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<TextInput
|
||||
id="vendorName"
|
||||
labelText="Name on invoice"
|
||||
value={form.vendorName}
|
||||
onChange={(e) => set("vendorName", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<TextArea
|
||||
id="vendorAddress"
|
||||
labelText="Address"
|
||||
rows={3}
|
||||
value={form.vendorAddress}
|
||||
onChange={(e) => set("vendorAddress", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="vendorEmail"
|
||||
labelText="Email"
|
||||
value={form.vendorEmail}
|
||||
onChange={(e) => set("vendorEmail", e.target.value)}
|
||||
invalid={Boolean(emailError(form.vendorEmail))}
|
||||
invalidText={emailError(form.vendorEmail)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="vendorPhone"
|
||||
labelText="Phone"
|
||||
value={form.vendorPhone}
|
||||
onChange={(e) => set("vendorPhone", e.target.value)}
|
||||
invalid={Boolean(phoneError(form.vendorPhone))}
|
||||
invalidText={phoneError(form.vendorPhone)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="vendorPan"
|
||||
labelText="PAN"
|
||||
value={form.vendorPan}
|
||||
onChange={(e) => set("vendorPan", e.target.value.toUpperCase())}
|
||||
invalid={Boolean(panError(form.vendorPan))}
|
||||
invalidText={panError(form.vendorPan)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="vendorGstin"
|
||||
labelText="GSTIN (leave blank if not registered)"
|
||||
value={form.vendorGstin}
|
||||
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<Select
|
||||
id="vendorState"
|
||||
labelText="State of supply (your state)"
|
||||
value={form.vendorStateCode}
|
||||
onChange={(e) => set("vendorStateCode", e.target.value)}
|
||||
>
|
||||
{INDIAN_STATES.map((s) => (
|
||||
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
|
||||
))}
|
||||
</Select>
|
||||
</Column>
|
||||
</Grid>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Branding",
|
||||
content: (
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<ImagePicker
|
||||
kind="logo"
|
||||
label="Vendor logo (shown on the invoice header)"
|
||||
value={form.logoPath}
|
||||
onChange={(path) => set("logoPath", path)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<ImagePicker
|
||||
kind="signature"
|
||||
label="Signature (PNG or JPEG). If left empty, invoices note that no signature is required."
|
||||
value={form.signaturePath}
|
||||
onChange={(path) => set("signaturePath", path)}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Bank",
|
||||
content: (
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<p className="voiced-muted" style={{ marginBottom: "1rem" }}>
|
||||
Shown as the “Pay To” block. You can add more accounts later in Settings.
|
||||
</p>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="bankName"
|
||||
labelText="Bank name"
|
||||
value={bank.bankName}
|
||||
onChange={(e) => bankSet("bankName", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="accountName"
|
||||
labelText="Account name"
|
||||
value={bank.accountName}
|
||||
onChange={(e) => bankSet("accountName", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="accountNo"
|
||||
labelText="Account number"
|
||||
value={bank.accountNo}
|
||||
onChange={(e) => bankSet("accountNo", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="branch"
|
||||
labelText="Branch"
|
||||
value={bank.branch}
|
||||
onChange={(e) => bankSet("branch", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="ifsc"
|
||||
labelText="IFSC code"
|
||||
value={bank.ifsc}
|
||||
onChange={(e) => bankSet("ifsc", e.target.value.toUpperCase())}
|
||||
invalid={Boolean(ifscError(bank.ifsc))}
|
||||
invalidText={ifscError(bank.ifsc)}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Numbering",
|
||||
content: (
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<p className="voiced-muted" style={{ marginBottom: "1rem" }}>
|
||||
The serial keeps counting up automatically. Choose a prefix now; you can start a new series
|
||||
(resetting to 1) at any time from the Series page.
|
||||
</p>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="prefix"
|
||||
labelText="Series prefix"
|
||||
helperText={`First invoice will be ${prefix}${String(1).padStart(padding, "0")}`}
|
||||
value={prefix}
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<NumberInput
|
||||
id="padding"
|
||||
label="Serial digits"
|
||||
min={1}
|
||||
max={8}
|
||||
value={padding}
|
||||
onChange={(_e, { value }) => setPadding(Number(value) || 3)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<NumberInput
|
||||
id="taxRate"
|
||||
label="Default tax rate (%)"
|
||||
min={0}
|
||||
max={100}
|
||||
value={form.defaultTaxRate}
|
||||
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<NumberInput
|
||||
id="terms"
|
||||
label="Payment terms (days)"
|
||||
min={0}
|
||||
max={365}
|
||||
value={form.paymentTermsDays}
|
||||
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<RadioButtonGroup
|
||||
legendText="Default tax treatment"
|
||||
name="taxType"
|
||||
valueSelected={form.defaultTaxType}
|
||||
onChange={(value) => set("defaultTaxType", value as TaxType)}
|
||||
>
|
||||
<RadioButton id="tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
|
||||
<RadioButton id="tt-igst" labelText="IGST (inter-state)" value="igst" />
|
||||
<RadioButton id="tt-none" labelText="No tax" value="none" />
|
||||
</RadioButtonGroup>
|
||||
</Column>
|
||||
</Grid>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const isLast = step === steps.length - 1;
|
||||
|
||||
return (
|
||||
<div className="voiced-page" style={{ maxWidth: 880 }}>
|
||||
<h1 className="voiced-page__title">Set up Voiced</h1>
|
||||
<p className="voiced-page__subtitle">
|
||||
A one-time setup. Everything here can be changed later from Settings.
|
||||
</p>
|
||||
|
||||
<ProgressIndicator currentIndex={step} spaceEqually style={{ marginBottom: "2rem" }}>
|
||||
{steps.map((s) => (
|
||||
<ProgressStep key={s.label} label={s.label} />
|
||||
))}
|
||||
</ProgressIndicator>
|
||||
|
||||
{error ? (
|
||||
<InlineNotification kind="error" lowContrast title="Could not save" subtitle={error} />
|
||||
) : null}
|
||||
|
||||
<div style={{ marginTop: "1.5rem" }}>{steps[step].content}</div>
|
||||
|
||||
<div className="voiced-header-action" style={{ marginTop: "2rem" }}>
|
||||
<Button kind="secondary" disabled={step === 0 || saving} onClick={() => setStep((s) => s - 1)}>
|
||||
Back
|
||||
</Button>
|
||||
{isLast ? (
|
||||
<Button disabled={saving || businessInvalid} onClick={finish}>
|
||||
{saving ? "Saving…" : "Finish setup"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setStep((s) => s + 1)}>Next</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Column,
|
||||
Grid,
|
||||
InlineLoading,
|
||||
InlineNotification,
|
||||
NumberInput,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tag,
|
||||
TextInput,
|
||||
} from "@carbon/react";
|
||||
import { confirm } from "@tauri-apps/plugin-dialog";
|
||||
import { api } from "../lib/api";
|
||||
import type { InvoiceSeries } from "../lib/types";
|
||||
|
||||
export default function SeriesSettings() {
|
||||
const [active, setActive] = useState<InvoiceSeries | null>(null);
|
||||
const [all, setAll] = useState<InvoiceSeries[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [prefix, setPrefix] = useState(`AP/${new Date().getFullYear()}-`);
|
||||
const [padding, setPadding] = useState(3);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [current, list] = await Promise.all([api.getActiveSeries(), api.listSeries()]);
|
||||
setActive(current);
|
||||
setAll(list);
|
||||
if (current) {
|
||||
setPrefix(current.prefix);
|
||||
setPadding(current.padding);
|
||||
}
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const startNew = async () => {
|
||||
const ok = await confirm(
|
||||
`Start a new series from ${prefix}${String(1).padStart(padding, "0")}? ` +
|
||||
"The current series will be retired and the counter resets to 1.",
|
||||
{ title: "Start new series", kind: "warning" },
|
||||
);
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const created = await api.startNewSeries(prefix, padding);
|
||||
setNotice(`New series started. Next invoice: ${created.nextInvoiceNumber}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<InlineLoading description="Loading series" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<h1 className="voiced-page__title">Invoice series</h1>
|
||||
<p className="voiced-page__subtitle">
|
||||
The active series drives the automatic invoice numbers. Start a new series to reset the counter.
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
|
||||
) : null}
|
||||
{notice ? (
|
||||
<InlineNotification
|
||||
kind="success"
|
||||
lowContrast
|
||||
title="Done"
|
||||
subtitle={notice}
|
||||
onCloseButtonClick={() => setNotice(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Grid narrow style={{ marginBottom: "2rem" }}>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="seriesPrefix"
|
||||
labelText="New series prefix"
|
||||
helperText={`Next: ${prefix}${String(1).padStart(padding, "0")}`}
|
||||
value={prefix}
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={4}>
|
||||
<NumberInput
|
||||
id="seriesPadding"
|
||||
label="Serial digits"
|
||||
min={1}
|
||||
max={8}
|
||||
value={padding}
|
||||
onChange={(_e, { value }) => setPadding(Number(value) || 3)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={6} style={{ display: "flex", alignItems: "flex-end" }}>
|
||||
<Button onClick={startNew} disabled={busy || !prefix.trim()}>
|
||||
{busy ? "Starting…" : "Start new series"}
|
||||
</Button>
|
||||
</Column>
|
||||
</Grid>
|
||||
|
||||
<TableContainer title="Series history">
|
||||
<Table aria-label="Invoice series">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Prefix</TableHeader>
|
||||
<TableHeader>Next number</TableHeader>
|
||||
<TableHeader>Digits</TableHeader>
|
||||
<TableHeader>Status</TableHeader>
|
||||
<TableHeader>Created</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{all.map((series) => (
|
||||
<TableRow key={series.id}>
|
||||
<TableCell>{series.prefix}</TableCell>
|
||||
<TableCell>{series.nextInvoiceNumber}</TableCell>
|
||||
<TableCell>{series.padding}</TableCell>
|
||||
<TableCell>
|
||||
{series.isActive ? <Tag type="green">Active</Tag> : <Tag type="gray">Retired</Tag>}
|
||||
</TableCell>
|
||||
<TableCell>{new Date(series.createdAt).toLocaleDateString("en-GB")}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user