Initial commit: Voiced invoice desktop app
Offline-first invoicing app for Test Vendor, 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user