- 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>
572 lines
28 KiB
JavaScript
572 lines
28 KiB
JavaScript
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>
|
||
);
|
||
}
|