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.
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 703ed60f2cc5ad3d98a5bf4a
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.
src/Invoice.tsx
// "Create an invoice": billable time for one client becomes an invoice in Invoice, the free OpenApps
// invoice app, through its public API. Only the lines, your name and the client's name are sent.
import { useMemo, useState } from "react";
import { invoiceLines, formatMoney, rateOf, addDays, type Row, type InvoiceGrouping } from "../functions/lib/time";
import { INVOICE_API, INVOICE_SITE } from "../functions/lib/config";
import { useStore, updateWorkspace, projectOf, clientOf, today } from "./lib/data";
import { explain } from "./lib/bounded";
import { Modal, copy } from "./ui";
interface Made { url: string; adminUrl: string; number: string; total: string }
export function InvoiceModal({ rows, from, to, client, onClose }: { rows: Row[]; from: string; to: string; client: string | null; onClose: () => void }) {
const s = useStore();
const billable = useMemo(() => rows.filter((r) => r.billable && rateOf(projectOf(s, r.project), s.ws.rate) > 0), [rows, s]);
const clientIds = useMemo(() => [...new Set(billable.map((r) => projectOf(s, r.project)?.client || ""))], [billable, s]);
const [billTo, setBillTo] = useState(() => client ?? clientIds[0] ?? "");
const [toName, setToName] = useState("");
const [fromName, setFromName] = useState(s.ws.name);
const [grouping, setGrouping] = useState<InvoiceGrouping>("project");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [made, setMade] = useState<Made | null>(null);
const forClient = billable.filter((r) => (projectOf(s, r.project)?.client || "") === billTo);
const lines = invoiceLines(forClient, s.projects, s.ws.rate, s.ws.currency, grouping, s.tz);
const total = lines.reduce((a, l) => a + l.amount, 0);
const clientName = billTo ? clientOf(s, billTo)?.name ?? "" : toName.trim();
const skipped = rows.length - billable.length;
const create = async () => {
setError("");
if (!fromName.trim()) return setError("Add your name or business name. It goes at the top of the invoice.");
if (!clientName) return setError("Add the client's name.");
if (!lines.length) return setError("There is no billable time for this client.");
if (lines.length > 200) return setError("An invoice holds up to 200 lines. Pick one line per project, or a shorter range.");
setBusy(true);
// Opened now, while the click still counts, so the browser does not block it as a pop-up.
const win = window.open("", "_blank");
try {
if (fromName.trim() !== s.ws.name) await updateWorkspace(s, { name: fromName.trim() }).catch(() => null);
const issue = today(s);
const res = await fetch(`${INVOICE_API}/invoices`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
from: { name: fromName.trim() },
to: { name: clientName },
items: lines.map((l) => ({ description: l.description, quantity: l.quantity, unitPrice: l.unitPrice })),
currency: s.ws.currency,
issueDate: issue,
dueDate: addDays(issue, 14),
notes: from === to ? `Time on ${from}.` : `Time from ${from} to ${to}.`,
}),
});
const out = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(out?.error || `Invoice answered ${res.status}. Try again.`);
const m: Made = { url: out.url, adminUrl: out.adminUrl, number: out.number, total: out.totalDisplay };
// Invoice is a separate app: only follow plain web links from it, never a script URL on Time's own page.
if (![m.url, m.adminUrl].every((u) => /^https:\/\//.test(String(u)))) throw new Error("Invoice answered with a link Time cannot open. Try again.");
setMade(m);
if (win) win.location.href = m.adminUrl;
} catch (err) {
win?.close();
setError(explain(err));
}
setBusy(false);
};
if (made) {
return (
<Modal title="Invoice created" onClose={onClose} footer={<><span className="grow" /><button type="button" className="btn" onClick={onClose}>Done</button></>}>
<div className="stack">
<p className="notice notice-ok">Invoice {made.number} for {made.total} is ready in Invoice. It opened in a new tab, where you can add your logo, address and payment details, then download the PDF.</p>
<div className="stack" style={{ ["--gap" as string]: "8px" }}>
<a className="btn" href={made.adminUrl} target="_blank" rel="noopener">Open the invoice</a>
<button type="button" className="btn btn-line" onClick={() => void copy(made.url, "Client link copied")}>Copy the link for your client</button>
</div>
<p className="hint">Keep the invoice's own tab or its admin link: it is how you edit it or mark it paid later. Time does not keep a copy.</p>
</div>
</Modal>
);
}
return (
<Modal title="Create an invoice" wide onClose={onClose} footer={
<>
<span className="grow" />
<button type="button" className="btn btn-line" onClick={onClose}>Cancel</button>
<button type="button" className="btn" onClick={create} disabled={busy || !lines.length}>{busy ? "Creating..." : "Create the invoice"}</button>
</>
}>
{!billable.length ? (
<div className="stack">
<p className="notice">There is no billable time with an hourly rate in this range. Mark entries as billable, and set a rate in Settings or on the project.</p>
</div>
) : (
<div className="stack">
<div className="two">
<label className="field"><span>From (you)</span><input className="input" value={fromName} maxLength={80} placeholder="Your name or business" onChange={(e) => setFromName(e.target.value)} /></label>
<label className="field"><span>Bill to</span>
<select className="select" value={billTo} onChange={(e) => setBillTo(e.target.value)}>
{clientIds.map((id) => <option key={id || "none"} value={id}>{id ? clientOf(s, id)?.name ?? "Client" : "Projects with no client"}</option>)}
</select>
</label>
</div>
{!billTo && <label className="field"><span>Client's name</span><input className="input" value={toName} maxLength={100} placeholder="Acme Inc." onChange={(e) => setToName(e.target.value)} /></label>}
<div className="segmented two-seg" role="radiogroup" aria-label="Lines">
<button type="button" role="radio" aria-checked={grouping === "project"} onClick={() => setGrouping("project")}>One line per project</button>
<button type="button" role="radio" aria-checked={grouping === "entry"} onClick={() => setGrouping("entry")}>One line per entry</button>
</div>
<div className="table-wrap">
<table className="table invoice-lines">
<thead><tr><th scope="col">Line</th><th scope="col" className="num">Hours</th><th scope="col" className="num hide-sm">Rate</th><th scope="col" className="num">Amount</th></tr></thead>
<tbody>
{lines.slice(0, 50).map((l, i) => (
<tr key={i}><td className="clip">{l.description}</td><td className="num">{l.quantity}</td><td className="num hide-sm">{l.unitPrice}</td><td className="num">{formatMoney(l.amount, s.ws.currency)}</td></tr>
))}
</tbody>
<tfoot><tr><th scope="row" colSpan={2}>Total before tax{lines.length > 50 ? ` (${lines.length} lines)` : ""}</th><td className="hide-sm" /><td className="num strong">{formatMoney(total, s.ws.currency)}</td></tr></tfoot>
</table>
</div>
<p className="hint">Hours are rounded to two decimals. {skipped > 0 ? `${skipped} ${skipped === 1 ? "entry is" : "entries are"} left out: not billable, or no rate. ` : ""}Tax, discount, logo and payment details are added in Invoice.</p>
<p className="notice small">
<strong>What is shared:</strong> Time sends these lines, your name and the client's name to <a href={INVOICE_SITE} target="_blank" rel="noopener">Invoice</a>, another free OpenApps app, which stores the invoice so you and your client can open it by link. Nothing else leaves Time.
</p>
{error && <p className="notice notice-error" role="alert">{error}</p>}
</div>
)}
</Modal>
);
}
Change history
Point OpenApps links to openapps.free
Bind agent address constants in policy
Use functions.openapps.xyz alias and add OpenApp contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync