invoice-free.openapps.xyz

Source

This app’s code and revision history are public. Download a copy or explore the files below.

Public
The app’s code and revision history are public.
Owned app · rev 333971bf96d2
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone b3b80918bc912dda8c53a48d
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
← All files
src/Mine.tsx
// "Your invoices": the invoices and estimates you shared from this device, with their status, what is
// still owed, and a CSV export. The list and the private keys live only in this browser.
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { formatMoney, fromMinor, statusOf, localDay, type Kind, type Status } from "../functions/lib/invoice";
import { client, me } from "./lib/bounded";
import { navigate } from "./lib/router";
import { fmtDate } from "./format";
import { loadMine, type Mine } from "./store";

interface Live { status: string; total: number; currency: string; number: string; client: string; dueDate: string; kind?: Kind; paidAt?: number | null }

const LABEL: Record<Status, string> = { paid: "Paid", overdue: "Overdue", unpaid: "Unpaid", estimate: "Estimate" };
const CHIP: Record<Status, string> = { paid: "chip-free", overdue: "chip-overdue", unpaid: "", estimate: "chip-cobalt" };

/** "$1,200.00 + €80.00": one amount per currency. */
function sum(rows: { total: number; currency: string }[]): string {
  const by = new Map<string, number>();
  for (const r of rows) by.set(r.currency, (by.get(r.currency) ?? 0) + r.total);
  return [...by].map(([cur, total]) => formatMoney(total, cur)).join(" + ");
}

function csv(rows: (string | number)[][]): string {
  return rows.map((r) => r.map((v) => {
    const s = String(v);
    return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  }).join(",")).join("\r\n");
}

export function YourInvoices() {
  const [list, setList] = useState<Mine[]>(loadMine);
  // The live state of each invoice, which an owner may have changed from another device.
  const [live, setLive] = useState<Record<string, Live> | null>(null);

  useEffect(() => {
    const onChange = () => setList(loadMine());
    window.addEventListener("invoice:mine", onChange);
    return () => window.removeEventListener("invoice:mine", onChange);
  }, []);

  const ids = list.slice(0, 50).map((m) => m.id).join(",");
  useEffect(() => {
    if (!ids) return;
    let cancelled = false;
    (async () => {
      try {
        const [, { getMany }] = await Promise.all([me(), client()]);
        const rows = await getMany(ids.split(",").map((id) => `invoices/${id}`));
        if (cancelled) return;
        const next: Record<string, Live> = {};
        for (const r of rows) {
          if (!r.data) continue;
          try {
            const d = JSON.parse(r.data.data);
            next[r.path.split("/").pop()!] = {
              status: r.data.status, total: r.data.total, currency: r.data.currency, number: d.number, client: d.to?.name,
              dueDate: d.dueDate, kind: d.kind, paidAt: r.data.paidAt,
            };
          } catch { /* keep what this device remembered */ }
        }
        setLive(next);
      } catch {
        if (!cancelled) setLive({}); // statuses are a nicety; the list still works
      }
    })();
    return () => { cancelled = true; };
  }, [ids]);

  if (!list.length) return null;
  const today = localDay();
  const rows = list.map((saved) => {
    const l = live?.[saved.id];
    const m = { ...saved, ...l };
    return { ...m, status: l ? statusOf(l.status, m.dueDate, today, m.kind) : null };
  });
  const owed = rows.filter((r) => r.status === "unpaid" || r.status === "overdue");
  const overdue = rows.filter((r) => r.status === "overdue");

  const exportCsv = () => {
    const body = csv([
      ["Type", "Number", "Client", "Issue date", "Due date", "Status", "Paid on", "Currency", "Total", "Link"],
      ...rows.map((r) => [
        r.kind === "estimate" ? "Estimate" : "Invoice", r.number, r.client, r.issueDate, r.dueDate, r.status ? LABEL[r.status] : "",
        r.status === "paid" && r.paidAt ? localDay(new Date(r.paidAt * 1000)) : "", r.currency, fromMinor(r.total, r.currency), `${location.origin}/i/${r.id}`,
      ]),
    ]);
    const url = URL.createObjectURL(new Blob(["" + body], { type: "text/csv;charset=utf-8" }));
    const a = document.createElement("a");
    a.href = url;
    a.download = `invoices-${today}.csv`;
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 10_000);
  };

  const jumpSlot = document.getElementById("mine-jump");
  const summary = owed.length ? `${sum(owed)} unpaid${overdue.length ? `, including ${sum(overdue)} overdue` : ""}` : null;
  return (
    <section className="section-tight mine" id="your-invoices" aria-labelledby="mine-title">
      {jumpSlot && createPortal(
        <a className="mine-jump enter" href="#your-invoices">
          Your invoices ({list.length}){owed.length ? `: ${sum(owed)} unpaid` : ""}
        </a>, jumpSlot)}
      <div className="wrap">
        <div className="spread mine-head">
          <div>
            <h2 className="h2" id="mine-title">Your invoices</h2>
            <p className="muted mine-summary" aria-live="polite">
              {summary ? `${summary}.` : live && rows.every((r) => r.status) ? "Nothing is owed. Shared from this device." : "Shared from this device."}
            </p>
          </div>
          <button className="btn btn-line btn-sm" type="button" onClick={exportCsv}>Export CSV</button>
        </div>
        <ul className="mine-list">
          {rows.map((m, i) => {
            const href = `/i/${m.id}/admin#k=${m.key}`;
            return (
              <li key={m.id} className="enter" style={{ ["--i" as string]: i }}>
                <a href={href} onClick={(e) => { e.preventDefault(); navigate(href); }}>
                  <span className="mine-main">
                    <strong>{m.client || "No client"}</strong>
                    <span className="muted">{m.kind === "estimate" ? "Estimate " : ""}{m.number} · {fmtDate(m.issueDate)}</span>
                  </span>
                  <span className="mine-side">
                    <span className="mine-total">{formatMoney(m.total, m.currency)}</span>
                    {m.status
                      ? <span className={`chip ${CHIP[m.status]}`}>{LABEL[m.status]}</span>
                      : live === null ? <span className="skeleton chip-skeleton" aria-hidden="true" /> : null}
                  </span>
                </a>
              </li>
            );
          })}
        </ul>
      </div>
    </section>
  );
}

Change history

Quality pass: phone-readable invoice, estimates, email client, saved clients, CSV export, openapi.json
333971bf96 · 5 hours ago
Bounded source sync
088b53bddb · 1 day ago
Bounded source sync
3f3e9ff8f9 · 1 day ago
Bounded source sync
209bec4267 · 1 day ago
Bounded source sync
11f3d98cda · 1 day ago
Bounded source sync
722948ebc3 · 1 day ago
Bounded source sync
3e9d04b2c0 · 1 day ago