Invoice Generator

Invoice Generator - Create simple, professional invoices as PDF.

code.tsx

import React, { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { jsPDF } from "jspdf";

interface LineItem {
  description: string;
  qty: string;
  rate: string;
}

interface Invoice {
  number: string;
  date: string;
  dueDate: string;
  currency: string;
  fromName: string;
  fromAddress: string;
  fromEmail: string;
  fromTaxId: string;
  toName: string;
  toAddress: string;
  toEmail: string;
  items: LineItem[];
  taxPercent: string;
  taxNote: string;
  paymentInfo: string;
  notes: string;
}

const today = new Date().toISOString().split("T")[0];

const STORAGE_KEY = "typebulb_invoice";

const blank: Invoice = {
  number: "1",
  date: today,
  dueDate: "Due on Receipt",
  currency: "USD",
  fromName: "",
  fromAddress: "",
  fromEmail: "",
  fromTaxId: "",
  toName: "",
  toAddress: "",
  toEmail: "",
  items: [{ description: "", qty: "", rate: "" }],
  taxPercent: "0",
  taxNote: "",
  paymentInfo: "",
  notes: "",
};

const fmtMoney = (n: number) =>
  n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });

const fmtDate = (s: string) => {
  const d = new Date(s + "T00:00:00");
  if (isNaN(d.getTime())) return s;
  return d.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "2-digit" });
};

const calcSubtotal = (items: LineItem[]) =>
  items.reduce((sum, it) => sum + (parseFloat(it.qty) || 0) * (parseFloat(it.rate) || 0), 0);

function buildPDF(inv: Invoice) {
  const doc = new jsPDF();
  const pw = doc.internal.pageSize.getWidth();
  const m = 20;
  const cw = pw - 2 * m;
  let y = 25;

  const gray = () => doc.setTextColor(100);
  const black = () => doc.setTextColor(30);
  const bold = () => doc.setFont("helvetica", "bold");
  const norm = () => doc.setFont("helvetica", "normal");
  const fs = (s: number) => doc.setFontSize(s);

  // Title
  bold(); fs(22); black();
  doc.text("INVOICE", pw / 2, y, { align: "center" });
  y += 16;

  // Invoice details
  fs(9.5);
  bold(); gray();
  doc.text("Invoice Number:", m, y);
  norm(); black();
  doc.text(inv.number, m + 33, y);

  bold(); gray();
  doc.text("Invoice Date:", pw / 2 + 5, y);
  norm(); black();
  doc.text(fmtDate(inv.date), pw / 2 + 5 + 28, y);
  y += 6;

  bold(); gray();
  doc.text("Due Date:", pw / 2 + 5, y);
  norm(); black();
  doc.text(inv.dueDate, pw / 2 + 5 + 28, y);
  y += 12;

  // Divider
  doc.setDrawColor(220);
  doc.setLineWidth(0.3);
  doc.line(m, y, m + cw, y);
  y += 10;

  // From / Bill To headers
  bold(); fs(8); gray();
  doc.text("From:", m, y);
  doc.text("Bill To:", pw / 2 + 5, y);
  y += 6;

  // Names
  fs(10); bold(); black();
  doc.text(inv.fromName, m, y);
  doc.text(inv.toName, pw / 2 + 5, y);
  y += 5.5;

  // Addresses
  norm(); fs(9.5);
  const fAddr = inv.fromAddress.split("\n").filter(Boolean);
  const tAddr = inv.toAddress.split("\n").filter(Boolean);
  for (let i = 0; i < Math.max(fAddr.length, tAddr.length); i++) {
    if (fAddr[i]) doc.text(fAddr[i], m, y);
    if (tAddr[i]) doc.text(tAddr[i], pw / 2 + 5, y);
    y += 5;
  }

  if (inv.fromEmail || inv.toEmail) {
    gray();
    if (inv.fromEmail) doc.text("Email: " + inv.fromEmail, m, y);
    if (inv.toEmail) doc.text("Email: " + inv.toEmail, pw / 2 + 5, y);
    y += 5;
  }

  if (inv.fromTaxId) {
    gray();
    doc.text("Tax ID: " + inv.fromTaxId, m, y);
    y += 5;
  }

  black();
  y += 10;

  // Table
  const col = [m, m + 90, m + 118, m + 145];

  doc.setFillColor(245, 245, 245);
  doc.rect(m, y - 1, cw, 9, "F");

  bold(); fs(8.5); gray();
  doc.text("Description", col[0] + 3, y + 5.5);
  doc.text("Qty", col[1] + 3, y + 5.5);
  doc.text("Rate (" + inv.currency + ")", col[2] + 3, y + 5.5);
  doc.text("Amount (" + inv.currency + ")", col[3] + 3, y + 5.5);

  y += 9;
  doc.setDrawColor(200);
  doc.line(m, y, m + cw, y);
  y += 2;

  norm(); fs(9.5); black();
  inv.items.forEach((it) => {
    const q = parseFloat(it.qty) || 0;
    const r = parseFloat(it.rate) || 0;
    y += 6;
    doc.text(it.description, col[0] + 3, y);
    doc.text(it.qty || "0", col[1] + 3, y);
    doc.text("$" + fmtMoney(r), col[2] + 3, y);
    doc.text("$" + fmtMoney(q * r), col[3] + 3, y);
  });

  y += 4;
  doc.setDrawColor(220);
  doc.line(m, y, m + cw, y);
  y += 10;

  // Totals
  const sub = calcSubtotal(inv.items);
  const taxPct = parseFloat(inv.taxPercent) || 0;
  const tax = sub * taxPct / 100;
  const total = sub + tax;
  const tl = col[2] + 3;
  const tv = m + cw - 3;

  fs(9.5);
  bold(); gray();
  doc.text("Subtotal:", tl, y);
  norm(); black();
  doc.text(inv.currency + " $" + fmtMoney(sub), tv, y, { align: "right" });
  y += 6;

  bold(); gray();
  doc.text("Tax:", tl, y);
  norm(); black();
  doc.text(inv.currency + " $" + fmtMoney(tax), tv, y, { align: "right" });
  if (inv.taxNote) {
    y += 4;
    gray(); fs(8);
    doc.text("(" + inv.taxNote + ")", tv, y, { align: "right" });
    fs(9.5); black();
  }
  y += 7;

  doc.setDrawColor(180);
  doc.line(tl - 3, y - 2, m + cw, y - 2);

  bold(); fs(11); gray();
  doc.text("Total Due:", tl, y + 2);
  black();
  doc.text(inv.currency + " $" + fmtMoney(total), tv, y + 2, { align: "right" });
  y += 16;

  // Payment Info
  if (inv.paymentInfo.trim()) {
    fs(9); bold(); gray();
    doc.text("Payment Information:", m, y);
    y += 6;
    norm(); black(); fs(9.5);
    const lines = doc.splitTextToSize(inv.paymentInfo, cw);
    doc.text(lines, m, y);
    y += lines.length * 4.5 + 8;
  }

  // Notes
  if (inv.notes.trim()) {
    fs(9); bold(); gray();
    doc.text("Notes:", m, y);
    y += 6;
    norm(); black(); fs(9.5);
    const lines = doc.splitTextToSize(inv.notes, cw);
    doc.text(lines, m, y);
  }

  const blob = doc.output("blob");
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "invoice_" + inv.number + ".pdf";
  a.click();
  URL.revokeObjectURL(url);
}

function loadSaved(): Invoice {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return blank;
    return { ...blank, ...JSON.parse(raw) };
  } catch {
    return blank;
  }
}

const App = () => {
  const [inv, setInv] = useState<Invoice>(loadSaved);

  useEffect(() => {
    try { localStorage.setItem(STORAGE_KEY, JSON.stringify(inv)); } catch {}
  }, [inv]);

  const newInvoice = () => {
    const nextNum = String((parseInt(inv.number) || 0) + 1);
    setInv({ ...inv, number: nextNum, date: today, dueDate: "Due on Receipt", toName: "", toAddress: "", toEmail: "", items: [{ description: "", qty: "", rate: "" }], notes: "" });
  };

  const set = <K extends keyof Invoice>(k: K, v: Invoice[K]) =>
    setInv((p) => ({ ...p, [k]: v }));

  const setItem = (i: number, field: keyof LineItem, val: string) => {
    const next = [...inv.items];
    next[i] = { ...next[i], [field]: val };
    set("items", next);
  };

  const addItem = () => set("items", [...inv.items, { description: "", qty: "", rate: "" }]);

  const removeItem = (i: number) => {
    if (inv.items.length <= 1) return;
    set("items", inv.items.filter((_, idx) => idx !== i));
  };

  const sub = calcSubtotal(inv.items);
  const taxPct = parseFloat(inv.taxPercent) || 0;
  const tax = sub * taxPct / 100;
  const total = sub + tax;

  const ua = navigator.userAgent;
  const isWebView = (/iPhone|iPad|iPod/i.test(ua) && !ua.includes("Safari")) || /; wv\)/.test(ua);

  if (isWebView) return (
    <div className="container">
      <div className="in-app-banner">
        Your in-app browser can't download files. Please open this page directly in your browser.
        <button className="copy-link-btn" onClick={async () => tb.copy(await tb.url())}>
          Copy Link
        </button>
      </div>
    </div>
  );

  return (
    <div className="container">
      <header className="main-header">
        <h1>Invoice Generator</h1>
        <p className="subtitle">Create simple, professional invoices. Runs entirely in your browser.</p>
      </header>

      <div className="form-card">
        <div className="row-4">
          <div className="field">
            <label>Invoice #</label>
            <input value={inv.number} onChange={(e) => set("number", e.target.value)} />
          </div>
          <div className="field">
            <label>Date</label>
            <input type="date" value={inv.date} onChange={(e) => set("date", e.target.value)} />
          </div>
          <div className="field">
            <label>Due Date</label>
            <input value={inv.dueDate} onChange={(e) => set("dueDate", e.target.value)} />
          </div>
          <div className="field">
            <label>Currency</label>
            <input value={inv.currency} onChange={(e) => set("currency", e.target.value)} />
          </div>
        </div>
      </div>

      <div className="form-row-2">
        <div className="form-card">
          <h3>From</h3>
          <div className="field">
            <label>Name</label>
            <input value={inv.fromName} onChange={(e) => set("fromName", e.target.value)} placeholder="Your name" />
          </div>
          <div className="field">
            <label>Address</label>
            <textarea rows={3} value={inv.fromAddress} onChange={(e) => set("fromAddress", e.target.value)} placeholder="Street address&#10;City, State, ZIP" />
          </div>
          <div className="field">
            <label>Email</label>
            <input type="email" value={inv.fromEmail} onChange={(e) => set("fromEmail", e.target.value)} placeholder="[email protected]" />
          </div>
          <div className="field">
            <label>Tax ID</label>
            <input value={inv.fromTaxId} onChange={(e) => set("fromTaxId", e.target.value)} placeholder="Optional" />
          </div>
        </div>

        <div className="form-card">
          <h3>Bill To</h3>
          <div className="field">
            <label>Name</label>
            <input value={inv.toName} onChange={(e) => set("toName", e.target.value)} placeholder="Client name" />
          </div>
          <div className="field">
            <label>Address</label>
            <textarea rows={3} value={inv.toAddress} onChange={(e) => set("toAddress", e.target.value)} placeholder="Street address&#10;City, State, ZIP" />
          </div>
          <div className="field">
            <label>Email</label>
            <input type="email" value={inv.toEmail} onChange={(e) => set("toEmail", e.target.value)} placeholder="[email protected]" />
          </div>
        </div>
      </div>

      <div className="form-card">
        <h3>Line Items</h3>
        <table className="items-table">
          <thead>
            <tr>
              <th className="col-desc">Description</th>
              <th className="col-qty">Qty</th>
              <th className="col-rate">Rate ({inv.currency})</th>
              <th className="col-amount">Amount</th>
              <th className="col-action"></th>
            </tr>
          </thead>
          <tbody>
            {inv.items.map((item, i) => {
              const amt = (parseFloat(item.qty) || 0) * (parseFloat(item.rate) || 0);
              return (
                <tr key={i}>
                  <td><input value={item.description} onChange={(e) => setItem(i, "description", e.target.value)} placeholder="Service description" /></td>
                  <td><input type="number" value={item.qty} onChange={(e) => setItem(i, "qty", e.target.value)} placeholder="0" /></td>
                  <td><input type="number" value={item.rate} onChange={(e) => setItem(i, "rate", e.target.value)} placeholder="0" step="any" /></td>
                  <td className="amount-cell">${fmtMoney(amt)}</td>
                  <td><button className="remove-btn" onClick={() => removeItem(i)} disabled={inv.items.length <= 1}>×</button></td>
                </tr>
              );
            })}
          </tbody>
        </table>
        <button className="add-btn" onClick={addItem}>+ Add Line Item</button>
      </div>

      <div className="form-card">
        <div className="row-tax">
          <div className="field">
            <label>Tax %</label>
            <input type="number" value={inv.taxPercent} onChange={(e) => set("taxPercent", e.target.value)} step="any" />
          </div>
          <div className="field tax-note-field">
            <label>Tax Note</label>
            <input value={inv.taxNote} onChange={(e) => set("taxNote", e.target.value)} placeholder="e.g. not applicable — non-resident supplier" />
          </div>
        </div>
      </div>

      <div className="form-row-2">
        <div className="form-card">
          <h3>Payment Information</h3>
          <textarea rows={4} value={inv.paymentInfo} onChange={(e) => set("paymentInfo", e.target.value)} placeholder="Bank name, BSB, account number, etc." />
        </div>
        <div className="form-card">
          <h3>Notes</h3>
          <textarea rows={4} value={inv.notes} onChange={(e) => set("notes", e.target.value)} placeholder="Additional notes..." />
        </div>
      </div>

      <div className="totals-card">
        <div className="total-row">
          <span>Subtotal</span>
          <span>{inv.currency} ${fmtMoney(sub)}</span>
        </div>
        <div className="total-row">
          <span>Tax ({inv.taxPercent}%)</span>
          <span>{inv.currency} ${fmtMoney(tax)}</span>
        </div>
        <div className="total-row total-due">
          <span>Total Due</span>
          <span>{inv.currency} ${fmtMoney(total)}</span>
        </div>
      </div>

      <div className="action-row">
        <button className="action-btn" onClick={newInvoice}>New Invoice</button>
        <button className="action-btn" onClick={() => buildPDF(inv)}>Download PDF</button>
      </div>
    </div>
  );
};

const container = document.getElementById("root");
const root = createRoot(container!);
root.render(<App />);

Markdown source · More bulbs by samples · Typebulb home