time-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 4857f8a940b5
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.
← All files
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
4857f8a940 · 19 hours ago
Bind agent address constants in policy
62b1e6acb2 · 20 hours ago
Use functions.openapps.xyz alias and add OpenApp contribution strip
61d972193a · 20 hours ago
Bounded source sync
fc644b4327 · 1 day ago
Bounded source sync
22f511f3db · 1 day ago
Bounded source sync
351a8d754c · 1 day ago
Bounded source sync
0da238a77e · 1 day ago