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:
parent
444201e4ec
commit
958ec9a1b5
@ -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" });
|
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 };
|
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/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.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) => {
|
app.get("/api/library/file", h(async (req, res) => {
|
||||||
|
|||||||
@ -32,7 +32,10 @@ export default function Library() {
|
|||||||
const [tab, setTab] = useState(isAdmin ? "submitted" : "changes");
|
const [tab, setTab] = useState(isAdmin ? "submitted" : "changes");
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [error, setError] = 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); }, []);
|
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]);
|
const load = useCallback(() => api(`/api/library${client ? `?client=${client}` : ""}`).then(setData).catch(setError), [client]);
|
||||||
@ -63,6 +66,8 @@ export default function Library() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<Button variant="ghost" size="sm" icon={RefreshCw} onClick={load}>Refresh</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>
|
</div>
|
||||||
<ErrorNote error={error} />
|
<ErrorNote error={error} />
|
||||||
{!data ? <Spinner /> : !items.length ? (
|
{!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))]">
|
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
|
||||||
{items.map((i) => (
|
{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} />
|
<Preview item={i} />
|
||||||
<div className="p-3.5 flex-1 flex flex-col gap-1.5">
|
<div className="p-3.5 flex-1 flex flex-col gap-1.5">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<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 === "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 === "version" && <VersionDialog item={dialog.item} onClose={() => setDialog(null)} onDone={done} />}
|
||||||
{dialog?.kind === "review" && <ReviewDialog 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} />}
|
{dialog?.kind === "schedule" && <ScheduleDialog item={dialog.item} clientName={name(dialog.item.client)} onClose={() => setDialog(null)} onDone={done} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -268,3 +279,54 @@ function ScheduleDialog({ item, clientName, onClose, onDone }) {
|
|||||||
</Dialog>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user