@pylonsync/create-pylon 0.3.275 → 0.3.276

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/bin/create-pylon.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/agency/app/dashboard/dashboard-client.tsx +1213 -59
  4. package/templates/agency/app/layout.tsx +1 -1
  5. package/templates/agency/app/page.tsx +72 -30
  6. package/templates/agency/app/seeder.tsx +26 -0
  7. package/templates/agency/app/work/[slug]/page.tsx +182 -0
  8. package/templates/agency/app/work/page.tsx +83 -0
  9. package/templates/agency/app.ts +168 -19
  10. package/templates/agency/components/marketing.tsx +39 -0
  11. package/templates/agency/functions/clientsForOwner.ts +27 -0
  12. package/templates/agency/functions/deleteClient.ts +27 -0
  13. package/templates/agency/functions/deleteInvoice.ts +19 -0
  14. package/templates/agency/functions/deleteProject.ts +20 -0
  15. package/templates/agency/functions/invoicesForOwner.ts +27 -0
  16. package/templates/agency/functions/seedProjects.ts +41 -0
  17. package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
  18. package/templates/agency/functions/setInvoiceStatus.ts +27 -0
  19. package/templates/agency/functions/setProjectFlags.ts +35 -0
  20. package/templates/agency/functions/upsertClient.ts +73 -0
  21. package/templates/agency/functions/upsertInvoice.ts +113 -0
  22. package/templates/agency/functions/upsertProject.ts +97 -0
  23. package/templates/agency/lib/agency.ts +165 -3
  24. package/templates/agency/lib/invoice-pdf.tsx +174 -0
  25. package/templates/agency/lib/site.config.ts +180 -1
  26. package/templates/agency/package.json +2 -1
  27. package/templates/ai-chat/app/chat-client.tsx +354 -41
  28. package/templates/ai-chat/functions/deleteConversation.ts +33 -0
  29. package/templates/ai-studio/app/studio-client.tsx +172 -29
  30. package/templates/ai-studio/app.ts +7 -7
  31. package/templates/ai-studio/lib/studio.ts +5 -5
@@ -1,9 +1,105 @@
1
1
  "use client";
2
2
 
3
- import React, { useEffect, useState } from "react";
3
+ import React, { useEffect, useMemo, useState } from "react";
4
4
  import { db, callFn } from "@pylonsync/react";
5
5
  import { useAuth } from "@pylonsync/client";
6
- import type { InquiryRow, OwnerInquiriesResult } from "@/lib/agency";
6
+ import {
7
+ money,
8
+ parseLineItems,
9
+ lineItemsTotal,
10
+ type InquiryRow,
11
+ type OwnerInquiriesResult,
12
+ type ProjectRow,
13
+ type ClientRow,
14
+ type InvoiceRow,
15
+ type InvoiceLineItem,
16
+ type OwnerClientsResult,
17
+ type OwnerInvoicesResult,
18
+ } from "@/lib/agency";
19
+ import { siteConfig } from "@/lib/site.config";
20
+
21
+ // The studio back-office. One owner-gated dashboard with four tabs:
22
+ // • Pipeline — live inquiries + the public "slots open" capacity counter.
23
+ // • Work — the portfolio. Projects are public-read, so this reads them
24
+ // with a LIVE `db.useQuery("Project")` (drafts included) and
25
+ // toggling `selected` re-curates the homepage in real time.
26
+ // • Clients — the CRM. PII, so it loads through the owner-gated
27
+ // `clientsForOwner` and never travels over entity sync.
28
+ // • Invoices — billing. Money + client data, owner-gated like Clients.
29
+ //
30
+ // The whole dashboard is gated to PYLON_OWNER_EMAIL: one owner probe up front
31
+ // (inquiriesForOwner) decides access; a non-owner sees the owner-only card.
32
+ type Tab = "pipeline" | "work" | "clients" | "invoices";
33
+
34
+ export function AgencyDashboard({ userEmail }: { userEmail: string }) {
35
+ const [tab, setTab] = useState<Tab>("pipeline");
36
+ const [authorized, setAuthorized] = useState<boolean | null>(null);
37
+ const [error, setError] = useState<string | null>(null);
38
+
39
+ useEffect(() => {
40
+ let cancelled = false;
41
+ (async () => {
42
+ try {
43
+ const r = await callFn<OwnerInquiriesResult>("inquiriesForOwner", {});
44
+ if (cancelled) return;
45
+ if (!r.authorized) {
46
+ setAuthorized(false);
47
+ return;
48
+ }
49
+ setAuthorized(true);
50
+ // Seed demo clients + invoices once, for the owner only (idempotent).
51
+ void callFn("seedStudioBackoffice", {}).catch(() => {});
52
+ } catch (e) {
53
+ if (!cancelled) setError(e instanceof Error ? e.message : String(e));
54
+ }
55
+ })();
56
+ return () => {
57
+ cancelled = true;
58
+ };
59
+ }, []);
60
+
61
+ if (error) {
62
+ return <div className="rounded-xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700">{error}</div>;
63
+ }
64
+ if (authorized === null) return <Skeleton />;
65
+ if (!authorized) return <OwnerOnly email={userEmail} />;
66
+
67
+ const tabs: { id: Tab; label: string }[] = [
68
+ { id: "pipeline", label: "Pipeline" },
69
+ { id: "work", label: "Work" },
70
+ { id: "clients", label: "Clients" },
71
+ { id: "invoices", label: "Invoices" },
72
+ ];
73
+
74
+ return (
75
+ <div className="space-y-6">
76
+ <nav className="flex gap-1 border-b border-zinc-200">
77
+ {tabs.map((t) => (
78
+ <button
79
+ key={t.id}
80
+ type="button"
81
+ onClick={() => setTab(t.id)}
82
+ className={
83
+ "-mb-px border-b-2 px-3.5 py-2.5 text-[13.5px] font-medium transition-colors " +
84
+ (tab === t.id
85
+ ? "border-brand text-zinc-900"
86
+ : "border-transparent text-zinc-500 hover:text-zinc-800")
87
+ }
88
+ >
89
+ {t.label}
90
+ </button>
91
+ ))}
92
+ </nav>
93
+
94
+ {tab === "pipeline" ? <PipelinePanel /> : null}
95
+ {tab === "work" ? <WorkPanel /> : null}
96
+ {tab === "clients" ? <ClientsPanel /> : null}
97
+ {tab === "invoices" ? <InvoicesPanel /> : null}
98
+ </div>
99
+ );
100
+ }
101
+
102
+ /* =============================== PIPELINE =============================== */
7
103
 
8
104
  interface CapacityRow {
9
105
  id: string;
@@ -12,34 +108,21 @@ interface CapacityRow {
12
108
  updatedAt: string;
13
109
  }
14
110
 
15
- // The owner's live pipeline. Liveness rides the SAME public Capacity row the
16
- // landing page uses: `db.useQuery("Capacity")` re-renders the instant slots
17
- // change (cross-tab, via the replica). The leads themselves never sync — they
18
- // come from the owner-gated `inquiriesForOwner`, (re)fetched on mount and
19
- // whenever capacity changes (which is exactly when a lead is booked/released).
20
- // So the pipeline stays live, but PII only ever travels through the gated call.
21
- export function AgencyDashboard({ userEmail }: { userEmail: string }) {
111
+ // Liveness rides the public Capacity row: `db.useQuery("Capacity")` re-renders
112
+ // the instant slots change (cross-tab). Leads come from the owner-gated
113
+ // inquiriesForOwner, refetched whenever capacity changes (which is exactly when
114
+ // a lead is booked/released), so the pipeline stays live without PII syncing.
115
+ function PipelinePanel() {
22
116
  const { data: caps } = db.useQuery<CapacityRow>("Capacity");
23
117
  const cap = caps[0];
24
118
  const liveKey = `${cap?.openSlots ?? "?"}:${cap?.label ?? ""}`;
25
119
 
26
120
  const [inquiries, setInquiries] = useState<InquiryRow[] | null>(null);
27
- const [denied, setDenied] = useState(false);
28
- const [error, setError] = useState<string | null>(null);
29
121
  const [busyId, setBusyId] = useState<string | null>(null);
30
122
 
31
123
  async function load() {
32
- try {
33
- const r = await callFn<OwnerInquiriesResult>("inquiriesForOwner", {});
34
- if (!r.authorized) setDenied(true);
35
- else {
36
- setInquiries(r.inquiries);
37
- setDenied(false);
38
- setError(null);
39
- }
40
- } catch (e) {
41
- setError(e instanceof Error ? e.message : String(e));
42
- }
124
+ const r = await callFn<OwnerInquiriesResult>("inquiriesForOwner", {});
125
+ if (r.authorized) setInquiries(r.inquiries);
43
126
  }
44
127
 
45
128
  useEffect(() => {
@@ -57,25 +140,15 @@ export function AgencyDashboard({ userEmail }: { userEmail: string }) {
57
140
  }
58
141
  }
59
142
 
60
- if (denied) return <OwnerOnly email={userEmail} />;
61
- if (error) {
62
- return <div className="rounded-xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700">{error}</div>;
63
- }
64
- if (!inquiries) return <Skeleton />;
143
+ if (!inquiries) return <Skeleton compact />;
65
144
 
66
145
  const newCount = inquiries.filter((i) => i.status === "new").length;
67
146
  const bookedCount = inquiries.filter((i) => i.status === "booked").length;
68
147
  const active = inquiries.filter((i) => i.status !== "declined");
69
148
 
70
149
  return (
71
- <div className="space-y-8">
72
- <div>
73
- <h1 className="text-xl font-semibold tracking-tight">Pipeline</h1>
74
- <p className="mt-1 text-sm text-zinc-500">
75
- Live — leads land here the moment they&apos;re sent. Booking one drops the open-slot count
76
- on your site instantly.
77
- </p>
78
- </div>
150
+ <div className="space-y-6">
151
+ <PanelHead title="Pipeline" sub="Live — leads land here the moment they're sent. Booking one drops the open-slot count on your site instantly." />
79
152
 
80
153
  <div className="grid gap-4 sm:grid-cols-3">
81
154
  <Stat label="Open slots" value={String(cap?.openSlots ?? 0)} hint={cap?.label} />
@@ -85,13 +158,9 @@ export function AgencyDashboard({ userEmail }: { userEmail: string }) {
85
158
 
86
159
  <CapacityCard cap={cap} />
87
160
 
88
- {/* Inquiries */}
89
- <div className="rounded-xl border border-zinc-200 bg-white">
90
- <div className="border-b border-zinc-100 px-4 py-3 text-sm font-semibold text-zinc-900">
91
- Inquiries <span className="font-normal text-zinc-400">({active.length})</span>
92
- </div>
161
+ <Card title="Inquiries" count={active.length}>
93
162
  {inquiries.length === 0 ? (
94
- <p className="p-8 text-center text-sm text-zinc-500">No inquiries yet — share your site.</p>
163
+ <Empty>No inquiries yet — share your site.</Empty>
95
164
  ) : (
96
165
  <ul className="divide-y divide-zinc-100">
97
166
  {inquiries.map((i) => (
@@ -100,7 +169,7 @@ export function AgencyDashboard({ userEmail }: { userEmail: string }) {
100
169
  <div className="min-w-0 flex-1">
101
170
  <div className="flex flex-wrap items-center gap-2">
102
171
  <span className="text-[14px] font-medium text-zinc-900">{i.name}</span>
103
- <StatusBadge status={i.status} />
172
+ <Pill kind="inquiry" status={i.status} />
104
173
  </div>
105
174
  <div className="mt-0.5 truncate text-[12.5px] text-zinc-500">
106
175
  {i.email}
@@ -139,20 +208,17 @@ export function AgencyDashboard({ userEmail }: { userEmail: string }) {
139
208
  ))}
140
209
  </ul>
141
210
  )}
142
- </div>
211
+ </Card>
143
212
  </div>
144
213
  );
145
214
  }
146
215
 
147
- // Editable capacity — the number the public hero shows live. Saving calls
148
- // setCapacity; the change syncs straight to every open landing page.
149
216
  function CapacityCard({ cap }: { cap?: CapacityRow }) {
150
217
  const [label, setLabel] = useState(cap?.label ?? "");
151
218
  const [slots, setSlots] = useState(String(cap?.openSlots ?? 0));
152
219
  const [saving, setSaving] = useState(false);
153
220
  const [saved, setSaved] = useState(false);
154
221
 
155
- // Keep inputs in sync if the live row changes underneath us (e.g. a booking).
156
222
  useEffect(() => {
157
223
  setLabel(cap?.label ?? "");
158
224
  setSlots(String(cap?.openSlots ?? 0));
@@ -206,6 +272,965 @@ function CapacityCard({ cap }: { cap?: CapacityRow }) {
206
272
  );
207
273
  }
208
274
 
275
+ /* ================================= WORK ================================ */
276
+
277
+ // Projects are public-read, so this is a LIVE query — toggling `selected` here
278
+ // updates the homepage's "Selected work" on its next render, and any other open
279
+ // dashboard tab instantly. Writes go through the owner-gated functions.
280
+ function WorkPanel() {
281
+ const { data: projects } = db.useQuery<ProjectRow>("Project");
282
+ const [editing, setEditing] = useState<ProjectRow | "new" | null>(null);
283
+ const [busyId, setBusyId] = useState<string | null>(null);
284
+
285
+ const ordered = useMemo(
286
+ () => [...projects].sort((a, b) => a.order - b.order || (a.createdAt < b.createdAt ? -1 : 1)),
287
+ [projects],
288
+ );
289
+ const selectedCount = projects.filter((p) => p.selected && p.published).length;
290
+
291
+ async function flag(id: string, patch: { selected?: boolean; published?: boolean }) {
292
+ setBusyId(id);
293
+ try {
294
+ await callFn("setProjectFlags", { id, ...patch });
295
+ } finally {
296
+ setBusyId(null);
297
+ }
298
+ }
299
+ async function remove(p: ProjectRow) {
300
+ if (!confirm(`Delete “${p.title}”? This can't be undone.`)) return;
301
+ setBusyId(p.id);
302
+ try {
303
+ await callFn("deleteProject", { id: p.id });
304
+ } finally {
305
+ setBusyId(null);
306
+ }
307
+ }
308
+
309
+ return (
310
+ <div className="space-y-6">
311
+ <PanelHead
312
+ title="Work"
313
+ sub={`Your portfolio. ${selectedCount} featured on the homepage. Toggle the star to feature, the eye to publish.`}
314
+ action={<NewButton onClick={() => setEditing("new")}>New project</NewButton>}
315
+ />
316
+
317
+ <Card title="Projects" count={projects.length}>
318
+ {ordered.length === 0 ? (
319
+ <Empty>No projects yet — add your first case study.</Empty>
320
+ ) : (
321
+ <ul className="divide-y divide-zinc-100">
322
+ {ordered.map((p) => (
323
+ <li key={p.id} className="flex items-center gap-3 px-4 py-3">
324
+ <div className="min-w-0 flex-1">
325
+ <div className="flex flex-wrap items-center gap-2">
326
+ <span className="truncate text-[14px] font-medium text-zinc-900">{p.title}</span>
327
+ {!p.published ? (
328
+ <span className="rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-medium text-zinc-500">draft</span>
329
+ ) : null}
330
+ {p.selected && p.published ? (
331
+ <span className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">featured</span>
332
+ ) : null}
333
+ </div>
334
+ <div className="mt-0.5 truncate text-[12.5px] text-zinc-500">
335
+ {p.client}
336
+ {p.summary ? ` · ${p.summary}` : ""}
337
+ </div>
338
+ </div>
339
+ <div className="flex shrink-0 items-center gap-1">
340
+ <IconToggle
341
+ on={p.selected}
342
+ disabled={busyId === p.id}
343
+ title={p.selected ? "Featured on homepage" : "Feature on homepage"}
344
+ onClick={() => flag(p.id, { selected: !p.selected })}
345
+ >
346
+ {p.selected ? "★" : "☆"}
347
+ </IconToggle>
348
+ <IconToggle
349
+ on={p.published}
350
+ disabled={busyId === p.id}
351
+ title={p.published ? "Published" : "Draft (hidden)"}
352
+ onClick={() => flag(p.id, { published: !p.published })}
353
+ >
354
+ {p.published ? "👁" : "🚫"}
355
+ </IconToggle>
356
+ <a
357
+ href={`/work/${p.slug}`}
358
+ target="_blank"
359
+ rel="noreferrer"
360
+ className="rounded-md px-2 py-1 text-[12px] text-zinc-500 hover:bg-zinc-100 hover:text-zinc-800"
361
+ title="View case study"
362
+ >
363
+
364
+ </a>
365
+ <button
366
+ type="button"
367
+ onClick={() => setEditing(p)}
368
+ className="rounded-md px-2.5 py-1 text-[12.5px] font-medium text-zinc-600 hover:bg-zinc-100"
369
+ >
370
+ Edit
371
+ </button>
372
+ <button
373
+ type="button"
374
+ disabled={busyId === p.id}
375
+ onClick={() => remove(p)}
376
+ className="rounded-md px-2 py-1 text-[12.5px] text-zinc-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
377
+ title="Delete"
378
+ >
379
+
380
+ </button>
381
+ </div>
382
+ </li>
383
+ ))}
384
+ </ul>
385
+ )}
386
+ </Card>
387
+
388
+ {editing ? (
389
+ <ProjectForm
390
+ initial={editing === "new" ? null : editing}
391
+ nextOrder={projects.length}
392
+ onClose={() => setEditing(null)}
393
+ />
394
+ ) : null}
395
+ </div>
396
+ );
397
+ }
398
+
399
+ function ProjectForm({
400
+ initial,
401
+ nextOrder,
402
+ onClose,
403
+ }: {
404
+ initial: ProjectRow | null;
405
+ nextOrder: number;
406
+ onClose: () => void;
407
+ }) {
408
+ const [f, setF] = useState({
409
+ title: initial?.title ?? "",
410
+ client: initial?.client ?? "",
411
+ summary: initial?.summary ?? "",
412
+ year: initial?.year ?? "",
413
+ tags: initial?.tags ?? "",
414
+ selected: initial?.selected ?? false,
415
+ published: initial?.published ?? true,
416
+ order: initial?.order ?? nextOrder,
417
+ challenge: initial?.challenge ?? "",
418
+ approach: initial?.approach ?? "",
419
+ outcome: initial?.outcome ?? "",
420
+ liveUrl: initial?.liveUrl ?? "",
421
+ });
422
+ const [saving, setSaving] = useState(false);
423
+ const [err, setErr] = useState<string | null>(null);
424
+ const set = <K extends keyof typeof f>(k: K, v: (typeof f)[K]) => setF((s) => ({ ...s, [k]: v }) as typeof f);
425
+
426
+ async function save() {
427
+ if (!f.title.trim()) { setErr("A title is required."); return; }
428
+ setSaving(true);
429
+ setErr(null);
430
+ try {
431
+ await callFn("upsertProject", {
432
+ id: initial?.id,
433
+ title: f.title.trim(),
434
+ client: f.client.trim() || undefined,
435
+ summary: f.summary.trim() || undefined,
436
+ year: f.year.trim() || undefined,
437
+ tags: f.tags.trim() || undefined,
438
+ selected: f.selected,
439
+ published: f.published,
440
+ order: Number(f.order) || 0,
441
+ challenge: f.challenge.trim() || undefined,
442
+ approach: f.approach.trim() || undefined,
443
+ outcome: f.outcome.trim() || undefined,
444
+ liveUrl: f.liveUrl.trim() || undefined,
445
+ });
446
+ onClose();
447
+ } catch (e) {
448
+ setErr(e instanceof Error ? e.message : "Couldn't save — try again.");
449
+ setSaving(false);
450
+ }
451
+ }
452
+
453
+ return (
454
+ <Modal title={initial ? "Edit project" : "New project"} onClose={onClose} onSubmit={save} saving={saving} error={err}>
455
+ <Field label="Title" required>
456
+ <input value={f.title} onChange={(e) => set("title", e.target.value)} className={inputCls} autoFocus />
457
+ </Field>
458
+ <div className="grid gap-3 sm:grid-cols-2">
459
+ <Field label="Client label" hint="e.g. Fintech · 0→1">
460
+ <input value={f.client} onChange={(e) => set("client", e.target.value)} className={inputCls} />
461
+ </Field>
462
+ <Field label="Year">
463
+ <input value={f.year} onChange={(e) => set("year", e.target.value)} placeholder="2026" className={inputCls} />
464
+ </Field>
465
+ </div>
466
+ <Field label="Summary" hint="One line, shown on the card">
467
+ <input value={f.summary} onChange={(e) => set("summary", e.target.value)} className={inputCls} />
468
+ </Field>
469
+ <Field label="Tags" hint="Comma-separated">
470
+ <input value={f.tags} onChange={(e) => set("tags", e.target.value)} placeholder="Product design, iOS" className={inputCls} />
471
+ </Field>
472
+ <Field label="The challenge">
473
+ <textarea value={f.challenge} onChange={(e) => set("challenge", e.target.value)} rows={2} className={inputCls + " resize-none py-2"} />
474
+ </Field>
475
+ <Field label="Our approach">
476
+ <textarea value={f.approach} onChange={(e) => set("approach", e.target.value)} rows={2} className={inputCls + " resize-none py-2"} />
477
+ </Field>
478
+ <Field label="The outcome">
479
+ <textarea value={f.outcome} onChange={(e) => set("outcome", e.target.value)} rows={2} className={inputCls + " resize-none py-2"} />
480
+ </Field>
481
+ <div className="grid gap-3 sm:grid-cols-2">
482
+ <Field label="Live URL">
483
+ <input value={f.liveUrl} onChange={(e) => set("liveUrl", e.target.value)} placeholder="https://…" className={inputCls} />
484
+ </Field>
485
+ <Field label="Order" hint="Lower shows first">
486
+ <input type="number" value={f.order} onChange={(e) => set("order", Number(e.target.value))} className={inputCls + " tabular-nums"} />
487
+ </Field>
488
+ </div>
489
+ <div className="flex flex-wrap gap-5 pt-1">
490
+ <Switch label="Feature on homepage" checked={f.selected} onChange={(v) => set("selected", v)} />
491
+ <Switch label="Published" checked={f.published} onChange={(v) => set("published", v)} />
492
+ </div>
493
+ </Modal>
494
+ );
495
+ }
496
+
497
+ /* =============================== CLIENTS =============================== */
498
+
499
+ function ClientsPanel() {
500
+ const [clients, setClients] = useState<ClientRow[] | null>(null);
501
+ const [editing, setEditing] = useState<ClientRow | "new" | null>(null);
502
+ const [note, setNote] = useState<string | null>(null);
503
+ const [busyId, setBusyId] = useState<string | null>(null);
504
+
505
+ async function load() {
506
+ const r = await callFn<OwnerClientsResult>("clientsForOwner", {});
507
+ if (r.authorized) setClients(r.clients);
508
+ }
509
+ useEffect(() => { void load(); }, []);
510
+
511
+ async function remove(c: ClientRow) {
512
+ if (!confirm(`Delete ${c.name}?`)) return;
513
+ setBusyId(c.id);
514
+ setNote(null);
515
+ try {
516
+ const r = await callFn<{ ok: boolean; invoices: number }>("deleteClient", { id: c.id });
517
+ if (!r.ok) setNote(`${c.name} has ${r.invoices} invoice${r.invoices === 1 ? "" : "s"} — delete those first.`);
518
+ else await load();
519
+ } finally {
520
+ setBusyId(null);
521
+ }
522
+ }
523
+
524
+ if (!clients) return <Skeleton compact />;
525
+
526
+ return (
527
+ <div className="space-y-6">
528
+ <PanelHead
529
+ title="Clients"
530
+ sub="Your CRM — private contact details, never exposed to the public site."
531
+ action={<NewButton onClick={() => setEditing("new")}>New client</NewButton>}
532
+ />
533
+ {note ? (
534
+ <div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-2.5 text-[13px] text-amber-800">{note}</div>
535
+ ) : null}
536
+
537
+ <Card title="Contacts" count={clients.length}>
538
+ {clients.length === 0 ? (
539
+ <Empty>No clients yet — add your first contact.</Empty>
540
+ ) : (
541
+ <ul className="divide-y divide-zinc-100">
542
+ {clients.map((c) => (
543
+ <li key={c.id} className="flex items-start gap-3 px-4 py-3.5">
544
+ <div className="min-w-0 flex-1">
545
+ <div className="flex flex-wrap items-center gap-2">
546
+ <span className="text-[14px] font-medium text-zinc-900">{c.name}</span>
547
+ {c.company ? <span className="text-[13px] text-zinc-500">{c.company}</span> : null}
548
+ <Pill kind="client" status={c.status} />
549
+ </div>
550
+ <div className="mt-0.5 truncate text-[12.5px] text-zinc-500">
551
+ {[c.email, c.phone].filter(Boolean).join(" · ") || "No contact details"}
552
+ </div>
553
+ {c.notes ? <p className="mt-1 line-clamp-2 text-[13px] leading-relaxed text-zinc-600">{c.notes}</p> : null}
554
+ </div>
555
+ <div className="flex shrink-0 items-center gap-1">
556
+ <button type="button" onClick={() => setEditing(c)} className="rounded-md px-2.5 py-1 text-[12.5px] font-medium text-zinc-600 hover:bg-zinc-100">
557
+ Edit
558
+ </button>
559
+ <button
560
+ type="button"
561
+ disabled={busyId === c.id}
562
+ onClick={() => remove(c)}
563
+ className="rounded-md px-2 py-1 text-[12.5px] text-zinc-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
564
+ title="Delete"
565
+ >
566
+
567
+ </button>
568
+ </div>
569
+ </li>
570
+ ))}
571
+ </ul>
572
+ )}
573
+ </Card>
574
+
575
+ {editing ? (
576
+ <ClientForm initial={editing === "new" ? null : editing} onClose={() => setEditing(null)} onSaved={load} />
577
+ ) : null}
578
+ </div>
579
+ );
580
+ }
581
+
582
+ function ClientForm({
583
+ initial,
584
+ onClose,
585
+ onSaved,
586
+ }: {
587
+ initial: ClientRow | null;
588
+ onClose: () => void;
589
+ onSaved: () => Promise<void>;
590
+ }) {
591
+ const [f, setF] = useState({
592
+ name: initial?.name ?? "",
593
+ company: initial?.company ?? "",
594
+ email: initial?.email ?? "",
595
+ phone: initial?.phone ?? "",
596
+ status: initial?.status ?? "prospect",
597
+ notes: initial?.notes ?? "",
598
+ });
599
+ const [saving, setSaving] = useState(false);
600
+ const [err, setErr] = useState<string | null>(null);
601
+ const set = <K extends keyof typeof f>(k: K, v: (typeof f)[K]) => setF((s) => ({ ...s, [k]: v }) as typeof f);
602
+
603
+ async function save() {
604
+ if (!f.name.trim()) { setErr("A name is required."); return; }
605
+ setSaving(true);
606
+ setErr(null);
607
+ try {
608
+ await callFn("upsertClient", {
609
+ id: initial?.id,
610
+ name: f.name.trim(),
611
+ company: f.company.trim() || undefined,
612
+ email: f.email.trim() || undefined,
613
+ phone: f.phone.trim() || undefined,
614
+ status: f.status,
615
+ notes: f.notes.trim() || undefined,
616
+ });
617
+ await onSaved();
618
+ onClose();
619
+ } catch (e) {
620
+ setErr(e instanceof Error ? e.message : "Couldn't save — try again.");
621
+ setSaving(false);
622
+ }
623
+ }
624
+
625
+ return (
626
+ <Modal title={initial ? "Edit client" : "New client"} onClose={onClose} onSubmit={save} saving={saving} error={err}>
627
+ <div className="grid gap-3 sm:grid-cols-2">
628
+ <Field label="Name" required>
629
+ <input value={f.name} onChange={(e) => set("name", e.target.value)} className={inputCls} autoFocus />
630
+ </Field>
631
+ <Field label="Company">
632
+ <input value={f.company} onChange={(e) => set("company", e.target.value)} className={inputCls} />
633
+ </Field>
634
+ <Field label="Email">
635
+ <input type="email" value={f.email} onChange={(e) => set("email", e.target.value)} className={inputCls} />
636
+ </Field>
637
+ <Field label="Phone">
638
+ <input value={f.phone} onChange={(e) => set("phone", e.target.value)} className={inputCls} />
639
+ </Field>
640
+ </div>
641
+ <Field label="Status">
642
+ <select value={f.status} onChange={(e) => set("status", e.target.value)} className={inputCls}>
643
+ <option value="prospect">Prospect</option>
644
+ <option value="active">Active</option>
645
+ <option value="past">Past</option>
646
+ </select>
647
+ </Field>
648
+ <Field label="Notes">
649
+ <textarea value={f.notes} onChange={(e) => set("notes", e.target.value)} rows={3} className={inputCls + " resize-none py-2"} />
650
+ </Field>
651
+ </Modal>
652
+ );
653
+ }
654
+
655
+ /* =============================== INVOICES ============================== */
656
+
657
+ function InvoicesPanel() {
658
+ const [invoices, setInvoices] = useState<InvoiceRow[] | null>(null);
659
+ const [clients, setClients] = useState<ClientRow[]>([]);
660
+ const { data: projects } = db.useQuery<ProjectRow>("Project");
661
+ const [editing, setEditing] = useState<InvoiceRow | "new" | null>(null);
662
+ const [viewing, setViewing] = useState<InvoiceRow | null>(null);
663
+ const [busyId, setBusyId] = useState<string | null>(null);
664
+
665
+ async function load() {
666
+ const [inv, cl] = await Promise.all([
667
+ callFn<OwnerInvoicesResult>("invoicesForOwner", {}),
668
+ callFn<OwnerClientsResult>("clientsForOwner", {}),
669
+ ]);
670
+ if (inv.authorized) setInvoices(inv.invoices);
671
+ if (cl.authorized) setClients(cl.clients);
672
+ // Keep an open view in sync after an edit.
673
+ if (inv.authorized) {
674
+ setViewing((cur) => (cur ? inv.invoices.find((i) => i.id === cur.id) ?? null : null));
675
+ }
676
+ }
677
+ useEffect(() => { void load(); }, []);
678
+
679
+ const clientFor = (inv: InvoiceRow) => clients.find((c) => c.id === inv.clientId);
680
+
681
+ async function setStatus(id: string, status: string) {
682
+ setBusyId(id);
683
+ try {
684
+ await callFn("setInvoiceStatus", { id, status });
685
+ await load();
686
+ } finally {
687
+ setBusyId(null);
688
+ }
689
+ }
690
+ async function remove(inv: InvoiceRow) {
691
+ if (!confirm(`Delete ${inv.number}?`)) return;
692
+ setBusyId(inv.id);
693
+ try {
694
+ await callFn("deleteInvoice", { id: inv.id });
695
+ await load();
696
+ } finally {
697
+ setBusyId(null);
698
+ }
699
+ }
700
+
701
+ const totals = useMemo(() => {
702
+ const list = invoices ?? [];
703
+ const paid = list.filter((i) => i.status === "paid").reduce((s, i) => s + i.amountCents, 0);
704
+ const outstanding = list
705
+ .filter((i) => i.status === "sent" || i.status === "overdue")
706
+ .reduce((s, i) => s + i.amountCents, 0);
707
+ return { paid, outstanding };
708
+ }, [invoices]);
709
+
710
+ if (!invoices) return <Skeleton compact />;
711
+
712
+ return (
713
+ <div className="space-y-6">
714
+ <PanelHead
715
+ title="Invoices"
716
+ sub="Billing — private. View any invoice, export it to PDF, and track what's paid vs outstanding. Never syncs to the public site."
717
+ action={<NewButton onClick={() => setEditing("new")}>New invoice</NewButton>}
718
+ />
719
+
720
+ <div className="grid gap-4 sm:grid-cols-3">
721
+ <Stat label="Paid" value={money(totals.paid)} />
722
+ <Stat label="Outstanding" value={money(totals.outstanding)} />
723
+ <Stat label="Invoices" value={String(invoices.length)} />
724
+ </div>
725
+
726
+ <Card title="All invoices" count={invoices.length}>
727
+ {invoices.length === 0 ? (
728
+ <Empty>No invoices yet.</Empty>
729
+ ) : (
730
+ <ul className="divide-y divide-zinc-100">
731
+ {invoices.map((inv) => (
732
+ <li key={inv.id} className="flex items-center gap-3 px-4 py-3.5">
733
+ <button
734
+ type="button"
735
+ onClick={() => setViewing(inv)}
736
+ className="min-w-0 flex-1 text-left"
737
+ title="View invoice"
738
+ >
739
+ <div className="flex flex-wrap items-center gap-2">
740
+ <span className="font-mono text-[12.5px] font-medium text-zinc-900">{inv.number}</span>
741
+ <span className="text-[14px] font-medium text-zinc-900">{money(inv.amountCents)}</span>
742
+ <Pill kind="invoice" status={inv.status} />
743
+ </div>
744
+ <div className="mt-0.5 truncate text-[12.5px] text-zinc-500">
745
+ {inv.clientName}
746
+ {inv.projectTitle ? ` · ${inv.projectTitle}` : ""}
747
+ {inv.dueAt ? ` · due ${inv.dueAt}` : ""}
748
+ </div>
749
+ </button>
750
+ <div className="flex shrink-0 items-center gap-1">
751
+ <select
752
+ value={inv.status}
753
+ disabled={busyId === inv.id}
754
+ onChange={(e) => setStatus(inv.id, e.target.value)}
755
+ aria-label="Status"
756
+ className="h-8 rounded-md border border-zinc-300 bg-white px-2 text-[12px] text-zinc-600 outline-none focus:border-brand"
757
+ >
758
+ <option value="draft">Draft</option>
759
+ <option value="sent">Sent</option>
760
+ <option value="paid">Paid</option>
761
+ <option value="overdue">Overdue</option>
762
+ </select>
763
+ <button type="button" onClick={() => setViewing(inv)} className="rounded-md px-2.5 py-1 text-[12.5px] font-medium text-zinc-600 hover:bg-zinc-100">
764
+ View
765
+ </button>
766
+ <button type="button" onClick={() => setEditing(inv)} className="rounded-md px-2.5 py-1 text-[12.5px] font-medium text-zinc-600 hover:bg-zinc-100">
767
+ Edit
768
+ </button>
769
+ <button
770
+ type="button"
771
+ disabled={busyId === inv.id}
772
+ onClick={() => remove(inv)}
773
+ className="rounded-md px-2 py-1 text-[12.5px] text-zinc-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
774
+ title="Delete"
775
+ >
776
+
777
+ </button>
778
+ </div>
779
+ </li>
780
+ ))}
781
+ </ul>
782
+ )}
783
+ </Card>
784
+
785
+ {viewing ? (
786
+ <InvoiceView
787
+ inv={viewing}
788
+ client={clientFor(viewing)}
789
+ onClose={() => setViewing(null)}
790
+ onEdit={() => { setEditing(viewing); setViewing(null); }}
791
+ />
792
+ ) : null}
793
+
794
+ {editing ? (
795
+ <InvoiceForm
796
+ initial={editing === "new" ? null : editing}
797
+ clients={clients}
798
+ projects={projects}
799
+ nextNumber={`INV-${String(invoices.length + 1).padStart(3, "0")}`}
800
+ onClose={() => setEditing(null)}
801
+ onSaved={load}
802
+ />
803
+ ) : null}
804
+ </div>
805
+ );
806
+ }
807
+
808
+ // Assemble the PDF payload from an invoice + its client + the studio config,
809
+ // then download it. The renderer is imported lazily here so it never lands in
810
+ // SSR or the initial bundle.
811
+ async function downloadInvoicePdf(inv: InvoiceRow, client?: ClientRow) {
812
+ const { buildInvoiceBlob } = await import("@/lib/invoice-pdf");
813
+ const blob = await buildInvoiceBlob({
814
+ number: inv.number,
815
+ status: inv.status,
816
+ issuedAt: inv.issuedAt,
817
+ dueAt: inv.dueAt,
818
+ notes: inv.notes,
819
+ projectTitle: inv.projectTitle,
820
+ items: parseLineItems(inv.lineItems),
821
+ amountCents: inv.amountCents,
822
+ billTo: { name: inv.clientName, company: client?.company, email: client?.email },
823
+ studio: {
824
+ name: siteConfig.brand.name,
825
+ addressLines: siteConfig.billing.addressLines,
826
+ paymentTerms: siteConfig.billing.paymentTerms,
827
+ footerNote: siteConfig.billing.footerNote,
828
+ brandColor: siteConfig.colors.brand,
829
+ },
830
+ });
831
+ const url = URL.createObjectURL(blob);
832
+ const a = document.createElement("a");
833
+ a.href = url;
834
+ a.download = `${inv.number}.pdf`;
835
+ document.body.appendChild(a);
836
+ a.click();
837
+ a.remove();
838
+ setTimeout(() => URL.revokeObjectURL(url), 2000);
839
+ }
840
+
841
+ // The full invoice document — an on-screen preview that mirrors the PDF, with
842
+ // Download PDF + Edit in the toolbar.
843
+ function InvoiceView({
844
+ inv,
845
+ client,
846
+ onClose,
847
+ onEdit,
848
+ }: {
849
+ inv: InvoiceRow;
850
+ client?: ClientRow;
851
+ onClose: () => void;
852
+ onEdit: () => void;
853
+ }) {
854
+ const [downloading, setDownloading] = useState(false);
855
+ const items = parseLineItems(inv.lineItems);
856
+ const rows = items.length > 0 ? items : [{ description: "Services", quantity: 1, unitCents: inv.amountCents }];
857
+ const { brand, billing } = siteConfig;
858
+
859
+ useEffect(() => {
860
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
861
+ window.addEventListener("keydown", onKey);
862
+ return () => window.removeEventListener("keydown", onKey);
863
+ }, [onClose]);
864
+
865
+ async function download() {
866
+ setDownloading(true);
867
+ try {
868
+ await downloadInvoicePdf(inv, client);
869
+ } catch {
870
+ /* generation is best-effort; the dashboard stays usable */
871
+ } finally {
872
+ setDownloading(false);
873
+ }
874
+ }
875
+
876
+ return (
877
+ <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 sm:p-8" onMouseDown={onClose}>
878
+ <div
879
+ onMouseDown={(e) => e.stopPropagation()}
880
+ className="my-auto w-full max-w-2xl overflow-hidden rounded-2xl bg-white shadow-[0_24px_64px_-16px_rgba(0,0,0,0.4)]"
881
+ >
882
+ {/* Toolbar */}
883
+ <div className="flex items-center justify-between border-b border-zinc-100 px-5 py-3">
884
+ <h2 className="font-mono text-[14px] font-semibold text-zinc-900">{inv.number}</h2>
885
+ <div className="flex items-center gap-2">
886
+ <button type="button" onClick={onEdit} className="rounded-lg px-3 py-1.5 text-[13px] font-medium text-zinc-600 hover:bg-zinc-100">
887
+ Edit
888
+ </button>
889
+ <button
890
+ type="button"
891
+ onClick={download}
892
+ disabled={downloading}
893
+ className="rounded-lg bg-brand px-3.5 py-1.5 text-[13px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
894
+ >
895
+ {downloading ? "Preparing…" : "Download PDF"}
896
+ </button>
897
+ <button type="button" onClick={onClose} className="rounded-md px-2 py-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700">✕</button>
898
+ </div>
899
+ </div>
900
+
901
+ {/* Document */}
902
+ <div className="max-h-[75vh] overflow-y-auto px-8 py-8 text-[13px] text-zinc-700">
903
+ <div className="flex items-start justify-between gap-6">
904
+ <div>
905
+ <div className="text-[17px] font-semibold text-zinc-900">{brand.name}</div>
906
+ {billing.addressLines.map((l, i) => (
907
+ <div key={i} className="text-[12px] text-zinc-500">{l}</div>
908
+ ))}
909
+ </div>
910
+ <div className="text-right">
911
+ <div className="font-mono text-[11px] font-semibold uppercase tracking-[0.16em] text-brand">Invoice</div>
912
+ <div className="font-mono text-[14px] font-semibold text-zinc-900">{inv.number}</div>
913
+ <div className="mt-1"><Pill kind="invoice" status={inv.status} /></div>
914
+ </div>
915
+ </div>
916
+
917
+ <div className="my-6 border-t border-zinc-200" />
918
+
919
+ <div className="flex items-start justify-between gap-6">
920
+ <div>
921
+ <div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-zinc-400">Bill to</div>
922
+ <div className="mt-1 font-medium text-zinc-900">{inv.clientName}</div>
923
+ {client?.company ? <div className="text-[12px] text-zinc-500">{client.company}</div> : null}
924
+ {client?.email ? <div className="text-[12px] text-zinc-500">{client.email}</div> : null}
925
+ </div>
926
+ <div className="w-48 space-y-1 text-[12px]">
927
+ {inv.projectTitle ? <MetaLine label="Project" value={inv.projectTitle} /> : null}
928
+ {inv.issuedAt ? <MetaLine label="Issued" value={inv.issuedAt} /> : null}
929
+ {inv.dueAt ? <MetaLine label="Due" value={inv.dueAt} /> : null}
930
+ <MetaLine label="Terms" value={billing.paymentTerms} />
931
+ </div>
932
+ </div>
933
+
934
+ <table className="mt-7 w-full text-[12.5px]">
935
+ <thead>
936
+ <tr className="border-b border-zinc-900 text-[10px] uppercase tracking-wide text-zinc-500">
937
+ <th className="py-1.5 text-left font-semibold">Description</th>
938
+ <th className="py-1.5 text-right font-semibold">Qty</th>
939
+ <th className="py-1.5 text-right font-semibold">Unit</th>
940
+ <th className="py-1.5 text-right font-semibold">Amount</th>
941
+ </tr>
942
+ </thead>
943
+ <tbody>
944
+ {rows.map((it, i) => (
945
+ <tr key={i} className="border-b border-zinc-100">
946
+ <td className="py-2 pr-3">{it.description}</td>
947
+ <td className="py-2 text-right tabular-nums">{it.quantity}</td>
948
+ <td className="py-2 text-right tabular-nums">{money(it.unitCents)}</td>
949
+ <td className="py-2 text-right tabular-nums">{money(Math.round(it.quantity * it.unitCents))}</td>
950
+ </tr>
951
+ ))}
952
+ </tbody>
953
+ </table>
954
+
955
+ <div className="mt-4 flex justify-end">
956
+ <div className="flex w-56 items-baseline justify-between">
957
+ <span className="text-[13px] font-semibold text-zinc-900">Total due</span>
958
+ <span className="text-[18px] font-semibold tabular-nums text-brand">{money(inv.amountCents)}</span>
959
+ </div>
960
+ </div>
961
+
962
+ {inv.notes ? (
963
+ <div className="mt-8">
964
+ <div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-zinc-400">Notes</div>
965
+ <p className="mt-1 whitespace-pre-wrap text-[12.5px] text-zinc-600">{inv.notes}</p>
966
+ </div>
967
+ ) : null}
968
+
969
+ <p className="mt-10 text-center text-[11px] text-zinc-400">{billing.footerNote}</p>
970
+ </div>
971
+ </div>
972
+ </div>
973
+ );
974
+ }
975
+
976
+ function MetaLine({ label, value }: { label: string; value: string }) {
977
+ return (
978
+ <div className="flex items-baseline justify-between gap-3">
979
+ <span className="text-zinc-400">{label}</span>
980
+ <span className="font-medium text-zinc-700">{value}</span>
981
+ </div>
982
+ );
983
+ }
984
+
985
+ // One editable line in the invoice form — strings while editing, converted to
986
+ // { quantity, unitCents } on save.
987
+ interface ItemDraft {
988
+ description: string;
989
+ quantity: string;
990
+ unit: string; // dollars, as typed
991
+ }
992
+
993
+ function initialItems(initial: InvoiceRow | null): ItemDraft[] {
994
+ const parsed = initial ? parseLineItems(initial.lineItems) : [];
995
+ if (parsed.length > 0) {
996
+ return parsed.map((it) => ({
997
+ description: it.description,
998
+ quantity: String(it.quantity),
999
+ unit: (it.unitCents / 100).toFixed(2),
1000
+ }));
1001
+ }
1002
+ // A legacy invoice (amount, no items) seeds one line at its total.
1003
+ if (initial && initial.amountCents > 0) {
1004
+ return [{ description: "Services", quantity: "1", unit: (initial.amountCents / 100).toFixed(2) }];
1005
+ }
1006
+ return [{ description: "", quantity: "1", unit: "" }];
1007
+ }
1008
+
1009
+ function draftToItem(d: ItemDraft): InvoiceLineItem {
1010
+ return {
1011
+ description: d.description.trim(),
1012
+ quantity: Number(d.quantity) || 0,
1013
+ unitCents: Math.max(0, Math.round((parseFloat(d.unit) || 0) * 100)),
1014
+ };
1015
+ }
1016
+
1017
+ function InvoiceForm({
1018
+ initial,
1019
+ clients,
1020
+ projects,
1021
+ nextNumber,
1022
+ onClose,
1023
+ onSaved,
1024
+ }: {
1025
+ initial: InvoiceRow | null;
1026
+ clients: ClientRow[];
1027
+ projects: ProjectRow[];
1028
+ nextNumber: string;
1029
+ onClose: () => void;
1030
+ onSaved: () => Promise<void>;
1031
+ }) {
1032
+ const [f, setF] = useState({
1033
+ number: initial?.number ?? nextNumber,
1034
+ clientId: initial?.clientId ?? clients[0]?.id ?? "",
1035
+ projectId: initial?.projectId ?? "",
1036
+ status: initial?.status ?? "draft",
1037
+ issuedAt: initial?.issuedAt ?? "",
1038
+ dueAt: initial?.dueAt ?? "",
1039
+ notes: initial?.notes ?? "",
1040
+ });
1041
+ const [items, setItems] = useState<ItemDraft[]>(() => initialItems(initial));
1042
+ const [saving, setSaving] = useState(false);
1043
+ const [err, setErr] = useState<string | null>(null);
1044
+ const set = <K extends keyof typeof f>(k: K, v: (typeof f)[K]) => setF((s) => ({ ...s, [k]: v }) as typeof f);
1045
+
1046
+ const setItem = (i: number, patch: Partial<ItemDraft>) =>
1047
+ setItems((arr) => arr.map((it, idx) => (idx === i ? { ...it, ...patch } : it)));
1048
+ const addItem = () => setItems((arr) => [...arr, { description: "", quantity: "1", unit: "" }]);
1049
+ const removeItem = (i: number) => setItems((arr) => (arr.length > 1 ? arr.filter((_, idx) => idx !== i) : arr));
1050
+
1051
+ const totalCents = lineItemsTotal(items.map(draftToItem));
1052
+
1053
+ async function save() {
1054
+ if (!f.clientId) { setErr("Pick a client."); return; }
1055
+ if (!f.number.trim()) { setErr("An invoice number is required."); return; }
1056
+ const lineItems = items.map(draftToItem).filter((it) => it.description.length > 0 || it.unitCents > 0);
1057
+ setSaving(true);
1058
+ setErr(null);
1059
+ try {
1060
+ await callFn("upsertInvoice", {
1061
+ id: initial?.id,
1062
+ number: f.number.trim(),
1063
+ clientId: f.clientId,
1064
+ projectId: f.projectId || undefined,
1065
+ lineItems,
1066
+ amountCents: totalCents,
1067
+ status: f.status,
1068
+ issuedAt: f.issuedAt || undefined,
1069
+ dueAt: f.dueAt || undefined,
1070
+ notes: f.notes.trim() || undefined,
1071
+ });
1072
+ await onSaved();
1073
+ onClose();
1074
+ } catch (e) {
1075
+ setErr(e instanceof Error ? e.message : "Couldn't save — try again.");
1076
+ setSaving(false);
1077
+ }
1078
+ }
1079
+
1080
+ const noClients = clients.length === 0;
1081
+
1082
+ return (
1083
+ <Modal title={initial ? "Edit invoice" : "New invoice"} onClose={onClose} onSubmit={save} saving={saving} error={err}>
1084
+ {noClients ? (
1085
+ <p className="rounded-lg bg-amber-50 px-3 py-2 text-[13px] text-amber-800">
1086
+ Add a client first — an invoice needs someone to bill.
1087
+ </p>
1088
+ ) : null}
1089
+ <div className="grid gap-3 sm:grid-cols-2">
1090
+ <Field label="Number" required>
1091
+ <input value={f.number} onChange={(e) => set("number", e.target.value)} className={inputCls + " font-mono"} />
1092
+ </Field>
1093
+ <Field label="Client" required>
1094
+ <select value={f.clientId} onChange={(e) => set("clientId", e.target.value)} className={inputCls} disabled={noClients}>
1095
+ {noClients ? <option value="">No clients yet</option> : null}
1096
+ {clients.map((c) => (
1097
+ <option key={c.id} value={c.id}>
1098
+ {c.name}{c.company ? ` · ${c.company}` : ""}
1099
+ </option>
1100
+ ))}
1101
+ </select>
1102
+ </Field>
1103
+ </div>
1104
+
1105
+ {/* Line items */}
1106
+ <div>
1107
+ <span className="mb-1 block text-[12.5px] font-medium text-zinc-600">Line items</span>
1108
+ <div className="space-y-2">
1109
+ {items.map((it, i) => (
1110
+ <div key={i} className="flex items-center gap-2">
1111
+ <input
1112
+ value={it.description}
1113
+ onChange={(e) => setItem(i, { description: e.target.value })}
1114
+ placeholder="Description"
1115
+ className={inputBase + " min-w-0 flex-1"}
1116
+ />
1117
+ <input
1118
+ type="number"
1119
+ min={0}
1120
+ value={it.quantity}
1121
+ onChange={(e) => setItem(i, { quantity: e.target.value })}
1122
+ aria-label="Quantity"
1123
+ title="Quantity"
1124
+ className={inputBase + " w-14 shrink-0 text-center tabular-nums"}
1125
+ />
1126
+ <div className="relative w-28 shrink-0">
1127
+ <span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[13px] text-zinc-400">$</span>
1128
+ <input
1129
+ type="number"
1130
+ min={0}
1131
+ step="0.01"
1132
+ value={it.unit}
1133
+ onChange={(e) => setItem(i, { unit: e.target.value })}
1134
+ placeholder="0.00"
1135
+ aria-label="Unit price"
1136
+ title="Unit price"
1137
+ className={inputBase + " w-full pl-6 text-right tabular-nums"}
1138
+ />
1139
+ </div>
1140
+ <button
1141
+ type="button"
1142
+ onClick={() => removeItem(i)}
1143
+ disabled={items.length === 1}
1144
+ className="shrink-0 rounded-md px-2 py-1 text-[13px] text-zinc-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-30"
1145
+ title="Remove line"
1146
+ >
1147
+
1148
+ </button>
1149
+ </div>
1150
+ ))}
1151
+ </div>
1152
+ <div className="mt-2 flex items-center justify-between">
1153
+ <button type="button" onClick={addItem} className="text-[12.5px] font-medium text-brand hover:underline">
1154
+ + Add line
1155
+ </button>
1156
+ <span className="text-[13px] text-zinc-500">
1157
+ Total <span className="font-semibold tabular-nums text-zinc-900">{money(totalCents)}</span>
1158
+ </span>
1159
+ </div>
1160
+ </div>
1161
+
1162
+ <Field label="Project" hint="Optional — ties the bill to a case study">
1163
+ <select value={f.projectId} onChange={(e) => set("projectId", e.target.value)} className={inputCls}>
1164
+ <option value="">None</option>
1165
+ {projects.map((p) => (
1166
+ <option key={p.id} value={p.id}>{p.title}</option>
1167
+ ))}
1168
+ </select>
1169
+ </Field>
1170
+ <div className="grid gap-3 sm:grid-cols-3">
1171
+ <Field label="Status">
1172
+ <select value={f.status} onChange={(e) => set("status", e.target.value)} className={inputCls}>
1173
+ <option value="draft">Draft</option>
1174
+ <option value="sent">Sent</option>
1175
+ <option value="paid">Paid</option>
1176
+ <option value="overdue">Overdue</option>
1177
+ </select>
1178
+ </Field>
1179
+ <Field label="Issued">
1180
+ <input type="date" value={f.issuedAt ?? ""} onChange={(e) => set("issuedAt", e.target.value)} className={inputCls} />
1181
+ </Field>
1182
+ <Field label="Due">
1183
+ <input type="date" value={f.dueAt ?? ""} onChange={(e) => set("dueAt", e.target.value)} className={inputCls} />
1184
+ </Field>
1185
+ </div>
1186
+ <Field label="Notes">
1187
+ <textarea value={f.notes} onChange={(e) => set("notes", e.target.value)} rows={2} className={inputCls + " resize-none py-2"} />
1188
+ </Field>
1189
+ </Modal>
1190
+ );
1191
+ }
1192
+
1193
+ /* ============================== PRIMITIVES ============================= */
1194
+
1195
+ function PanelHead({ title, sub, action }: { title: string; sub: string; action?: React.ReactNode }) {
1196
+ return (
1197
+ <div className="flex flex-wrap items-start justify-between gap-3">
1198
+ <div>
1199
+ <h1 className="text-xl font-semibold tracking-tight">{title}</h1>
1200
+ <p className="mt-1 max-w-xl text-sm text-zinc-500">{sub}</p>
1201
+ </div>
1202
+ {action}
1203
+ </div>
1204
+ );
1205
+ }
1206
+
1207
+ function NewButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
1208
+ return (
1209
+ <button
1210
+ type="button"
1211
+ onClick={onClick}
1212
+ className="shrink-0 rounded-lg bg-zinc-900 px-3.5 py-2 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700"
1213
+ >
1214
+ + {children}
1215
+ </button>
1216
+ );
1217
+ }
1218
+
1219
+ function Card({ title, count, children }: { title: string; count: number; children: React.ReactNode }) {
1220
+ return (
1221
+ <div className="rounded-xl border border-zinc-200 bg-white">
1222
+ <div className="border-b border-zinc-100 px-4 py-3 text-sm font-semibold text-zinc-900">
1223
+ {title} <span className="font-normal text-zinc-400">({count})</span>
1224
+ </div>
1225
+ {children}
1226
+ </div>
1227
+ );
1228
+ }
1229
+
1230
+ function Empty({ children }: { children: React.ReactNode }) {
1231
+ return <p className="p-8 text-center text-sm text-zinc-500">{children}</p>;
1232
+ }
1233
+
209
1234
  function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) {
210
1235
  return (
211
1236
  <div className="rounded-xl border border-zinc-200 bg-white p-4">
@@ -216,24 +1241,154 @@ function Stat({ label, value, hint }: { label: string; value: string; hint?: str
216
1241
  );
217
1242
  }
218
1243
 
219
- function StatusBadge({ status }: { status: string }) {
220
- const tone =
221
- status === "booked"
222
- ? "bg-green-50 text-green-700"
223
- : status === "declined"
224
- ? "bg-zinc-100 text-zinc-400"
225
- : "bg-amber-50 text-amber-700"; // new
226
- const label = status === "new" ? "new lead" : status;
227
- return <span className={"rounded-full px-2 py-0.5 text-[10px] font-medium capitalize " + tone}>{label}</span>;
1244
+ // One pill component for every status across the dashboard, tone-mapped by kind.
1245
+ function Pill({ kind, status }: { kind: "inquiry" | "client" | "invoice"; status: string }) {
1246
+ const tones: Record<string, string> = {
1247
+ // inquiry
1248
+ new: "bg-amber-50 text-amber-700",
1249
+ booked: "bg-green-50 text-green-700",
1250
+ declined: "bg-zinc-100 text-zinc-400",
1251
+ // client
1252
+ prospect: "bg-blue-50 text-blue-700",
1253
+ active: "bg-green-50 text-green-700",
1254
+ past: "bg-zinc-100 text-zinc-500",
1255
+ // invoice
1256
+ draft: "bg-zinc-100 text-zinc-500",
1257
+ sent: "bg-blue-50 text-blue-700",
1258
+ paid: "bg-green-50 text-green-700",
1259
+ overdue: "bg-red-50 text-red-700",
1260
+ };
1261
+ const label = kind === "inquiry" && status === "new" ? "new lead" : status;
1262
+ return (
1263
+ <span className={"rounded-full px-2 py-0.5 text-[10px] font-medium capitalize " + (tones[status] ?? "bg-zinc-100 text-zinc-500")}>
1264
+ {label}
1265
+ </span>
1266
+ );
228
1267
  }
229
1268
 
1269
+ function IconToggle({
1270
+ on,
1271
+ disabled,
1272
+ title,
1273
+ onClick,
1274
+ children,
1275
+ }: {
1276
+ on: boolean;
1277
+ disabled?: boolean;
1278
+ title: string;
1279
+ onClick: () => void;
1280
+ children: React.ReactNode;
1281
+ }) {
1282
+ return (
1283
+ <button
1284
+ type="button"
1285
+ disabled={disabled}
1286
+ title={title}
1287
+ onClick={onClick}
1288
+ className={
1289
+ "rounded-md px-2 py-1 text-[13px] leading-none transition-colors disabled:opacity-50 " +
1290
+ (on ? "text-amber-500 hover:bg-amber-50" : "text-zinc-300 hover:bg-zinc-100 hover:text-zinc-500")
1291
+ }
1292
+ >
1293
+ {children}
1294
+ </button>
1295
+ );
1296
+ }
1297
+
1298
+ function Switch({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
1299
+ return (
1300
+ <button type="button" onClick={() => onChange(!checked)} className="flex items-center gap-2 text-[13px] text-zinc-700">
1301
+ <span
1302
+ className={
1303
+ "relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors " +
1304
+ (checked ? "bg-brand" : "bg-zinc-300")
1305
+ }
1306
+ >
1307
+ <span className={"inline-block size-4 transform rounded-full bg-white transition-transform " + (checked ? "translate-x-4" : "translate-x-0.5")} />
1308
+ </span>
1309
+ {label}
1310
+ </button>
1311
+ );
1312
+ }
1313
+
1314
+ // A simple centered modal with a sticky footer. Submits on the footer button or
1315
+ // Enter; closes on the backdrop, the ✕, or Escape.
1316
+ function Modal({
1317
+ title,
1318
+ onClose,
1319
+ onSubmit,
1320
+ saving,
1321
+ error,
1322
+ children,
1323
+ }: {
1324
+ title: string;
1325
+ onClose: () => void;
1326
+ onSubmit: () => void;
1327
+ saving: boolean;
1328
+ error: string | null;
1329
+ children: React.ReactNode;
1330
+ }) {
1331
+ useEffect(() => {
1332
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
1333
+ window.addEventListener("keydown", onKey);
1334
+ return () => window.removeEventListener("keydown", onKey);
1335
+ }, [onClose]);
1336
+
1337
+ return (
1338
+ <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 p-4 sm:p-8" onMouseDown={onClose}>
1339
+ <form
1340
+ onMouseDown={(e) => e.stopPropagation()}
1341
+ onSubmit={(e) => { e.preventDefault(); onSubmit(); }}
1342
+ className="my-auto w-full max-w-lg rounded-2xl bg-white shadow-[0_24px_64px_-16px_rgba(0,0,0,0.35)]"
1343
+ >
1344
+ <div className="flex items-center justify-between border-b border-zinc-100 px-5 py-3.5">
1345
+ <h2 className="text-[15px] font-semibold text-zinc-900">{title}</h2>
1346
+ <button type="button" onClick={onClose} className="rounded-md px-2 py-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700">✕</button>
1347
+ </div>
1348
+ <div className="max-h-[70vh] space-y-3.5 overflow-y-auto px-5 py-4">{children}</div>
1349
+ <div className="flex items-center justify-between gap-3 border-t border-zinc-100 px-5 py-3.5">
1350
+ <span className="text-[12.5px] text-red-600">{error}</span>
1351
+ <div className="flex items-center gap-2">
1352
+ <button type="button" onClick={onClose} className="rounded-lg px-3.5 py-2 text-[13px] font-medium text-zinc-600 hover:bg-zinc-100">
1353
+ Cancel
1354
+ </button>
1355
+ <button type="submit" disabled={saving} className="rounded-lg bg-brand px-4 py-2 text-[13px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50">
1356
+ {saving ? "Saving…" : "Save"}
1357
+ </button>
1358
+ </div>
1359
+ </div>
1360
+ </form>
1361
+ </div>
1362
+ );
1363
+ }
1364
+
1365
+ function Field({ label, hint, required, children }: { label: string; hint?: string; required?: boolean; children: React.ReactNode }) {
1366
+ return (
1367
+ <label className="block">
1368
+ <span className="mb-1 block text-[12.5px] font-medium text-zinc-600">
1369
+ {label}
1370
+ {required ? <span className="text-brand"> *</span> : null}
1371
+ {hint ? <span className="ml-1 font-normal text-zinc-400">— {hint}</span> : null}
1372
+ </span>
1373
+ {children}
1374
+ </label>
1375
+ );
1376
+ }
1377
+
1378
+ // Base field styles WITHOUT a width, so flex rows (the invoice line items) can
1379
+ // size each input themselves. `inputCls` is the full-width variant for the
1380
+ // stacked form fields.
1381
+ const inputBase =
1382
+ "h-9 rounded-lg border border-zinc-300 bg-white px-3 text-[14px] text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20";
1383
+ const inputCls = inputBase + " w-full";
1384
+
230
1385
  function OwnerOnly({ email }: { email: string }) {
231
1386
  return (
232
1387
  <div className="rounded-xl border border-dashed border-zinc-300 px-6 py-12 text-center">
233
1388
  <h1 className="text-lg font-semibold">This dashboard is owner-only</h1>
234
1389
  <p className="mx-auto mt-2 max-w-md text-sm text-zinc-500">
235
1390
  You&apos;re signed in as <span className="font-medium text-zinc-700">{email || "this account"}</span>.
236
- Only the studio owner can see inquiries. Set{" "}
1391
+ Only the studio owner can see the back-office. Set{" "}
237
1392
  <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[12px]">PYLON_OWNER_EMAIL={email || "you@studio.com"}</code>{" "}
238
1393
  in your <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[12px]">.env</code>, restart, and reload —
239
1394
  or sign in with the owner account.
@@ -270,16 +1425,15 @@ export function UserMenu({ email }: { email: string }) {
270
1425
  );
271
1426
  }
272
1427
 
273
- function Skeleton() {
1428
+ function Skeleton({ compact }: { compact?: boolean }) {
274
1429
  return (
275
1430
  <div className="space-y-8">
276
- <div className="h-6 w-28 animate-pulse rounded bg-zinc-100" />
1431
+ {!compact ? <div className="h-6 w-28 animate-pulse rounded bg-zinc-100" /> : null}
277
1432
  <div className="grid gap-4 sm:grid-cols-3">
278
1433
  {[0, 1, 2].map((i) => (
279
1434
  <div key={i} className="h-20 animate-pulse rounded-xl bg-zinc-100" />
280
1435
  ))}
281
1436
  </div>
282
- <div className="h-28 animate-pulse rounded-xl bg-zinc-100" />
283
1437
  <div className="h-48 animate-pulse rounded-xl bg-zinc-100" />
284
1438
  </div>
285
1439
  );