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>
This commit is contained in:
parent
3f800d46ad
commit
aa56d2b531
@ -65,7 +65,7 @@ export function listDocs(slug) {
|
||||
for (const [kind, sub] of Object.entries(DOC_DIRS)) {
|
||||
const d = path.join(dir, sub);
|
||||
if (!fs.existsSync(d)) continue;
|
||||
for (const f of fs.readdirSync(d)) if (f.endsWith(".md")) add(path.join(sub, f), kind === "reports" ? "report" : "calendar");
|
||||
for (const f of fs.readdirSync(d)) if (f.endsWith(".md")) add(path.join(sub, f), kind === "reports" ? (/-monthly\.md$/.test(f) ? "monthly" : "report") : "calendar");
|
||||
}
|
||||
for (const f of ["brand.md", "goals.md", "constraints.md", "history.md"]) if (fs.existsSync(path.join(dir, f))) add(f, "setup");
|
||||
return docs.sort((a, b) => (a.kind === "setup") - (b.kind === "setup") || b.updatedAt.localeCompare(a.updatedAt));
|
||||
|
||||
@ -12,6 +12,7 @@ import * as published from "./published.js";
|
||||
import * as portal from "./portal.js";
|
||||
import * as notify from "./notify.js";
|
||||
import * as notifyEvents from "./notify-events.js";
|
||||
import * as monthly from "./monthly.js";
|
||||
import * as odoo from "./odoo.js";
|
||||
import * as jobs from "./jobs.js";
|
||||
import * as docs from "./docs.js";
|
||||
@ -322,6 +323,12 @@ app.get("/api/schedule/media", h(async (req, res) => {
|
||||
}));
|
||||
|
||||
|
||||
// ---------- monthly client report ----------
|
||||
app.get("/api/clients/:slug/monthly", h(async (req) => ({ settings: monthly.settingsFor(req.params.slug), lastMonth: monthly.lastMonth(), email: notify.emailConfigured() })));
|
||||
app.put("/api/clients/:slug/monthly", requireAdmin, h(async (req) => ({ settings: monthly.setSettings(req.params.slug, req.body || {}) })));
|
||||
app.post("/api/clients/:slug/monthly/build", h(async (req) => monthly.buildWithPdf(req.params.slug, String(req.body?.month || monthly.lastMonth()))));
|
||||
app.post("/api/clients/:slug/monthly/email", requireAdmin, h(async (req) => monthly.emailReport(req.params.slug, String(req.body?.month || monthly.lastMonth()))));
|
||||
|
||||
// ---------- notifications ----------
|
||||
app.get("/api/notifications", h(async (req) => notify.listFor(req.user.id)));
|
||||
app.post("/api/notifications/read", h(async (req) => { notify.markRead(req.user.id, Array.isArray(req.body?.ids) ? req.body.ids : null); return notify.listFor(req.user.id); }));
|
||||
@ -442,6 +449,7 @@ function scheduleNightly() {
|
||||
}
|
||||
if (process.env.DASHBOARD_NIGHTLY_REFRESH !== "false") scheduleNightly();
|
||||
notifyEvents.start();
|
||||
if (process.env.DASHBOARD_NIGHTLY_REFRESH !== "false") monthly.startSchedule();
|
||||
|
||||
// Publish approved posts when they're due (each client's own time zone). Every 2 minutes.
|
||||
if (process.env.SCHEDULER_ENABLED !== "false") {
|
||||
|
||||
193
dashboard/server/monthly.js
Normal file
193
dashboard/server/monthly.js
Normal file
@ -0,0 +1,193 @@
|
||||
// Monthly client report, built from real numbers (no AI needed): posts and results for the month, follower change,
|
||||
// best posts and formats, how the client compares with tracked competitors, and next month's plan.
|
||||
// Written as clients/<slug>/reports/YYYY-MM-monthly.md, exported to the branded PDF, optionally emailed to the client.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DATA_DIR, AGENCY_NAME } from "./config.js";
|
||||
import * as clients from "./clients.js";
|
||||
import * as metrics from "./metrics.js";
|
||||
import * as published from "./published.js";
|
||||
import * as portal from "./portal.js";
|
||||
import * as notify from "./notify.js";
|
||||
import * as plan from "../../connector/src/plan.js";
|
||||
import * as competitors from "../../connector/src/competitors.js";
|
||||
import { toPdf } from "../../tools/report-pdf/build.mjs";
|
||||
|
||||
const SETTINGS = path.join(DATA_DIR, "monthly.json");
|
||||
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return d; } };
|
||||
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
||||
const nf = new Intl.NumberFormat("en-CA");
|
||||
const n = (v) => (v == null ? "—" : nf.format(v));
|
||||
const PL = { instagram: "Instagram", facebook: "Facebook", tiktok: "TikTok", youtube: "YouTube", gbp: "Google Business Profile", linkedin: "LinkedIn", pinterest: "Pinterest", threads: "Threads" };
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
export function settingsFor(slug) { return { auto: false, email: false, ...(readJson(SETTINGS, {})[slug] || {}) }; }
|
||||
export function setSettings(slug, patch) {
|
||||
clients.safeSlug(slug);
|
||||
const all = readJson(SETTINGS, {});
|
||||
all[slug] = { ...settingsFor(slug), ...(patch.auto !== undefined ? { auto: Boolean(patch.auto) } : {}), ...(patch.email !== undefined ? { email: Boolean(patch.email) } : {}) };
|
||||
fs.writeFileSync(SETTINGS, JSON.stringify(all, null, 1));
|
||||
return all[slug];
|
||||
}
|
||||
|
||||
const monthLabel = (m) => new Date(`${m}-15T12:00:00Z`).toLocaleDateString("en-CA", { month: "long", year: "numeric", timeZone: "UTC" });
|
||||
const lastMonth = () => { const d = new Date(); d.setUTCDate(1); d.setUTCMonth(d.getUTCMonth() - 1); return d.toISOString().slice(0, 7); };
|
||||
const nextMonthOf = (m) => { const d = new Date(`${m}-15T12:00:00Z`); d.setUTCMonth(d.getUTCMonth() + 1); return d.toISOString().slice(0, 7); };
|
||||
const endOf = (m) => { const d = new Date(`${m}-01T12:00:00Z`); d.setUTCMonth(d.getUTCMonth() + 1); d.setUTCDate(0); return d.toISOString().slice(0, 10); };
|
||||
const day = (iso) => new Date(`${String(iso).slice(0, 10)}T12:00:00Z`).toLocaleDateString("en-CA", { month: "short", day: "numeric", timeZone: "UTC" });
|
||||
const clean = (s, max = 110) => { const t = String(s || "").replace(/\s+/g, " ").replace(/[|]/g, "/").trim(); return t.length > max ? `${t.slice(0, max - 1)}…` : t; };
|
||||
|
||||
/** Build the report for one client and month (YYYY-MM, default last month). Returns the .md path relative to the client folder. */
|
||||
export function build(slug, month = lastMonth()) {
|
||||
const dir = clients.safeSlug(slug);
|
||||
if (!/^\d{4}-\d{2}$/.test(month)) throw err("Month must be YYYY-MM.");
|
||||
const c = clients.getClient(slug);
|
||||
const first = `${month}-01`, last = endOf(month);
|
||||
const daysBack = Math.ceil((Date.now() - new Date(`${first}T00:00:00Z`)) / 86400000) + 1;
|
||||
const items = published.feed({ client: slug, days: daysBack }).items.filter((i) => i.at && i.at.slice(0, 10) >= first && i.at.slice(0, 10) <= last);
|
||||
const hist = metrics.readHistory(slug);
|
||||
const inMonth = hist.filter((h) => h.date >= first && h.date <= last);
|
||||
const before = [...hist].reverse().find((h) => h.date < first) || inMonth[0];
|
||||
const end = inMonth.at(-1);
|
||||
const ig0 = before?.igFollowers, ig1 = end?.igFollowers, fb0 = before?.fbFollowers, fb1 = end?.fbFollowers;
|
||||
const snap = metrics.readSnapshot(slug);
|
||||
|
||||
const reach = items.filter((i) => i.metrics.reach != null).reduce((s, i) => s + i.metrics.reach, 0);
|
||||
const inter = items.reduce((s, i) => s + (i.interactions || 0), 0);
|
||||
const byPlatform = {};
|
||||
for (const i of items) byPlatform[i.platform] = (byPlatform[i.platform] || 0) + 1;
|
||||
const top = [...items].filter((i) => i.interactions != null).sort((a, b) => b.interactions - a.interactions).slice(0, 3);
|
||||
const byFormat = {};
|
||||
for (const i of items.filter((x) => x.platform === "instagram")) (byFormat[i.format] ||= []).push(i.interactions || 0);
|
||||
const formatAvg = Object.entries(byFormat).map(([f, v]) => [f, Math.round(v.reduce((a, b) => a + b, 0) / v.length), v.length]).sort((a, b) => b[1] - a[1]);
|
||||
const byDay = {};
|
||||
for (const i of items) (byDay[new Date(i.at).getDay()] ||= []).push(i.interactions || 0);
|
||||
const bestDay = items.length < 6 ? null : Object.entries(byDay).filter(([, v]) => v.length >= 2).map(([d, v]) => [DAYS[d], v.reduce((a, b) => a + b, 0) / v.length]).sort((a, b) => b[1] - a[1])[0];
|
||||
|
||||
const own = snap?.ig ? { username: snap.ig.profile?.username, followers: snap.ig.profile?.followers_count, posts: snap.ig.posts || [], history: hist.map((h) => ({ date: h.date, followers: h.igFollowers })) } : null;
|
||||
const comp = competitors.compare(slug, own);
|
||||
const nm = nextMonthOf(month);
|
||||
const upcoming = plan.calendar({ client: slug, from: `${nm}-01`, to: endOf(nm) }).items;
|
||||
|
||||
const delta = (a, b) => (a == null || b == null ? null : b - a);
|
||||
const igD = delta(ig0, ig1), fbD = delta(fb0, fb1);
|
||||
const signed = (v) => (v == null ? "—" : `${v > 0 ? "+" : ""}${nf.format(v)}`);
|
||||
const accounts = [snap?.ig?.profile?.username && `Instagram @${snap.ig.profile.username}`, snap?.fb?.page?.name && `Facebook ${snap.fb.page.name}`].filter(Boolean).join(" · ");
|
||||
|
||||
const kpis = [
|
||||
`${n(items.length)} | Posts published | ${Object.entries(byPlatform).map(([p, k]) => `${k} ${PL[p] || p}`).join(", ") || "none this month"}`,
|
||||
`${n(reach)} | Accounts reached on Instagram | summed across this month's posts`,
|
||||
`${n(inter)} | Interactions | likes, comments, saves and shares`,
|
||||
`${signed(igD)} | Instagram followers | ${ig1 != null ? `${n(ig1)} at month end` : "no data yet"}`,
|
||||
];
|
||||
const lines = [];
|
||||
lines.push("---", `client: ${c?.name || slug}`, `report: Monthly social media report · ${monthLabel(month)}`, `period: ${day(first)} – ${day(last)}, ${month.slice(0, 4)}`);
|
||||
if (accounts) lines.push(`accounts: ${accounts}`);
|
||||
lines.push("kpis:", ...kpis.map((k) => ` - ${k}`), "---", "");
|
||||
|
||||
lines.push("## The month in short", "");
|
||||
const short = [];
|
||||
short.push(items.length ? `We published ${items.length} post${items.length === 1 ? "" : "s"}${Object.keys(byPlatform).length > 1 ? ` across ${Object.keys(byPlatform).length} platforms` : ""}.` : "No posts went out this month.");
|
||||
const fol = (k) => `${nf.format(k)} follower${k === 1 ? "" : "s"}`;
|
||||
if (igD != null) short.push(igD > 0 ? `Instagram grew by ${fol(igD)}.` : igD < 0 ? `Instagram lost ${fol(-igD)}.` : "Instagram followers held steady.");
|
||||
if (fbD != null && fbD !== 0) short.push(`Facebook ${fbD > 0 ? "gained" : "lost"} ${nf.format(Math.abs(fbD))} follower${Math.abs(fbD) === 1 ? "" : "s"}.`);
|
||||
if (formatAvg.filter(([, , k]) => k >= 2).length > 1 && formatAvg[0][2] >= 2) short.push(`${formatAvg[0][0]}s did best on Instagram, with about ${nf.format(formatAvg[0][1])} interactions each.`);
|
||||
if (bestDay) short.push(`${bestDay[0]} posts got the most engagement.`);
|
||||
lines.push(short.join(" "), "");
|
||||
|
||||
if (top.length) {
|
||||
lines.push("## Best posts", "", "| Date | Platform | Post | Reach | Interactions |", "|---|---|---|---|---|");
|
||||
for (const t of top) lines.push(`| ${day(t.at)} | ${PL[t.platform] || t.platform} | ${t.permalink ? `[${clean(t.caption, 70) || "View post"}](${t.permalink})` : clean(t.caption, 70)} | ${n(t.metrics.reach)} | ${n(t.interactions)} |`);
|
||||
lines.push("");
|
||||
}
|
||||
if (formatAvg.length) {
|
||||
lines.push("## What worked on Instagram", "", "| Format | Posts | Average interactions |", "|---|---|---|");
|
||||
for (const [f, avg, k] of formatAvg) lines.push(`| ${f} | ${k} | ${n(avg)} |`);
|
||||
lines.push("");
|
||||
}
|
||||
if (items.length) {
|
||||
lines.push("## Every post this month", "", "| Date | Platform | Format | Reach | Likes | Comments | Interactions |", "|---|---|---|---|---|---|---|");
|
||||
for (const i of [...items].sort((a, b) => a.at.localeCompare(b.at))) lines.push(`| ${day(i.at)} | ${PL[i.platform] || i.platform} | ${i.format} | ${n(i.metrics.reach)} | ${n(i.metrics.likes)} | ${n(i.metrics.comments)} | ${n(i.interactions)} |`);
|
||||
lines.push("");
|
||||
}
|
||||
if (comp.competitors.length) {
|
||||
lines.push("## How you compare", "", "Engagement rate is a typical post's likes and comments divided by followers, so accounts of different sizes compare fairly.", "",
|
||||
"| Account | Followers | Posts / week | Typical post | Engagement rate |", "|---|---|---|---|---|");
|
||||
for (const r of [...(comp.you ? [{ ...comp.you, username: comp.you.username || "you" }] : []), ...comp.competitors]) {
|
||||
lines.push(`| ${r.own ? "**" : ""}@${r.username}${r.own ? " (you)**" : ""} | ${n(r.followers)} | ${r.postsPerWeek ?? "—"} | ${n(r.avgEngagement)} | ${r.engagementRate == null ? "—" : `${r.engagementRate}%`} |`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(`## Coming up in ${monthLabel(nm)}`, "");
|
||||
if (upcoming.length) {
|
||||
for (const u of upcoming.slice(0, 10)) lines.push(`- ${day(u.date)}: ${clean(u.title, 90)} (${u.stage === "published" ? "published" : u.stage === "scheduled" ? "scheduled" : "in planning"})`);
|
||||
if (upcoming.length > 10) lines.push(`- …and ${upcoming.length - 10} more`);
|
||||
} else lines.push("We'll share next month's plan with you for approval.");
|
||||
lines.push("", `*Prepared by ${AGENCY_NAME}. Numbers come from Instagram and Facebook insights; reach isn't available for every platform.*`, "");
|
||||
|
||||
const reportsDir = path.join(dir, "reports");
|
||||
fs.mkdirSync(reportsDir, { recursive: true });
|
||||
const file = path.join(reportsDir, `${month}-monthly.md`);
|
||||
fs.writeFileSync(file, lines.join("\n"));
|
||||
return path.relative(dir, file).replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
/** Build and export the PDF. Returns { md, pdf } relative paths. */
|
||||
export function buildWithPdf(slug, month) {
|
||||
const md = build(slug, month);
|
||||
const dir = clients.safeSlug(slug);
|
||||
const pdf = toPdf(path.join(dir, md));
|
||||
return { md, pdf: path.relative(dir, pdf).replace(/\\/g, "/") };
|
||||
}
|
||||
|
||||
/** Email the month's PDF to the reviewer saved on the client's approval link. */
|
||||
export async function emailReport(slug, month = lastMonth()) {
|
||||
const dir = clients.safeSlug(slug);
|
||||
const link = portal.linkFor(slug);
|
||||
if (!link.enabled || !link.contactEmail) throw err("Add the client's email first (client page → Client approval → Who reviews).");
|
||||
const pdf = path.join(dir, "reports", `${month}-monthly.pdf`);
|
||||
if (!fs.existsSync(pdf)) throw err("Create the report first.");
|
||||
const c = clients.getClient(slug);
|
||||
const r = await notify.sendEmail({
|
||||
to: link.contactEmail,
|
||||
subject: `${c?.name || "Your"} social media report · ${monthLabel(month)}`,
|
||||
heading: `Your ${monthLabel(month)} social media report`,
|
||||
lines: [`Hi${link.contactName ? ` ${link.contactName}` : ""}, attached is how ${c?.name || "your accounts"} did in ${monthLabel(month)}: what we posted, what worked, how you compare with competitors, and what's coming next month.`, "Reply to this email with any questions."],
|
||||
fromName: AGENCY_NAME,
|
||||
attachments: [{ filename: `${(c?.name || slug).replace(/[^\w-]+/g, "-")}-${month}-report.pdf`, path: pdf }],
|
||||
});
|
||||
if (!r.sent) throw err(`Couldn't send: ${r.reason}`);
|
||||
return { sent: true, to: link.contactEmail };
|
||||
}
|
||||
|
||||
/** 1st of the month, 07:00: build last month's report for clients with auto on; email it where email is on. */
|
||||
export async function runMonthly() {
|
||||
const month = lastMonth();
|
||||
const done = [];
|
||||
for (const c of clients.listClients()) {
|
||||
const s = settingsFor(c.slug);
|
||||
if (!s.auto) continue;
|
||||
try {
|
||||
buildWithPdf(c.slug, month);
|
||||
let sent = false;
|
||||
if (s.email) { try { sent = (await emailReport(c.slug, month)).sent; } catch (e) { console.error("Monthly email", c.slug, e.message); } }
|
||||
done.push(c.slug);
|
||||
await notify.notify({ type: "monthly_report", level: "ok", title: `${c.name}: ${monthLabel(month)} report ready${sent ? " and emailed" : ""}`, link: `/clients/${c.slug}`, client: c.slug, to: "admins", email: false });
|
||||
} catch (e) {
|
||||
await notify.notify({ type: "monthly_report", level: "bad", title: `${c.name}: monthly report failed`, body: e.message, link: `/clients/${c.slug}`, client: c.slug, to: "admins" });
|
||||
}
|
||||
}
|
||||
return { month, done };
|
||||
}
|
||||
|
||||
export function startSchedule() {
|
||||
const tick = () => {
|
||||
const next = new Date(); next.setDate(1); next.setHours(7, 0, 0, 0);
|
||||
if (next <= new Date()) { next.setMonth(next.getMonth() + 1); }
|
||||
const wait = Math.min(next - new Date(), 2 ** 31 - 1); // setTimeout limit (~24 days); re-check then
|
||||
setTimeout(async () => { if (new Date().getDate() === 1 && new Date().getHours() >= 7) { try { console.log("Monthly reports:", await runMonthly()); } catch (e) { console.error("Monthly reports failed", e); } } tick(); }, wait);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
export { lastMonth };
|
||||
@ -39,7 +39,7 @@ ${button ? `<p style="margin:18px 0 4px"><a href="${esc(button.url)}" style="dis
|
||||
}
|
||||
|
||||
/** Send one email. Returns { sent } or { sent:false, reason }. Never throws (notifications mustn't break work). */
|
||||
export async function sendEmail({ to, subject, heading, lines, button, footer, fromName, replyTo }) {
|
||||
export async function sendEmail({ to, subject, heading, lines, button, footer, fromName, replyTo, attachments }) {
|
||||
const m = mailer();
|
||||
if (!m) return { sent: false, reason: "Email isn't set up (SMTP_HOST in .env)." };
|
||||
if (!to) return { sent: false, reason: "No recipient." };
|
||||
@ -48,6 +48,7 @@ export async function sendEmail({ to, subject, heading, lines, button, footer, f
|
||||
from: { name: fromName || AGENCY_NAME, address: fromAddress() }, to, subject, replyTo,
|
||||
text: [heading, "", ...lines, ...(button ? ["", `${button.label}: ${button.url}`] : [])].join("\n"),
|
||||
html: html({ heading, lines, button, footer }),
|
||||
attachments,
|
||||
});
|
||||
return { sent: true };
|
||||
} catch (e) {
|
||||
|
||||
@ -4,6 +4,7 @@ import { Bar, BarChart, CartesianGrid, Cell, Area, AreaChart, ResponsiveContaine
|
||||
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,
|
||||
@ -112,7 +113,7 @@ export default function Client() {
|
||||
</Empty>
|
||||
</Panel>
|
||||
))}
|
||||
{tab === "docs" && <Documents slug={slug} docs={data.docs} reload={load} />}
|
||||
{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} />}
|
||||
|
||||
@ -377,7 +378,7 @@ function FormatBars({ d }) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- documents
|
||||
const KIND_LABEL = { audit: "Audits", report: "Weekly reports", calendar: "Content calendars", "profile-check": "Profile checks", setup: "Client setup files" };
|
||||
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);
|
||||
|
||||
44
dashboard/web/src/pages/MonthlyReport.jsx
Normal file
44
dashboard/web/src/pages/MonthlyReport.jsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FileBarChart, Mail } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { useAuth } from "../App.jsx";
|
||||
import { Button, ErrorNote, Field, Panel } from "../ui.jsx";
|
||||
|
||||
/** Client page → Documents: build the monthly report from real numbers, email it, or have it done automatically. */
|
||||
export default function MonthlyReport({ slug, onBuilt }) {
|
||||
const { isAdmin } = useAuth();
|
||||
const [info, setInfo] = useState(null);
|
||||
const [month, setMonth] = useState("");
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [msg, setMsg] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => { api(`/api/clients/${slug}/monthly`).then((r) => { setInfo(r); setMonth(r.lastMonth); }).catch(setError); }, [slug]);
|
||||
if (!info) return null;
|
||||
|
||||
const run = async (what, fn) => { setBusy(what); setError(null); setMsg(null); try { await fn(); } catch (e) { setError(e); } setBusy(null); };
|
||||
const build = () => run("build", async () => { const r = await api(`/api/clients/${slug}/monthly/build`, { method: "POST", body: { month } }); setMsg(`Created ${r.pdf}. It's in the list below.`); onBuilt?.(r); });
|
||||
const email = () => run("email", async () => { const r = await api(`/api/clients/${slug}/monthly/email`, { method: "POST", body: { month } }); setMsg(`Emailed to ${r.to}.`); });
|
||||
const toggle = (k) => run(k, async () => { const r = await api(`/api/clients/${slug}/monthly`, { method: "PUT", body: { [k]: !info.settings[k] } }); setInfo({ ...info, settings: r.settings }); });
|
||||
|
||||
return (
|
||||
<Panel title="Monthly report" subtitle="Built from the client's real numbers: posts and results, follower change, best posts, competitors and next month's plan. Exported as the branded PDF.">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Field label="Month"><input id="mr-month" type="month" className="input w-auto" value={month} onChange={(e) => setMonth(e.target.value)} /></Field>
|
||||
<Button icon={FileBarChart} loading={busy === "build"} onClick={build} disabled={!month}>Create report</Button>
|
||||
{isAdmin && <Button icon={Mail} loading={busy === "email"} onClick={email} disabled={!month || !info.email}>Email to client</Button>}
|
||||
</div>
|
||||
{!info.email && isAdmin && <p className="text-[12.5px] text-muted">Emailing needs SMTP set up on the server (see Settings → Notifications).</p>}
|
||||
{isAdmin && (
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 text-[13px]">
|
||||
<label htmlFor="mr-auto" className="flex items-center gap-2"><input id="mr-auto" type="checkbox" checked={info.settings.auto} disabled={busy === "auto"} onChange={() => toggle("auto")} />Create it automatically on the 1st of each month</label>
|
||||
<label htmlFor="mr-email" className="flex items-center gap-2"><input id="mr-email" type="checkbox" checked={info.settings.email} disabled={!info.settings.auto || busy === "email"} onChange={() => toggle("email")} />…and email it to the client</label>
|
||||
</div>
|
||||
)}
|
||||
{msg && <div className="text-[12.5px] text-ok">{msg}</div>}
|
||||
<ErrorNote error={error} />
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user