Team task flow: assign calendar items, link uploads, move cards through production

- Assigning a plan item notifies the person (bell + email); team names offered when assigning
- Optional Odoo task from a calendar card (project + assignee, deadline = post date, brief in the task)
- Library upload can pick its calendar item: inherits date and Odoo task, card moves to 'in production'
- Approving the upload moves the card to 'ready'; changes requested notify the uploader
- Calendar shows the uploaded work in place of its plan card (no duplicates)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-23 10:13:23 -04:00
parent aa56d2b531
commit c4d8c6474c
5 changed files with 86 additions and 12 deletions

View File

@ -53,13 +53,13 @@ function validateFiles(format, files) {
if (format === "CAROUSEL" && (files.length < 2 || files.length > 10)) throw err("A carousel needs 2–10 files."); if (format === "CAROUSEL" && (files.length < 2 || files.length > 10)) throw err("A carousel needs 2–10 files.");
} }
export function createItem({ client, title, format = "IMAGE", files = [], captionIdea = "", notes = "", taskRef = "", forDate = "", uploadedBy = "team" }) { export function createItem({ client, title, format = "IMAGE", files = [], captionIdea = "", notes = "", taskRef = "", forDate = "", uploadedBy = "team", planItem = "" }) {
checkClient(client); checkClient(client);
if (!FORMATS.includes(format)) throw err("Unknown format."); if (!FORMATS.includes(format)) throw err("Unknown format.");
validateFiles(format, files); validateFiles(format, files);
const item = { const item = {
id: crypto.randomUUID().slice(0, 8), client, title: String(title || "").trim() || path.basename(files[0]).replace(/^\d+-\w{4}-/, ""), id: crypto.randomUUID().slice(0, 8), client, title: String(title || "").trim() || path.basename(files[0]).replace(/^\d+-\w{4}-/, ""),
format, files, captionIdea, notes, taskRef, forDate, status: "submitted", version: 1, format, files, captionIdea, notes, taskRef, forDate, planItem: String(planItem || ""), status: "submitted", version: 1,
uploadedBy, createdAt: now(), updatedAt: now(), history: [{ at: now(), by: uploadedBy, action: "submitted" }], postIds: [], uploadedBy, createdAt: now(), updatedAt: now(), history: [{ at: now(), by: uploadedBy, action: "submitted" }], postIds: [],
}; };
const all = read(); all.push(item); write(all); return item; const all = read(); all.push(item); write(all); return item;

View File

@ -34,7 +34,7 @@ function clean(patch) {
if ("status" in patch) { if (!STAGES.includes(patch.status)) throw err(`Status must be one of: ${STAGES.join(", ")}.`); out.status = patch.status; } if ("status" in patch) { if (!STAGES.includes(patch.status)) throw err(`Status must be one of: ${STAGES.join(", ")}.`); out.status = patch.status; }
if ("format" in patch) { if (!FORMATS.includes(patch.format)) throw err(`Format must be one of: ${FORMATS.join(", ")}.`); out.format = patch.format; } if ("format" in patch) { if (!FORMATS.includes(patch.format)) throw err(`Format must be one of: ${FORMATS.join(", ")}.`); out.format = patch.format; }
if ("platforms" in patch) out.platforms = (patch.platforms || []).map(String).slice(0, 10); if ("platforms" in patch) out.platforms = (patch.platforms || []).map(String).slice(0, 10);
for (const k of ["brief", "caption", "pillar", "assignee", "notes"]) if (k in patch) out[k] = String(patch[k] ?? "").slice(0, 5000); for (const k of ["brief", "caption", "pillar", "assignee", "notes", "taskRef", "libraryItem"]) if (k in patch) out[k] = String(patch[k] ?? "").slice(0, 5000);
return out; return out;
} }
@ -81,12 +81,18 @@ export function calendar({ client, from, to }) {
checkClient(client); checkClient(client);
if (!DATE_RE.test(from) || !DATE_RE.test(to)) throw err("from and to must be YYYY-MM-DD."); if (!DATE_RE.test(from) || !DATE_RE.test(to)) throw err("from and to must be YYYY-MM-DD.");
const tz = sched.clientInfo(client).timezone; const tz = sched.clientInfo(client).timezone;
const out = listItems({ client, from, to }).map((i) => ({ ...i, kind: "plan", stage: i.status, editable: true })); const libItems = lib.listItems({ client });
const activeLib = new Set(libItems.filter((l) => l.status !== "archived").map((l) => l.id));
// A plan item whose work was uploaded shows as the library card (with the plan's title) instead of twice.
const plans = listItems({ client, from, to });
const planById = Object.fromEntries(listItems({ client }).map((i) => [i.id, i]));
const out = plans.filter((i) => !(i.libraryItem && activeLib.has(i.libraryItem))).map((i) => ({ ...i, kind: "plan", stage: i.status, editable: true }));
for (const l of lib.listItems({ client })) { for (const l of libItems) {
if (!l.forDate || l.forDate < from || l.forDate > to || ["scheduled", "archived"].includes(l.status)) continue; if (!l.forDate || l.forDate < from || l.forDate > to || ["scheduled", "archived"].includes(l.status)) continue;
out.push({ out.push({
id: l.id, kind: "library", client, title: l.title, date: l.forDate, time: "", format: l.format, platforms: [], id: l.id, kind: "library", client, title: planById[l.planItem]?.title || l.title, date: l.forDate, time: planById[l.planItem]?.time || "", format: l.format, platforms: planById[l.planItem]?.platforms || [],
assignee: planById[l.planItem]?.assignee || l.uploadedBy, planItem: l.planItem || null,
stage: l.status === "approved" ? "ready" : "production", detail: l.status === "changes" ? "Changes requested" : l.status === "submitted" ? "Waiting for review" : "Approved in the library", stage: l.status === "approved" ? "ready" : "production", detail: l.status === "changes" ? "Changes requested" : l.status === "submitted" ? "Waiting for review" : "Approved in the library",
caption: l.captionIdea || "", files: l.files || [], editable: true, caption: l.captionIdea || "", files: l.files || [], editable: true,
}); });

View File

@ -339,8 +339,33 @@ app.post("/api/notify/test", h(async (req) => notify.sendEmail({ to: req.user.em
// ---------- content calendar: plan items + library items with a date + queued/published posts ---------- // ---------- content calendar: plan items + library items with a date + queued/published posts ----------
app.get("/api/calendar", h(async (req) => plan.calendar({ client: String(req.query.client || ""), from: String(req.query.from || ""), to: String(req.query.to || "") }))); app.get("/api/calendar", h(async (req) => plan.calendar({ client: String(req.query.client || ""), from: String(req.query.from || ""), to: String(req.query.to || "") })));
app.post("/api/calendar/move", h(async (req) => ({ item: plan.moveEntry(String(req.body?.kind || ""), String(req.body?.id || ""), String(req.body?.date || ""), who(req)) }))); app.post("/api/calendar/move", h(async (req) => ({ item: plan.moveEntry(String(req.body?.kind || ""), String(req.body?.id || ""), String(req.body?.date || ""), who(req)) })));
app.post("/api/plan", h(async (req) => ({ item: plan.createItem(req.body || {}, who(req)) }))); // Tell the person a plan item is assigned to (bell + email if they want it).
app.patch("/api/plan/:id", h(async (req) => ({ item: plan.updateItem(req.params.id, req.body || {}, who(req)) }))); async function notifyAssignee(item, before = null) {
if (!item.assignee || (before && before.assignee === item.assignee) || !["idea", "brief", "production"].includes(item.status)) return;
const c = clients.getClient(item.client);
await notify.notify({
type: "assigned", title: `New for you: ${c?.name || item.client} “${item.title}”`, to: [item.assignee],
body: `Due ${item.date}${item.format ? ` · ${item.format.toLowerCase()}` : ""}${item.brief ? ` · ${item.brief.slice(0, 200)}` : ""}`,
link: `/calendar?client=${item.client}&date=${item.date}`, client: item.client,
});
}
app.post("/api/plan", h(async (req) => { const item = plan.createItem(req.body || {}, who(req)); await notifyAssignee(item); return { item }; }));
app.patch("/api/plan/:id", h(async (req) => {
const before = plan.listItems({}).find((i) => i.id === req.params.id);
const item = plan.updateItem(req.params.id, req.body || {}, who(req));
await notifyAssignee(item, before);
return { item };
}));
app.get("/api/team/names", h(async () => ({ names: users.listUsers().filter((u) => u.active).map((u) => u.name || u.email) })));
app.post("/api/plan/:id/odoo-task", requireAdmin, h(async (req) => {
const item = plan.listItems({}).find((i) => i.id === req.params.id);
if (!item) throw Object.assign(new Error("Plan item not found."), { status: 404 });
const c = clients.getClient(item.client);
const userIds = (req.body?.userId ? [req.body.userId] : []);
const description = [item.brief && `Brief: ${item.brief}`, item.caption && `Draft caption: ${item.caption}`, item.platforms?.length && `Platforms: ${item.platforms.join(", ")}`, `Upload the finished work in Quorla → Content library and pick this calendar item.`].filter(Boolean).join("\n\n");
const task = await odoo.createTask({ projectId: req.body?.projectId, name: `${c?.name || item.client}: ${item.title}`, description, userIds, deadline: item.date });
return { item: plan.updateItem(item.id, { taskRef: String(task.id), status: item.status === "idea" ? "brief" : item.status }, who(req)), task };
}));
app.delete("/api/plan/:id", h(async (req) => { plan.removeItem(req.params.id); return { ok: true }; })); app.delete("/api/plan/:id", h(async (req) => { plan.removeItem(req.params.id); return { ok: true }; }));
// ---------- competitors per client (Instagram) ---------- // ---------- competitors per client (Instagram) ----------
@ -371,7 +396,10 @@ app.post("/api/library/upload", h(async (req) => {
return { path: file, size: fs.statSync(file).size }; return { path: file, size: fs.statSync(file).size };
})); }));
app.post("/api/library", h(async (req) => { app.post("/api/library", h(async (req) => {
const item = lib.createItem({ ...req.body, uploadedBy: who(req) }); // Uploaded for a calendar item: inherit its date and Odoo task, and move the card to "in production".
const p = req.body?.planItem ? plan.listItems({ client: req.body.client }).find((i) => i.id === req.body.planItem) : null;
const item = lib.createItem({ ...req.body, forDate: req.body?.forDate || p?.date || "", taskRef: req.body?.taskRef || p?.taskRef || "", planItem: p?.id || "", uploadedBy: who(req) });
if (p) plan.updateItem(p.id, { libraryItem: item.id, ...(["idea", "brief"].includes(p.status) ? { status: "production" } : {}) }, who(req));
const odooResult = await odoo.taskActivity(item.taskRef, { note: `<p>${esc(who(req))} uploaded <b>${esc(item.title)}</b> (${item.format.toLowerCase()}, ${item.files.length} file${item.files.length > 1 ? "s" : ""}) to the Social Manager library for review.</p>`, stageMatch: "review" }); const odooResult = await odoo.taskActivity(item.taskRef, { note: `<p>${esc(who(req))} uploaded <b>${esc(item.title)}</b> (${item.format.toLowerCase()}, ${item.files.length} file${item.files.length > 1 ? "s" : ""}) to the Social Manager library for review.</p>`, stageMatch: "review" });
return { item, odoo: odooResult }; return { item, odoo: odooResult };
})); }));
@ -383,6 +411,8 @@ app.patch("/api/library/:id", h(async (req) => {
app.post("/api/library/:id/review", requireAdmin, h(async (req) => { app.post("/api/library/:id/review", requireAdmin, h(async (req) => {
const item = lib.reviewItem(req.params.id, req.body || {}, who(req)); const item = lib.reviewItem(req.params.id, req.body || {}, who(req));
const ok = item.status === "approved"; const ok = item.status === "approved";
if (item.planItem) { try { plan.updateItem(item.planItem, { status: ok ? "ready" : "production" }, who(req)); } catch { /* plan item removed */ } }
if (!ok && item.uploadedBy) await notify.notify({ type: "library_changes", level: "warn", title: `Changes requested: ${item.title}`, body: item.reviewNote, to: [item.uploadedBy], link: `/library?client=${item.client}`, client: item.client });
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 };
})); }));

View File

@ -63,7 +63,13 @@ function ItemDialog({ item, client, onClose, onSaved }) {
}); });
const [busy, setBusy] = useState(null); const [busy, setBusy] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [names, setNames] = useState([]);
const [odoo, setOdoo] = useState(null);
const [task, setTask] = useState({ projectId: "", userId: "" });
const set = (k, v) => setF((s) => ({ ...s, [k]: v })); const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
useEffect(() => { api("/api/team/names").then((r) => setNames(r.names)).catch(() => {}); }, []);
useEffect(() => { if (isPlan && !isNew && !item.taskRef) api("/api/team").then((r) => setOdoo(r.status?.connected && r.status?.allowWrites ? r : null)).catch(() => {}); }, [isPlan, isNew, item.taskRef]);
const makeTask = () => run("task", () => api(`/api/plan/${item.id}/odoo-task`, { method: "POST", body: task }));
const run = async (what, fn) => { setBusy(what); setError(null); try { await fn(); onSaved(); } catch (e) { setError(e); setBusy(null); } }; const run = async (what, fn) => { setBusy(what); setError(null); try { await fn(); onSaved(); } catch (e) { setError(e); setBusy(null); } };
const save = () => run("save", () => (isPlan const save = () => run("save", () => (isPlan
@ -131,9 +137,23 @@ function ItemDialog({ item, client, onClose, onSaved }) {
<textarea id="plan-caption" className="input h-20 py-2" value={f.caption} onChange={(e) => set("caption", e.target.value)} /> <textarea id="plan-caption" className="input h-20 py-2" value={f.caption} onChange={(e) => set("caption", e.target.value)} />
</Field> </Field>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Assigned to"><input id="plan-assignee" className="input" value={f.assignee} onChange={(e) => set("assignee", e.target.value)} placeholder="Designer or editor" /></Field> <Field label="Assigned to" hint="They're notified when you save.">
<input id="plan-assignee" className="input" list="plan-team" value={f.assignee} onChange={(e) => set("assignee", e.target.value)} placeholder="Designer or editor" />
<datalist id="plan-team">{names.map((n) => <option key={n} value={n} />)}</datalist>
</Field>
<Field label="Content pillar"><input id="plan-pillar" className="input" value={f.pillar} onChange={(e) => set("pillar", e.target.value)} placeholder="e.g. Behind the scenes" /></Field> <Field label="Content pillar"><input id="plan-pillar" className="input" value={f.pillar} onChange={(e) => set("pillar", e.target.value)} placeholder="e.g. Behind the scenes" /></Field>
</div> </div>
{item.taskRef && <p className="text-[12.5px] text-muted">Odoo task #{item.taskRef}. Uploads for this card add a note to it and move it to Review.</p>}
{odoo && (
<div className="rounded-md border border-line p-3 space-y-2">
<div className="text-[13px] font-semibold">Create an Odoo task for this</div>
<div className="grid sm:grid-cols-[1fr_1fr_auto] gap-2 items-end">
<Field label="Project"><select id="plan-odoo-project" className="input" value={task.projectId} onChange={(e) => setTask({ ...task, projectId: e.target.value })}><option value="">Choose…</option>{odoo.projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</select></Field>
<Field label="Assignee in Odoo"><select id="plan-odoo-user" className="input" value={task.userId} onChange={(e) => setTask({ ...task, userId: e.target.value })}><option value="">Unassigned</option>{(odoo.workload || []).map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}</select></Field>
<Button loading={busy === "task"} disabled={!task.projectId} onClick={makeTask}>Create task</Button>
</div>
</div>
)}
</>} </>}
<ErrorNote error={error} /> <ErrorNote error={error} />
</div> </div>

View File

@ -129,13 +129,23 @@ function FilePicker({ files, setFiles, accept = "image/jpeg,image/png,video/mp4,
} }
function UploadDialog({ clients, defaultClient, onClose, onDone }) { function UploadDialog({ clients, defaultClient, onClose, onDone }) {
const [f, setF] = useState({ client: defaultClient || clients[0]?.slug || "", title: "", format: "IMAGE", captionIdea: "", notes: "", taskRef: "", forDate: "" }); 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 [files, setFiles] = useState([]);
const [tasks, setTasks] = useState([]); const [tasks, setTasks] = useState([]);
const [progress, setProgress] = useState(null); const [progress, setProgress] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const set = (k, v) => setF((s) => ({ ...s, [k]: v })); const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
useEffect(() => { api("/api/team").then((r) => setTasks(r.tasks || [])).catch(() => {}); }, []); 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 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 () => { const submit = async () => {
@ -148,8 +158,16 @@ function UploadDialog({ clients, defaultClient, onClose, onDone }) {
}; };
return ( 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></>}> <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"> <div className="grid grid-cols-2 gap-4">
<Field label="Client"><select className="input" value={f.client} onChange={(e) => set("client", e.target.value)}>{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}</select></Field> <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="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="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> <Field label="Planned for (optional)"><input type="date" className="input" value={f.forDate} onChange={(e) => set("forDate", e.target.value)} /></Field>