Bulk scheduling from the content library

- Approved tab with a client selected: tick items, 'Schedule N selected'
- Start date, one post every 1/2/3/4/7 days, times used in turn (pre-filled with the client's best times)
- Items with their own planned date keep it; creates drafts only, each still needs approval
- Platforms needing per-post details (YouTube, TikTok, Pinterest, Reddit) stay one at a time

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-23 10:22:15 -04:00
parent 444201e4ec
commit 958ec9a1b5
2 changed files with 89 additions and 2 deletions

View File

@ -445,6 +445,31 @@ app.post("/api/library/:id/review", requireAdmin, h(async (req) => {
await odoo.taskActivity(item.taskRef, { note: ok ? `<p>Approved by ${esc(who(req))}: <b>${esc(item.title)}</b>.</p>` : `<p>Changes requested by ${esc(who(req))}: ${esc(item.reviewNote)}</p>`, stageMatch: ok ? "done" : "progress" });
return { item };
}));
// Schedule several approved items at once: one per slot (start date, every N days, cycling through the times).
// Items with their own planned date keep it. Creates drafts only; each still needs approval.
app.post("/api/library/bulk-schedule", h(async (req) => {
const { client, ids = [], startDate, everyDays = 1, times = [], platforms = [], options = {} } = req.body || {};
clients.safeSlug(client);
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(startDate))) throw Object.assign(new Error("Pick a start date."), { status: 400 });
const slots = (times.length ? times : ["10:00"]).filter((t) => /^\d{2}:\d{2}$/.test(t));
if (!slots.length) throw Object.assign(new Error("Times must be like 10:00."), { status: 400 });
if (!platforms.length) throw Object.assign(new Error("Choose at least one platform."), { status: 400 });
const step = Math.min(Math.max(Number(everyDays) || 1, 1), 14);
const items = ids.map((id) => lib.getItem(id)).filter((i) => i.client === client && i.status === "approved")
.sort((a, b) => (a.forDate || "9999").localeCompare(b.forDate || "9999") || a.createdAt.localeCompare(b.createdAt));
const day = (iso, n) => { const d = new Date(`${iso}T12:00:00Z`); d.setUTCDate(d.getUTCDate() + n); return d.toISOString().slice(0, 10); };
const created = [], errors = [];
let k = 0;
for (const i of items) {
const own = i.forDate && i.forDate >= startDate;
const date = own ? i.forDate : day(startDate, k * step);
const time = slots[(own ? created.length : k) % slots.length];
if (!own) k += 1;
try { const post = lib.scheduleItem(i.id, { platforms, localTime: `${date} ${time}`, options }, who(req)); created.push({ id: i.id, title: i.title, localTime: post.localTime, warnings: post.warnings }); }
catch (e) { errors.push({ id: i.id, title: i.title, error: e.message }); }
}
return { created, errors };
}));
app.post("/api/library/:id/schedule", h(async (req) => ({ post: lib.scheduleItem(req.params.id, req.body || {}, who(req)), item: lib.getItem(req.params.id) })));
app.post("/api/library/:id/archive", h(async (req) => ({ item: lib.archiveItem(req.params.id, who(req)) })));
app.get("/api/library/file", h(async (req, res) => {

View File

@ -32,7 +32,10 @@ export default function Library() {
const [tab, setTab] = useState(isAdmin ? "submitted" : "changes");
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [dialog, setDialog] = useState(null); // {kind:'upload'} | {kind:'review'|'schedule'|'version', item}
const [dialog, setDialog] = useState(null); // {kind:'upload'} | {kind:'review'|'schedule'|'version', item} | {kind:'bulk'}
const [picked, setPicked] = useState([]);
const canBulk = tab === "approved" && Boolean(client);
useEffect(() => { setPicked([]); }, [client, tab]);
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(setError); }, []);
const load = useCallback(() => api(`/api/library${client ? `?client=${client}` : ""}`).then(setData).catch(setError), [client]);
@ -63,6 +66,8 @@ export default function Library() {
</button>
))}
<Button variant="ghost" size="sm" icon={RefreshCw} onClick={load}>Refresh</Button>
{canBulk && <Button size="sm" variant="primary" icon={CalendarPlus} disabled={!picked.length} onClick={() => setDialog({ kind: "bulk" })} className="ml-auto">Schedule {picked.length || ""} selected</Button>}
{tab === "approved" && !client && <span className="text-[12px] text-muted ml-auto">Pick a client to schedule several at once.</span>}
</div>
<ErrorNote error={error} />
{!data ? <Spinner /> : !items.length ? (
@ -70,7 +75,12 @@ export default function Library() {
) : (
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
{items.map((i) => (
<div key={i.id} className="panel overflow-hidden flex flex-col">
<div key={i.id} className={cx("panel overflow-hidden flex flex-col relative", picked.includes(i.id) && "ring-2 ring-brand")}>
{canBulk && (
<label className="absolute top-2 left-2 z-10 bg-white/95 rounded px-2 py-1 text-[12px] flex items-center gap-1.5 shadow-sm">
<input type="checkbox" checked={picked.includes(i.id)} onChange={() => setPicked((xs) => (xs.includes(i.id) ? xs.filter((x) => x !== i.id) : [...xs, i.id]))} aria-label={`Select ${i.title}`} />Select
</label>
)}
<Preview item={i} />
<div className="p-3.5 flex-1 flex flex-col gap-1.5">
<div className="flex items-center gap-2 flex-wrap">
@ -96,6 +106,7 @@ export default function Library() {
{dialog?.kind === "upload" && <UploadDialog clients={clients} defaultClient={client} onClose={() => setDialog(null)} onDone={() => { setTab("submitted"); done(); }} />}
{dialog?.kind === "version" && <VersionDialog item={dialog.item} onClose={() => setDialog(null)} onDone={done} />}
{dialog?.kind === "review" && <ReviewDialog item={dialog.item} onClose={() => setDialog(null)} onDone={done} />}
{dialog?.kind === "bulk" && <BulkDialog client={client} items={(data?.items || []).filter((i) => picked.includes(i.id))} onClose={() => setDialog(null)} onDone={() => { setPicked([]); done(); }} />}
{dialog?.kind === "schedule" && <ScheduleDialog item={dialog.item} clientName={name(dialog.item.client)} onClose={() => setDialog(null)} onDone={done} />}
</div>
);
@ -268,3 +279,54 @@ function ScheduleDialog({ item, clientName, onClose, onDone }) {
</Dialog>
);
}
const BULK_PLATFORMS = { instagram: "Instagram", facebook: "Facebook", gbp: "Google Business", linkedin: "LinkedIn", threads: "Threads" };
/** Schedule several approved items: one per slot from a start date, every N days, cycling through the times. Drafts only. */
function BulkDialog({ client, items, onClose, onDone }) {
const available = useClientPlatforms(client);
const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
const [f, setF] = useState({ startDate: tomorrow, everyDays: 2, times: "", platforms: [] });
const [busy, setBusy] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
useEffect(() => { api(`/api/schedule/suggest?client=${client}`).then((r) => set("times", r.times.join(", "))).catch(() => set("times", "10:00")); }, [client]);
const usable = (available.list || []).filter((p) => BULK_PLATFORMS[p.id]);
useEffect(() => { if (!available.loading) set("platforms", usable.filter((p) => ["instagram", "facebook"].includes(p.id)).map((p) => p.id)); }, [available.loading]); // eslint-disable-line react-hooks/exhaustive-deps
const go = async () => {
setBusy(true); setError(null);
try { setResult(await api("/api/library/bulk-schedule", { method: "POST", body: { client, ids: items.map((i) => i.id), startDate: f.startDate, everyDays: Number(f.everyDays), times: f.times.split(/[\s,]+/).filter(Boolean), platforms: f.platforms } })); }
catch (e) { setError(e); }
setBusy(false);
};
return (
<Dialog open onClose={result ? onDone : onClose} title={`Schedule ${items.length} item${items.length === 1 ? "" : "s"}`} wide
footer={result ? <Button variant="primary" onClick={onDone}>Done</Button> : <><Button onClick={onClose}>Cancel</Button><Button variant="primary" loading={busy} disabled={!f.platforms.length || !f.startDate} onClick={go}>Create {items.length} draft{items.length === 1 ? "" : "s"}</Button></>}>
{result ? (
<div className="space-y-2 text-[13px]">
<p><b>{result.created.length}</b> draft{result.created.length === 1 ? "" : "s"} created. They're in Publishing, waiting for approval.</p>
<ul className="space-y-1">{result.created.map((c) => <li key={c.id}>{c.localTime} · {c.title}{c.warnings?.length ? <span className="text-warn"> · {c.warnings.length} warning{c.warnings.length === 1 ? "" : "s"}</span> : null}</li>)}</ul>
{result.errors.map((e) => <div key={e.id} className="text-bad">{e.title}: {e.error}</div>)}
</div>
) : (
<div className="space-y-4">
<div className="grid sm:grid-cols-3 gap-3">
<Field label="Start on"><input id="bulk-start" type="date" className="input" value={f.startDate} onChange={(e) => set("startDate", e.target.value)} /></Field>
<Field label="One post every"><select id="bulk-every" className="input" value={f.everyDays} onChange={(e) => set("everyDays", e.target.value)}>{[1, 2, 3, 4, 7].map((n) => <option key={n} value={n}>{n === 1 ? "day" : n === 7 ? "week" : `${n} days`}</option>)}</select></Field>
<Field label="At (client time)" hint="Comma-separated; used in turn. Pre-filled with this client's best times."><input id="bulk-times" className="input" value={f.times} onChange={(e) => set("times", e.target.value)} /></Field>
</div>
<Field label="Post to" hint="Platforms that need extra details per post (YouTube title, TikTok audience) are scheduled one at a time instead.">
<div className="flex flex-wrap gap-x-4 gap-y-1.5 text-[13px]">
{usable.length ? usable.map((p) => (
<label key={p.id} htmlFor={`bulk-pl-${p.id}`} className="flex items-center gap-1.5"><input id={`bulk-pl-${p.id}`} type="checkbox" checked={f.platforms.includes(p.id)} onChange={() => set("platforms", f.platforms.includes(p.id) ? f.platforms.filter((x) => x !== p.id) : [...f.platforms, p.id])} />{BULK_PLATFORMS[p.id]}</label>
)) : <span className="text-muted">No connected platforms for this client.</span>}
</div>
</Field>
<p className="text-[12.5px] text-muted">Items with their own planned date keep it. Each draft uses the item's caption idea; edit captions in Publishing before approving.</p>
<ErrorNote error={error} />
</div>
)}
</Dialog>
);
}