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:
parent
aa56d2b531
commit
c4d8c6474c
@ -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.");
|
||||
}
|
||||
|
||||
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);
|
||||
if (!FORMATS.includes(format)) throw err("Unknown format.");
|
||||
validateFiles(format, files);
|
||||
const item = {
|
||||
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: [],
|
||||
};
|
||||
const all = read(); all.push(item); write(all); return item;
|
||||
|
||||
@ -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 ("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);
|
||||
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;
|
||||
}
|
||||
|
||||
@ -81,12 +81,18 @@ export function calendar({ client, from, to }) {
|
||||
checkClient(client);
|
||||
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 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;
|
||||
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",
|
||||
caption: l.captionIdea || "", files: l.files || [], editable: true,
|
||||
});
|
||||
|
||||
@ -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 ----------
|
||||
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/plan", h(async (req) => ({ item: plan.createItem(req.body || {}, who(req)) })));
|
||||
app.patch("/api/plan/:id", h(async (req) => ({ item: plan.updateItem(req.params.id, req.body || {}, who(req)) })));
|
||||
// Tell the person a plan item is assigned to (bell + email if they want it).
|
||||
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 }; }));
|
||||
|
||||
// ---------- competitors per client (Instagram) ----------
|
||||
@ -371,7 +396,10 @@ app.post("/api/library/upload", h(async (req) => {
|
||||
return { path: file, size: fs.statSync(file).size };
|
||||
}));
|
||||
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" });
|
||||
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) => {
|
||||
const item = lib.reviewItem(req.params.id, req.body || {}, who(req));
|
||||
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" });
|
||||
return { item };
|
||||
}));
|
||||
|
||||
@ -63,7 +63,13 @@ function ItemDialog({ item, client, onClose, onSaved }) {
|
||||
});
|
||||
const [busy, setBusy] = 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 }));
|
||||
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 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)} />
|
||||
</Field>
|
||||
<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>
|
||||
</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} />
|
||||
</div>
|
||||
|
||||
@ -129,13 +129,23 @@ function FilePicker({ files, setFiles, accept = "image/jpeg,image/png,video/mp4,
|
||||
}
|
||||
|
||||
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 [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 () => {
|
||||
@ -148,8 +158,16 @@ function UploadDialog({ clients, defaultClient, onClose, onDone }) {
|
||||
};
|
||||
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)}>{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="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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user