metatroncubeswdev aa56d2b531 Monthly client report from real numbers, emailed or automatic
- Posts and results for the month, follower change, best posts, formats that worked, every post,
  competitor comparison and next month's plan; branded PDF via tools/report-pdf (no AI key needed)
- Client page → Documents: create for any month, email the PDF to the client's reviewer
- Optional per client: create on the 1st of each month and email it; admins get a bell notification
- Documents list groups monthly reports separately

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:11:04 -04:00

572 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Bar, BarChart, CartesianGrid, Cell, Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { ArrowLeft, ArrowUpRight, ClipboardCheck, CalendarRange, FileBarChart, FileDown, FileText, Eye, RefreshCw, Sparkles, Briefcase, Link2, Facebook, Instagram } from "lucide-react";
import { api } from "../api.js";
import ClientPortal from "./ClientPortal.jsx";
import MonthlyReport from "./MonthlyReport.jsx";
import { useAuth } from "../App.jsx";
import {
Badge, Button, cx, Dialog, Empty, ErrorNote, fmt, fmtDate, fmtDateTime, Panel, Spinner, Stat, StatCell, StatRow, STATUS_TONE, Tabs, timeAgo,
} from "../ui.jsx";
const FORMAT = {
reel: { label: "Reel", color: "#3b3778" },
carousel: { label: "Carousel", color: "#fc6d45" },
image: { label: "Image", color: "#a9afba" },
};
const formatOf = (p) => (p.media_product_type === "REELS" || p.media_type === "VIDEO" ? "reel" : p.media_type === "CAROUSEL_ALBUM" ? "carousel" : "image");
const RUNS = [
{ type: "account-audit", label: "Account audit", icon: ClipboardCheck, hint: "Internal + client audit, brand draft, PDFs" },
{ type: "weekly-plan", label: "Weekly content plan", icon: CalendarRange, hint: "Next week's posts and creative briefs" },
{ type: "weekly-report", label: "Weekly report", icon: FileBarChart, hint: "Last 7 days vs previous week, lessons" },
];
export default function Client() {
const { slug } = useParams();
const nav = useNavigate();
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [tab, setTab] = useState("performance");
const [runOpen, setRunOpen] = useState(false);
const [linkOpen, setLinkOpen] = useState(false);
const { isAdmin } = useAuth();
const load = useCallback(() => api(`/api/clients/${slug}`).then(setData).catch(setError), [slug]);
useEffect(() => { setData(null); load(); }, [load]);
useEffect(() => {
if (!data?.refreshing) return;
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, [data?.refreshing, load]);
const refresh = async () => {
setError(null);
await api(`/api/clients/${slug}/refresh`, { method: "POST" }).catch(setError);
load();
};
if (error && !data) return <ErrorNote error={error} />;
if (!data) return <Spinner />;
const { client, snapshot } = data;
return (
<div className="space-y-5">
<Link to="/" className="inline-flex items-center gap-1 text-[12.5px] text-muted hover:text-ink">
<ArrowLeft className="size-3.5" /> Clients
</Link>
<header className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-[24px] font-extrabold truncate">{client.name}</h1>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1 text-[13px] text-muted">
{client.igUsername && (
<a className="hover:text-brand inline-flex items-center gap-0.5" href={`https://instagram.com/${client.igUsername}`} target="_blank" rel="noopener">
@{client.igUsername} <ArrowUpRight className="size-3" />
</a>
)}
{client.pageId && (
<a className="hover:text-brand inline-flex items-center gap-0.5" href={`https://facebook.com/${client.pageId}`} target="_blank" rel="noopener">
Facebook Page <ArrowUpRight className="size-3" />
</a>
)}
{client.category && <span>{client.category}</span>}
{client.odooProject && (
<span className="inline-flex items-center gap-1"><Briefcase className="size-3" />{client.odooProject.name}</span>
)}
</div>
</div>
<div className="flex gap-2 shrink-0">
{client.linked ? (
<Button icon={RefreshCw} onClick={refresh} loading={data.refreshing}>
{data.refreshing ? "Fetching from Meta" : "Refresh numbers"}
</Button>
) : isAdmin && (
<Button icon={Link2} onClick={() => setLinkOpen(true)}>Link an account</Button>
)}
<Button variant="primary" icon={Sparkles} onClick={() => setRunOpen(true)}>Run with Claude</Button>
</div>
</header>
<ErrorNote error={error} />
<SetupHints client={client} />
<Tabs
value={tab}
onChange={setTab}
tabs={[
{ value: "performance", label: "Performance" },
{ value: "docs", label: "Documents", count: data.docs.filter((d) => d.kind !== "setup").length },
{ value: "runs", label: "AI runs", count: data.jobs.length },
{ value: "portal", label: "Client approval" },
]}
/>
{tab === "performance" && (client.linked
? <Performance snapshot={snapshot} history={data.history} refreshing={data.refreshing} onRefresh={refresh} />
: (
<Panel>
<Empty icon={Link2} title="Not linked to a Facebook Page or Instagram account" action={isAdmin && <Button variant="primary" icon={Link2} onClick={() => setLinkOpen(true)}>Link an account</Button>}>
Numbers, publishing and the inbox need this client's Facebook Page or Instagram account. If it isn't in the list,
assign it to the agency's system user in Meta Business Settings → System users → Assign assets, then check again.
</Empty>
</Panel>
))}
{tab === "docs" && <div className="space-y-5"><MonthlyReport slug={slug} onBuilt={load} /><Documents slug={slug} docs={data.docs} reload={load} /></div>}
{tab === "runs" && <ClientRuns jobs={data.jobs} />}
{tab === "portal" && <ClientPortal slug={slug} clientName={client.name} />}
<RunDialog open={runOpen} onClose={() => setRunOpen(false)} client={client} onStarted={(job) => nav(`/runs/${job.id}`)} />
{linkOpen && <LinkPageDialog client={client} onClose={() => setLinkOpen(false)} onLinked={() => { setLinkOpen(false); load(); }} />}
</div>
);
}
/** Link an existing client folder to a Page the agency's Meta account manages. */
function LinkPageDialog({ client, onClose, onLinked }) {
const [accounts, setAccounts] = useState(null);
const [picked, setPicked] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const check = useCallback(() => {
setAccounts(null); setError(null);
api("/api/accounts/unlinked?refresh=1").then((r) => setAccounts(r.accounts)).catch(setError);
}, []);
useEffect(() => { check(); }, [check]);
const link = async () => {
setBusy(true); setError(null);
try { await api(`/api/clients/${client.slug}/link-page`, { method: "POST", body: { accountId: picked.account_id } }); onLinked(); }
catch (e) { setError(e); setBusy(false); }
};
return (
<Dialog open onClose={onClose} title={`Link ${client.name} to an account`}
footer={<><Button variant="ghost" onClick={onClose}>Cancel</Button><Button variant="primary" disabled={!picked} loading={busy} onClick={link}>Link and fetch numbers</Button></>}>
<p className="text-muted text-[13px] mb-3">Facebook Pages and Instagram accounts the agency's Meta account manages that no client is linked to yet. The client keeps its name and files.</p>
<ErrorNote error={error} className="mb-3" />
<div className="flex justify-end mb-2"><Button size="sm" variant="ghost" icon={RefreshCw} onClick={check}>Check Meta again</Button></div>
{!accounts && !error ? <Spinner label="Asking Meta…" /> : accounts?.length === 0 ? (
<Empty title="No unlinked accounts">Assign {client.name}'s Page or Instagram account to the agency's system user in Meta Business Settings → System users → Assign assets, then press Check Meta again.</Empty>
) : (
<ul className="border border-line rounded-lg divide-y divide-line max-h-[260px] overflow-y-auto">
{accounts?.map((a) => (
<li key={a.account_id}>
<button type="button" onClick={() => setPicked(a)} className={cx("w-full text-left px-3.5 py-2.5 flex items-center gap-3", picked?.account_id === a.account_id ? "bg-brand-soft" : "hover:bg-canvas")}>
<span className={cx("size-4 rounded-full border-2 shrink-0", picked?.account_id === a.account_id ? "border-brand bg-brand shadow-[inset_0_0_0_2px_white]" : "border-line-strong")} />
<span className="min-w-0 flex-1">
<span className="block font-medium truncate">{a.page_name}</span>
<span className="flex gap-3 text-[12px] text-muted">
{a.page_id ? <span className="inline-flex items-center gap-1"><Facebook className="size-3" />{a.category || "Page"}</span> : <span>Instagram only · no Facebook Page</span>}
{a.instagram ? <span className="inline-flex items-center gap-1"><Instagram className="size-3" />@{a.instagram.username}</span> : <span>no Instagram linked</span>}
</span>
</span>
</button>
</li>
))}
</ul>
)}
</Dialog>
);
}
function SetupHints({ client }) {
const notes = [];
if (client.setup.brand.draft) notes.push("brand.md is a DRAFT. Confirm it with the client.");
if (client.setup.goals.placeholders > 3) notes.push("goals.md still has template gaps (approver, KPIs, retainer).");
if (client.setup.constraints.placeholders > 3) notes.push("constraints.md is mostly empty (approver, claims, regulated rules).");
if (!notes.length) return null;
return (
<div className="rounded-lg border border-[#f1dcb8] bg-warn-soft px-4 py-2.5 text-[13px] text-[#7a4f10]">
<span className="font-semibold">Setup gaps · </span>
{notes.join(" ")}
</div>
);
}
// ---------------------------------------------------------------- performance
function Performance({ snapshot, history, refreshing, onRefresh }) {
const ig = snapshot?.ig;
const fb = snapshot?.fb;
const posts = ig?.posts || [];
const perPost = useMemo(
() =>
[...posts]
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
.map((p) => ({ date: fmtDate(p.timestamp), reach: p.insights?.reach ?? 0, format: formatOf(p), caption: (p.caption || "").split("\n")[0].slice(0, 70) })),
[posts]
);
if (!snapshot) {
return (
<Panel>
<Empty icon={RefreshCw} title={refreshing ? "Fetching numbers from Meta…" : "No numbers yet"} action={!refreshing && <Button variant="primary" onClick={onRefresh}>Fetch now</Button>}>
The first fetch pulls the profile, 28-day totals and insights for the last 20 posts. It takes about a minute.
</Empty>
</Panel>
);
}
const d = ig?.derived;
const t = ig?.totals || {};
const followersTrend = history.filter((h) => h.igFollowers != null);
const followerDelta = followersTrend.length > 1 ? followersTrend.at(-1).igFollowers - followersTrend[0].igFollowers : null;
return (
<div className="space-y-5">
<div className="flex items-center justify-between text-[12.5px] text-muted">
<span>Fetched {timeAgo(snapshot.fetchedAt)} · totals cover the last 28 days</span>
{snapshot.errors?.length > 0 && <span className="text-bad">{snapshot.errors.join(" · ")}</span>}
</div>
{ig && (
<StatRow className="grid-cols-5">
<StatCell>
<Stat label="IG followers" value={fmt(ig.profile.followers_count)}
note={followerDelta != null ? `${followerDelta >= 0 ? "+" : ""}${followerDelta} since ${fmtDate(followersTrend[0].date)}` : `follows ${fmt(ig.profile.follows_count)}`} />
</StatCell>
<StatCell><Stat label="Accounts reached" value={fmt(t.reach)} note={ig.profile.followers_count ? `${(t.reach / ig.profile.followers_count).toFixed(1)}× follower count` : null} /></StatCell>
<StatCell><Stat label="Interactions" value={fmt(t.total_interactions)} note={`${fmt(t.likes)} likes · ${fmt(t.comments)} comments`} /></StatCell>
<StatCell><Stat label="Profile link taps" value={fmt(t.profile_links_taps)} tone={t.profile_links_taps === 0 ? "bad" : null} note={t.profile_links_taps === 0 ? "nobody reached the website" : "to website / booking"} /></StatCell>
<StatCell><Stat label="Saves · shares" value={`${fmt(t.saves)} · ${fmt(t.shares)}`} note="signals content worth keeping" /></StatCell>
</StatRow>
)}
<div className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)] gap-5">
<Panel title="Reach per post" subtitle="Last 20 Instagram posts, oldest to newest" bodyClass="px-2 pt-3 pb-2">
{perPost.length ? (
<>
<div className="h-[220px]">
<ResponsiveContainer>
<BarChart data={perPost} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} stroke="#eef0f3" />
<XAxis dataKey="date" tick={{ fontSize: 11, fill: "#8a8f99" }} tickLine={false} axisLine={{ stroke: "#e4e7ec" }} interval="preserveStartEnd" minTickGap={18} />
<YAxis tick={{ fontSize: 11, fill: "#8a8f99" }} tickLine={false} axisLine={false} width={48} allowDecimals={false} tickFormatter={(v) => (v >= 10000 ? `${Math.round(v / 1000)}k` : v)} />
<Tooltip cursor={{ fill: "#f3f6fa" }} content={<PostTip />} />
<Bar dataKey="reach" radius={[3, 3, 0, 0]} maxBarSize={26} isAnimationActive={false}>
{perPost.map((p, i) => <Cell key={i} fill={FORMAT[p.format].color} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="flex gap-4 px-3 pt-1 text-[12px] text-muted">
{Object.values(FORMAT).map((f) => (
<span key={f.label} className="inline-flex items-center gap-1.5"><span className="size-2.5 rounded-sm" style={{ background: f.color }} />{f.label}</span>
))}
</div>
</>
) : <Empty title="No posts yet" />}
</Panel>
<Panel title="What's working" subtitle="Average reach by format (posts older than 48 h)" bodyClass="p-4 space-y-4">
{d && (
<>
<FormatBars d={d} />
<div className="grid grid-cols-3 gap-3 pt-3 border-t border-line">
<Stat label="Posts / 28d" value={fmt(d.postsLast28)} tone={d.postsLast28 < 8 ? "warn" : null} note="target 16+" />
<Stat label="Last post" value={d.daysSinceLastPost == null ? "—" : `${d.daysSinceLastPost}d`} tone={d.daysSinceLastPost >= 7 ? "bad" : null} note="ago" />
<Stat label="Longest gap" value={d.longestGapDays == null ? "—" : `${d.longestGapDays}d`} tone={d.longestGapDays > 7 ? "warn" : null} note="in last 20" />
</div>
</>
)}
</Panel>
</div>
{ig?.dailyReach?.length > 1 && (
<Panel title="Accounts reached per day" subtitle="Instagram, last 30 days" bodyClass="px-2 pt-3 pb-2">
<div className="h-[180px]">
<ResponsiveContainer>
<AreaChart data={ig.dailyReach.map((x) => ({ ...x, label: fmtDate(x.date) }))} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="reachFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b3778" stopOpacity={0.18} />
<stop offset="100%" stopColor="#3b3778" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} stroke="#eef0f3" />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#8a8f99" }} tickLine={false} axisLine={{ stroke: "#e4e7ec" }} minTickGap={24} />
<YAxis tick={{ fontSize: 11, fill: "#8a8f99" }} tickLine={false} axisLine={false} width={48} allowDecimals={false} tickFormatter={(v) => (v >= 10000 ? `${Math.round(v / 1000)}k` : v)} />
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 8, border: "1px solid #e4e7ec" }} formatter={(v) => [fmt(v), "Reached"]} />
<Area type="monotone" dataKey="value" stroke="#3b3778" strokeWidth={2} fill="url(#reachFill)" isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
</Panel>
)}
{fb && (
<StatRow className="grid-cols-4">
<StatCell><Stat label="FB followers" value={fmt(fb.page.followers_count)} /></StatCell>
<StatCell><Stat label="Unique viewers" value={fmt(fb.totals.page_total_media_view_unique)} note="saw a post or Reel" /></StatCell>
<StatCell><Stat label="Post engagements" value={fmt(fb.totals.page_post_engagements)} tone={fb.totals.page_post_engagements <= 1 ? "warn" : null} /></StatCell>
<StatCell><Stat label="Page views" value={fmt(fb.totals.page_views_total)} /></StatCell>
</StatRow>
)}
<Panel title="Recent Instagram posts" bodyClass="overflow-x-auto">
{posts.length ? (
<table className="tbl">
<thead>
<tr>
<th>Post</th><th>Format</th><th>Published</th>
<th className="text-right">Reached</th><th className="text-right">Likes</th><th className="text-right">Saves</th><th className="text-right">Shares</th><th />
</tr>
</thead>
<tbody>
{posts.map((p) => {
const f = FORMAT[formatOf(p)];
return (
<tr key={p.id}>
<td className="max-w-[380px]"><div className="truncate text-ink">{(p.caption || "(no caption)").split("\n")[0]}</div></td>
<td><span className="inline-flex items-center gap-1.5 text-ink-2"><span className="size-2 rounded-sm" style={{ background: f.color }} />{f.label}</span></td>
<td className="text-ink-2 whitespace-nowrap">{fmtDate(p.timestamp)}</td>
<td className="text-right num font-medium">{fmt(p.insights?.reach)}</td>
<td className="text-right num">{fmt(p.like_count)}</td>
<td className="text-right num">{fmt(p.insights?.saved)}</td>
<td className="text-right num">{fmt(p.insights?.shares)}</td>
<td className="text-right">
<a href={p.permalink} target="_blank" rel="noopener" className="text-muted hover:text-brand inline-flex" aria-label="Open on Instagram"><ArrowUpRight className="size-4" /></a>
</td>
</tr>
);
})}
</tbody>
</table>
) : <Empty title="No Instagram posts" />}
</Panel>
</div>
);
}
function PostTip({ active, payload }) {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="bg-white border border-line rounded-lg shadow-sm px-3 py-2 text-[12px] max-w-[240px]">
<div className="font-semibold text-ink">{fmt(p.reach)} reached · {FORMAT[p.format].label}</div>
<div className="text-muted">{p.date}</div>
{p.caption && <div className="text-ink-2 mt-1">{p.caption}</div>}
</div>
);
}
function FormatBars({ d }) {
const rows = [
["reel", d.formatMix.reels, d.avgReach.reels],
["carousel", d.formatMix.carousels, d.avgReach.carousels],
["image", d.formatMix.images, d.avgReach.images],
];
const max = Math.max(...rows.map((r) => r[2] || 0), 1);
return (
<div className="space-y-3">
{rows.map(([k, count, reach]) => (
<div key={k}>
<div className="flex justify-between text-[12.5px]">
<span className="text-ink-2">{FORMAT[k].label}s <span className="text-faint">· {count} posted</span></span>
<span className="num font-semibold text-ink">{reach == null ? "—" : fmt(reach)}</span>
</div>
<div className="h-2 rounded-full bg-[#eef0f3] mt-1.5 overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${((reach || 0) / max) * 100}%`, background: FORMAT[k].color }} />
</div>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------- documents
const KIND_LABEL = { monthly: "Monthly reports", audit: "Audits", report: "Weekly reports", calendar: "Content calendars", "profile-check": "Profile checks", setup: "Client setup files" };
function Documents({ slug, docs, reload }) {
const [preview, setPreview] = useState(null);
const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
const open = async (doc) => {
setPreview({ doc, loading: true });
try {
setPreview({ doc, ...(await api(`/api/clients/${slug}/doc?path=${encodeURIComponent(doc.path)}`)) });
} catch (e) {
setPreview(null);
setError(e);
}
};
const exportPdf = async (doc) => {
setBusy(doc.path);
setError(null);
try {
const r = await api(`/api/clients/${slug}/doc/pdf`, { method: "POST", body: { path: doc.path } });
window.open(fileUrl(slug, r.pdf), "_blank");
reload();
} catch (e) {
setError(e);
} finally {
setBusy(null);
}
};
const groups = Object.keys(KIND_LABEL).map((k) => [k, docs.filter((d) => d.kind === k)]).filter(([, list]) => list.length);
if (!docs.length) return <Panel><Empty icon={FileText} title="No documents yet">Run an account audit to create the first ones.</Empty></Panel>;
return (
<div className="space-y-5">
<ErrorNote error={error} />
{groups.map(([kind, list]) => (
<Panel key={kind} title={KIND_LABEL[kind]}>
<table className="tbl">
<tbody>
{list.map((d) => (
<tr key={d.path}>
<td className="w-full">
<button onClick={() => open(d)} className="text-left">
<span className="font-medium text-ink hover:text-brand">{d.title}</span>
<span className="block text-[12px] text-muted">Updated {fmtDateTime(d.updatedAt)}</span>
</button>
</td>
<td>
{d.audience === "client" && <Badge tone="ok">For client</Badge>}
{d.audience === "internal" && <Badge tone="bad">Internal only</Badge>}
</td>
<td className="whitespace-nowrap">
{d.pdf ? (
<span className={cx("text-[12px]", d.pdf.stale ? "text-warn" : "text-muted")}>{d.pdf.stale ? "PDF out of date" : "PDF ready"}</span>
) : <span className="text-[12px] text-faint">no PDF</span>}
</td>
<td>
<div className="flex gap-1.5 justify-end">
<Button size="sm" variant="ghost" icon={Eye} onClick={() => open(d)}>Preview</Button>
{d.pdf && !d.pdf.stale ? (
<a href={fileUrl(slug, d.path.replace(/\.md$/, ".pdf"))} target="_blank" rel="noopener">
<Button size="sm" icon={FileDown}>Open PDF</Button>
</a>
) : (
kind !== "setup" && <Button size="sm" icon={FileDown} loading={busy === d.path} onClick={() => exportPdf(d)}>Export PDF</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</Panel>
))}
<Dialog
open={Boolean(preview)}
onClose={() => setPreview(null)}
title={preview?.doc.title}
wide
footer={
preview && preview.doc.kind !== "setup" && (
<>
<a href={fileUrl(slug, preview.doc.path, true)}><Button>Download .md</Button></a>
<Button variant="primary" icon={FileDown} loading={busy === preview.doc.path} onClick={() => exportPdf(preview.doc)}>Export branded PDF</Button>
</>
)
}
>
{preview?.loading ? <Spinner /> : preview && (
<>
{preview.meta?.client && (
<div className="rounded-lg bg-[#232327] text-white px-5 py-4 mb-4">
<div className="text-[11px] font-bold tracking-[0.12em] uppercase text-sky">{preview.meta.report}</div>
<div className="font-display text-[20px] font-extrabold mt-1">{preview.meta.client}</div>
<div className="text-[12.5px] text-[#c9ccd3] mt-1">{[preview.meta.period, preview.meta.accounts].filter(Boolean).join(" · ")}</div>
</div>
)}
{preview.meta?.kpis?.length > 0 && (
<div className="grid grid-cols-4 gap-2.5 mb-5">
{preview.meta.kpis.map((k, i) => (
<div key={i} className="border border-line rounded-lg px-3 py-2.5 border-t-[3px] border-t-brand">
<div className="font-display text-[19px] font-extrabold num">{k.value}</div>
<div className="text-[12px] font-medium text-ink">{k.label}</div>
{k.note && <div className="text-[11.5px] text-muted">{k.note}</div>}
</div>
))}
</div>
)}
<article className="doc" dangerouslySetInnerHTML={{ __html: preview.html }} />
</>
)}
</Dialog>
</div>
);
}
const fileUrl = (slug, path, download) => `/api/clients/${slug}/file?path=${encodeURIComponent(path)}${download ? "&download=1" : ""}`;
// ---------------------------------------------------------------- runs
function ClientRuns({ jobs }) {
const nav = useNavigate();
if (!jobs.length) return <Panel><Empty icon={Sparkles} title="No AI runs for this client yet">Use “Run with Claude” to start an audit, weekly plan or report.</Empty></Panel>;
return (
<Panel bodyClass="overflow-x-auto">
<table className="tbl">
<thead><tr><th>Run</th><th>Status</th><th>Started</th><th>By</th><th className="text-right">Cost</th></tr></thead>
<tbody>
{jobs.map((j) => (
<tr key={j.id} className="clickable" onClick={() => nav(`/runs/${j.id}`)}>
<td className="font-medium">{j.label}</td>
<td><Badge tone={STATUS_TONE[j.status]} dot>{j.status}</Badge></td>
<td className="text-ink-2">{fmtDateTime(j.startedAt || j.createdAt)}</td>
<td className="text-ink-2">{j.createdBy}</td>
<td className="text-right num text-ink-2">{j.costUsd != null ? `$${j.costUsd.toFixed(2)}` : "—"}</td>
</tr>
))}
</tbody>
</table>
</Panel>
);
}
export function RunDialog({ open, onClose, client, onStarted }) {
const [type, setType] = useState("account-audit");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const start = async () => {
setBusy(true);
setError(null);
try {
const r = await api("/api/jobs", { method: "POST", body: { type, slug: client.slug } });
onClose();
onStarted(r.job);
} catch (e) {
setError(e);
} finally {
setBusy(false);
}
};
return (
<Dialog
open={open}
onClose={onClose}
title={`Run with Claude · ${client.name}`}
footer={<><Button onClick={onClose}>Cancel</Button><Button variant="primary" icon={Sparkles} loading={busy} onClick={start}>Start run</Button></>}
>
<div className="space-y-2">
{RUNS.map((r) => (
<button
key={r.type}
onClick={() => setType(r.type)}
className={cx("w-full flex items-start gap-3 text-left rounded-lg border px-3.5 py-3 transition-colors", type === r.type ? "border-brand bg-brand-soft" : "border-line hover:bg-[#f8fafc]")}
>
<r.icon className={cx("size-4 mt-0.5", type === r.type ? "text-brand" : "text-muted")} strokeWidth={2} />
<span>
<span className="block font-medium">{r.label}</span>
<span className="block text-[12.5px] text-muted">{r.hint}</span>
</span>
</button>
))}
</div>
<p className="text-[12.5px] text-muted mt-4">
Claude reads the client's brand files, playbooks and live numbers, writes the documents into the client's folder and exports the PDFs.
It never publishes or changes Odoo. Runs go one at a time and usually take 3–10 minutes.
</p>
<ErrorNote error={error} className="mt-3" />
</Dialog>
);
}