metatroncubeswdev 958ec9a1b5 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>
2026-10-23 10:22:15 -04:00

333 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useState } from "react";
import { Archive, CalendarPlus, Check, FolderUp, Image as ImageIcon, MessageSquareWarning, RefreshCw, Upload } from "lucide-react";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner, timeAgo } from "../ui.jsx";
import PlatformPicker, { defaultPlatforms, selectionFor, useClientPlatforms } from "../PlatformPicker.jsx";
const TONE = { submitted: "brand", changes: "warn", approved: "ok", scheduled: "neutral", archived: "neutral" };
const LABEL = { submitted: "To review", changes: "Changes requested", approved: "Ready to schedule", scheduled: "Scheduled", archived: "Archived" };
const FORMATS = [["IMAGE", "Single image"], ["REELS", "Reel (one video)"], ["CAROUSEL", "Carousel (2–10 files)"], ["STORY", "Story"]];
const fileUrl = (p) => `/api/library/file?path=${encodeURIComponent(p)}`;
const isVid = (p) => /\.(mp4|mov|m4v)$/i.test(p || "");
const baseName = (p) => String(p).split(/[\\/]/).pop().replace(/^\d+-\w{4}-/, "");
async function uploadFiles(client, files, onProgress) {
const paths = [];
for (const [n, file] of [...files].entries()) {
onProgress?.(`Uploading ${n + 1} of ${files.length}: ${file.name}`);
const res = await fetch("/api/library/upload", { method: "POST", credentials: "same-origin", headers: { "X-Client": client, "X-Filename": encodeURIComponent(file.name), "Content-Type": "application/octet-stream" }, body: file });
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `Upload failed (${file.name})`);
paths.push(j.path);
}
onProgress?.(null);
return paths;
}
export default function Library() {
const { isAdmin } = useAuth();
const [clients, setClients] = useState([]);
const [client, setClient] = useState("");
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} | {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]);
useEffect(() => { load(); }, [load]);
const items = useMemo(() => (data?.items || []).filter((i) => tab === "all" ? i.status !== "archived" : i.status === tab), [data, tab]);
const count = (s) => (data?.items || []).filter((i) => i.status === s).length;
const name = (slug) => clients.find((c) => c.slug === slug)?.name || slug;
const done = () => { setDialog(null); load(); };
return (
<div className="space-y-5">
<header className="flex items-end justify-between gap-4">
<div>
<h1 className="text-[22px] font-extrabold">Content library</h1>
<p className="text-muted mt-0.5">Designers and editors upload finished work here. Approved work is scheduled from here or picked up by the agent for the weekly plan.</p>
</div>
<Button variant="primary" icon={FolderUp} onClick={() => setDialog({ kind: "upload" })}>Upload work</Button>
</header>
<div className="flex gap-2 items-center flex-wrap">
<select id="lib-client" className="input h-9 w-auto" value={client} onChange={(e) => setClient(e.target.value)}>
<option value="">All clients</option>
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
</select>
{["submitted", "changes", "approved", "scheduled", "all"].map((k) => (
<button key={k} onClick={() => setTab(k)} className={cx("h-9 px-3 rounded-md text-[13px] font-medium", tab === k ? "bg-brand-soft text-brand-deep" : "text-ink-2 hover:bg-canvas")}>
{k === "all" ? "All" : LABEL[k]}{k !== "all" && data ? ` (${count(k)})` : ""}
</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 ? (
<Panel><Empty icon={ImageIcon} title="Nothing here">{tab === "submitted" ? "No work waiting for review." : tab === "approved" ? "No approved work waiting to be scheduled." : "No items in this view."}</Empty></Panel>
) : (
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
{items.map((i) => (
<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">
<Badge tone={TONE[i.status]} dot>{LABEL[i.status]}</Badge>
<span className="text-[12px] text-muted">{i.format.toLowerCase()}{i.files.length > 1 ? ` · ${i.files.length} files` : ""}{i.version > 1 ? ` · v${i.version}` : ""}</span>
</div>
<div className="font-semibold text-[13.5px] leading-snug">{i.title}</div>
<div className="text-[12px] text-muted">{name(i.client)} · {i.uploadedBy} · {timeAgo(i.updatedAt)}{i.taskRef ? ` · task #${i.taskRef}` : ""}{i.forDate ? ` · for ${i.forDate}` : ""}</div>
{i.captionIdea && <p className="text-[12.5px] text-ink-2 line-clamp-2">{i.captionIdea}</p>}
{i.notes && <p className="text-[12px] text-muted line-clamp-2">Note: {i.notes}</p>}
{i.status === "changes" && i.reviewNote && <p className="text-[12.5px] text-warn bg-warn-soft rounded px-2 py-1">{i.reviewedBy}: {i.reviewNote}</p>}
<div className="mt-auto pt-2 flex gap-2 flex-wrap">
{isAdmin && ["submitted", "changes"].includes(i.status) && <Button size="sm" variant="primary" icon={Check} onClick={() => setDialog({ kind: "review", item: i })}>Review</Button>}
{i.status === "changes" && <Button size="sm" icon={Upload} onClick={() => setDialog({ kind: "version", item: i })}>Upload new version</Button>}
{["approved", "scheduled"].includes(i.status) && <Button size="sm" variant={i.status === "approved" ? "primary" : "secondary"} icon={CalendarPlus} onClick={() => setDialog({ kind: "schedule", item: i })}>{i.status === "scheduled" ? "Schedule again" : "Schedule"}</Button>}
{i.status !== "archived" && <Button size="sm" variant="ghost" icon={Archive} onClick={() => api(`/api/library/${i.id}/archive`, { method: "POST" }).then(load).catch(setError)}>Archive</Button>}
</div>
</div>
</div>
))}
</div>
)}
{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>
);
}
function Preview({ item, big }) {
const [idx, setIdx] = useState(0);
const f = item.files[idx];
return (
<div className={cx("relative bg-canvas border-b border-line", big ? "aspect-[4/5] max-h-[420px]" : "aspect-[4/5]")}>
{isVid(f) ? <video src={fileUrl(f)} className="size-full object-contain bg-black" controls={big} muted={!big} preload="metadata" /> : <img src={fileUrl(f)} alt="" className="size-full object-cover" loading="lazy" />}
{item.files.length > 1 && (
<div className="absolute bottom-2 inset-x-0 flex justify-center gap-1">
{item.files.map((_, n) => <button key={n} aria-label={`File ${n + 1}`} onClick={() => setIdx(n)} className={cx("size-2 rounded-full", n === idx ? "bg-white" : "bg-white/50")} />)}
</div>
)}
</div>
);
}
function FilePicker({ files, setFiles, accept = "image/jpeg,image/png,video/mp4,video/quicktime" }) {
return (
<div className="flex flex-wrap gap-2 items-center">
{files.map((f, n) => <span key={n} className="text-[12px] bg-canvas border border-line rounded px-2 py-1">{n + 1}. {f.name} <span className="text-faint">{(f.size / 1048576).toFixed(1)} MB</span> <button className="text-bad ml-1" onClick={() => setFiles(files.filter((_, k) => k !== n))}>×</button></span>)}
<label className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md border border-line-strong text-[13px] cursor-pointer hover:bg-canvas">
<Upload className="size-3.5" /> Add files
<input type="file" className="hidden" multiple accept={accept} onChange={(e) => { setFiles([...files, ...e.target.files]); e.target.value = ""; }} />
</label>
</div>
);
}
function UploadDialog({ clients, defaultClient, onClose, onDone }) {
const [f, setF] = useState({ client: defaultClient || clients[0]?.slug || "", title: "", format: "IMAGE", captionIdea: "", notes: "", taskRef: "", forDate: "", planItem: "" });
const [planned, setPlanned] = useState([]);
const [files, setFiles] = useState([]);
const [tasks, setTasks] = useState([]);
const [progress, setProgress] = useState(null);
const [error, setError] = useState(null);
const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
useEffect(() => { api("/api/team").then((r) => setTasks(r.tasks || [])).catch(() => {}); }, []);
useEffect(() => {
if (!f.client) return;
const from = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10), to = new Date(Date.now() + 60 * 86400000).toISOString().slice(0, 10);
api(`/api/calendar?client=${f.client}&from=${from}&to=${to}`).then((r) => setPlanned(r.items.filter((i) => i.kind === "plan" && ["idea", "brief", "production"].includes(i.stage)))).catch(() => setPlanned([]));
}, [f.client]);
const pickPlan = (id) => {
const p = planned.find((x) => x.id === id);
setF((s) => ({ ...s, planItem: id, ...(p ? { title: s.title || p.title, format: p.format === "TEXT" ? s.format : p.format, forDate: p.date, captionIdea: s.captionIdea || p.caption || "", taskRef: p.taskRef || s.taskRef } : {}) }));
};
useEffect(() => { if (files.length > 1 && f.format !== "CAROUSEL") set("format", "CAROUSEL"); else if (files.length === 1 && isVid(files[0].name) && f.format === "IMAGE") set("format", "REELS"); }, [files]); // eslint-disable-line
const submit = async () => {
setError(null);
try {
const paths = await uploadFiles(f.client, files, setProgress);
await api("/api/library", { method: "POST", body: { ...f, files: paths } });
onDone();
} catch (e) { setError(e); setProgress(null); }
};
return (
<Dialog open onClose={onClose} title="Upload finished work" wide footer={<><Button onClick={onClose}>Cancel</Button><Button variant="primary" loading={!!progress} disabled={!f.client || !files.length} onClick={submit}>Submit for review</Button></>}>
{planned.length > 0 && (
<Field label="For calendar item (optional)" hint="Links the upload to its card on the content calendar and fills in the details.">
<select id="up-plan" className="input" value={f.planItem} onChange={(e) => pickPlan(e.target.value)}>
<option value="">Not from the calendar</option>
{planned.map((p) => <option key={p.id} value={p.id}>{p.date} · {p.title}{p.assignee ? ` (${p.assignee})` : ""}</option>)}
</select>
</Field>
)}
<div className="grid grid-cols-2 gap-4">
<Field label="Client"><select className="input" value={f.client} onChange={(e) => { set("client", e.target.value); set("planItem", ""); }}>{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}</select></Field>
<Field label="Format"><select className="input" value={f.format} onChange={(e) => set("format", e.target.value)}>{FORMATS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select></Field>
<Field label="Title" hint="What is it? e.g. Diwali sweets box reel"><input className="input" value={f.title} onChange={(e) => set("title", e.target.value)} /></Field>
<Field label="Planned for (optional)"><input type="date" className="input" value={f.forDate} onChange={(e) => set("forDate", e.target.value)} /></Field>
</div>
<Field label="Files" hint="Final exports only. Images 1080×1350 (feed) or 1080×1920 (Story/Reel). Carousel: add files in order.">
<FilePicker files={files} setFiles={setFiles} />
</Field>
{progress && <p className="text-[12.5px] text-muted">{progress}</p>}
<Field label="Odoo task (optional)" hint="Links the upload to the task: a note is added and it moves to Review.">
<select className="input" value={f.taskRef} onChange={(e) => set("taskRef", e.target.value)}>
<option value="">No task</option>
{tasks.map((t) => <option key={t.id} value={t.id}>#{t.id} {t.name}{t.project ? ` (${t.project.name})` : ""}</option>)}
</select>
</Field>
<Field label="Caption idea (optional)"><textarea className="input h-20" value={f.captionIdea} onChange={(e) => set("captionIdea", e.target.value)} /></Field>
<Field label="Notes for the reviewer (optional)" hint="Music used, trend it follows, anything to check."><input className="input" value={f.notes} onChange={(e) => set("notes", e.target.value)} /></Field>
<ErrorNote error={error} />
</Dialog>
);
}
function VersionDialog({ item, onClose, onDone }) {
const [files, setFiles] = useState([]);
const [progress, setProgress] = useState(null);
const [error, setError] = useState(null);
const submit = async () => {
setError(null);
try { const paths = await uploadFiles(item.client, files, setProgress); await api(`/api/library/${item.id}`, { method: "PATCH", body: { files: paths } }); onDone(); }
catch (e) { setError(e); setProgress(null); }
};
return (
<Dialog open onClose={onClose} title={`New version: ${item.title}`} footer={<><Button onClick={onClose}>Cancel</Button><Button variant="primary" loading={!!progress} disabled={!files.length} onClick={submit}>Resubmit</Button></>}>
{item.reviewNote && <p className="text-[13px] text-warn bg-warn-soft rounded px-3 py-2 flex gap-2"><MessageSquareWarning className="size-4 shrink-0 mt-0.5" />{item.reviewedBy}: {item.reviewNote}</p>}
<Field label="Replacement files" hint={`Same format (${item.format.toLowerCase()}); these replace all current files.`}><FilePicker files={files} setFiles={setFiles} /></Field>
{progress && <p className="text-[12.5px] text-muted">{progress}</p>}
<ErrorNote error={error} />
</Dialog>
);
}
function ReviewDialog({ item, onClose, onDone }) {
const [note, setNote] = useState("");
const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
const decide = async (decision) => {
setBusy(decision); setError(null);
try { await api(`/api/library/${item.id}/review`, { method: "POST", body: { decision, note } }); onDone(); } catch (e) { setError(e); setBusy(null); }
};
return (
<Dialog open onClose={onClose} title={`Review: ${item.title}`} wide footer={<><Button onClick={onClose}>Close</Button><Button variant="danger" loading={busy === "changes"} disabled={!note.trim()} onClick={() => decide("changes")}>Request changes</Button><Button variant="primary" icon={Check} loading={busy === "approve"} onClick={() => decide("approve")}>Approve</Button></>}>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-4">
<div className="rounded-md overflow-hidden border border-line"><Preview item={item} big /></div>
<div className="space-y-2 text-[13px]">
<div><b>{item.format.toLowerCase()}</b> · v{item.version} · by {item.uploadedBy}</div>
{item.captionIdea && <div><div className="text-muted text-[12px]">Caption idea</div><p className="whitespace-pre-line">{item.captionIdea}</p></div>}
{item.notes && <div><div className="text-muted text-[12px]">Notes</div><p>{item.notes}</p></div>}
<div className="text-[12px] text-muted">Check: first 3 seconds hook, text readable on a phone, brand fonts/colours, no client-sensitive names, music licensed for business use.</div>
<Field label="Feedback (required to request changes)"><textarea className="input h-24" value={note} onChange={(e) => setNote(e.target.value)} placeholder="e.g. Cut the intro to 1 second, make the price bigger" /></Field>
</div>
</div>
<ErrorNote error={error} />
</Dialog>
);
}
function ScheduleDialog({ item, clientName, onClose, onDone }) {
const [f, setF] = useState({ date: item.forDate || "", time: "", caption: item.captionIdea || "", fbCaption: "", collaborators: "" });
const available = useClientPlatforms(item.client);
const [sel, setSel] = useState({ platforms: [], options: {} });
useEffect(() => { if (!available.loading) setSel({ platforms: defaultPlatforms(available.list), options: {} }); }, [available]);
const chosen = selectionFor(sel, available, item.format);
const [tz, setTz] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
useEffect(() => { api(`/api/schedule/suggest?client=${item.client}`).then(setTz).catch(() => {}); }, [item.client]);
const submit = async () => {
setBusy(true); setError(null);
try {
if (chosen.problems.length) throw new Error(chosen.problems[0]);
await api(`/api/library/${item.id}/schedule`, { method: "POST", body: { platforms: chosen.platforms, options: chosen.options, localTime: `${f.date} ${f.time}`, caption: f.caption, fbCaption: f.fbCaption || undefined, collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean) } });
onDone();
} catch (e) { setError(e); setBusy(false); }
};
return (
<Dialog open onClose={onClose} title={`Schedule for ${clientName}`} wide footer={<><Button onClick={onClose}>Cancel</Button><Button variant="primary" loading={busy} disabled={!f.date || !f.time || !chosen.platforms.length} onClick={submit}>Add to Publishing as draft</Button></>}>
<p className="text-[12.5px] text-muted">This creates a draft in Publishing. An admin still approves it there before it goes live.</p>
<div className="grid grid-cols-2 gap-4">
<Field label={`When (${tz?.timezone?.replace("_", " ") || "client time"})`} hint={tz ? `Good times: ${tz.times.join(", ")}` : null}>
<div className="flex gap-2"><input type="date" className="input flex-1" value={f.date} onChange={(e) => set("date", e.target.value)} /><input type="time" className="input w-[120px]" value={f.time} onChange={(e) => set("time", e.target.value)} /></div>
</Field>
</div>
<Field label="Caption"><textarea className="input h-28" value={f.caption} onChange={(e) => set("caption", e.target.value)} /></Field>
<Field label="Post to"><PlatformPicker client={item.client} type={item.format} caption={f.caption} value={sel} onChange={setSel} available={available} media={item.files} /></Field>
{sel.platforms.includes("facebook") && <Field label="Facebook caption (optional)"><textarea className="input h-16" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} /></Field>}
{sel.platforms.includes("instagram") && item.format !== "STORY" && <Field label="Collab invites (optional)"><input className="input" placeholder="@username" value={f.collaborators} onChange={(e) => set("collaborators", e.target.value)} /></Field>}
<ErrorNote error={error} />
</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>
);
}