diff --git a/dashboard/server/index.js b/dashboard/server/index.js index ed0436b..371062c 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -445,6 +445,31 @@ app.post("/api/library/:id/review", requireAdmin, h(async (req) => { await odoo.taskActivity(item.taskRef, { note: ok ? `

Approved by ${esc(who(req))}: ${esc(item.title)}.

` : `

Changes requested by ${esc(who(req))}: ${esc(item.reviewNote)}

`, 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) => { diff --git a/dashboard/web/src/pages/Library.jsx b/dashboard/web/src/pages/Library.jsx index 91d58cc..bb065e9 100644 --- a/dashboard/web/src/pages/Library.jsx +++ b/dashboard/web/src/pages/Library.jsx @@ -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() { ))} + {canBulk && } + {tab === "approved" && !client && Pick a client to schedule several at once.} {!data ? : !items.length ? ( @@ -70,7 +75,12 @@ export default function Library() { ) : (
{items.map((i) => ( -
+
+ {canBulk && ( + + )}
@@ -96,6 +106,7 @@ export default function Library() { {dialog?.kind === "upload" && setDialog(null)} onDone={() => { setTab("submitted"); done(); }} />} {dialog?.kind === "version" && setDialog(null)} onDone={done} />} {dialog?.kind === "review" && setDialog(null)} onDone={done} />} + {dialog?.kind === "bulk" && picked.includes(i.id))} onClose={() => setDialog(null)} onDone={() => { setPicked([]); done(); }} />} {dialog?.kind === "schedule" && setDialog(null)} onDone={done} />}
); @@ -268,3 +279,54 @@ function ScheduleDialog({ item, clientName, onClose, onDone }) { ); } + +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 ( + Done : <>}> + {result ? ( +
+

{result.created.length} draft{result.created.length === 1 ? "" : "s"} created. They're in Publishing, waiting for approval.

+
    {result.created.map((c) =>
  • {c.localTime} · {c.title}{c.warnings?.length ? · {c.warnings.length} warning{c.warnings.length === 1 ? "" : "s"} : null}
  • )}
+ {result.errors.map((e) =>
{e.title}: {e.error}
)} +
+ ) : ( +
+
+ set("startDate", e.target.value)} /> + + set("times", e.target.value)} /> +
+ +
+ {usable.length ? usable.map((p) => ( + + )) : No connected platforms for this client.} +
+
+

Items with their own planned date keep it. Each draft uses the item's caption idea; edit captions in Publishing before approving.

+ +
+ )} +
+ ); +}