metatroncubeswdev c309581df0 Initial commit: Quorla social media manager
Dashboard (content library, publishing queue with approvals, inbox, team
management, AI runs), Meta connector, branded PDF reports, agent skills,
and Docker packaging (Dockerfile, docker-compose with Caddy HTTPS,
backup and path-relocation scripts).

Client data, live data and secrets are excluded via .gitignore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:02:29 -04:00

209 lines
10 KiB
JavaScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { ArrowUpRight, Plus, Users2, Star } from "lucide-react";
import { api } from "../api.js";
import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, fmt, fmtDate, Panel, Spinner, Stat, StatCell, StatRow } from "../ui.jsx";
export default function Team() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [person, setPerson] = useState("");
const [project, setProject] = useState("");
const [overdueOnly, setOverdueOnly] = useState(false);
const [q, setQ] = useState("");
const [creating, setCreating] = useState(false);
const load = useCallback(() => api("/api/team").then(setData).catch(setError), []);
useEffect(() => { load(); }, [load]);
const tasks = useMemo(() => {
const term = q.trim().toLowerCase();
return (data?.tasks || []).filter(
(t) =>
(!person || t.assignees.some((a) => String(a.id) === person)) &&
(!project || String(t.project?.id) === project) &&
(!overdueOnly || t.overdue) &&
(!term || t.name.toLowerCase().includes(term) || t.project?.name.toLowerCase().includes(term))
);
}, [data, person, project, overdueOnly, q]);
if (error) return <ErrorNote error={error} />;
if (!data) return <Spinner label="Loading from Odoo…" />;
if (!data.status.connected) {
return (
<div className="space-y-5">
<h1 className="text-[22px] font-extrabold">Team</h1>
<Panel>
<Empty icon={Users2} title={data.status.configured ? "Can't reach Odoo" : "Connect Odoo to see the team"}>
{data.status.error || "Add ODOO_URL, ODOO_DATABASE, ODOO_USERNAME and ODOO_API_KEY to the project .env, then restart the dashboard."}
</Empty>
</Panel>
</div>
);
}
const all = data.tasks;
const overdue = all.filter((t) => t.overdue).length;
const unassigned = all.filter((t) => !t.assignees.length).length;
const usedProjects = data.projects.filter((p) => all.some((t) => t.project?.id === p.id));
const busiest = data.workload.reduce((m, w) => Math.max(m, w.open), 0);
return (
<div className="space-y-5">
<header className="flex items-end justify-between gap-4">
<div>
<h1 className="text-[22px] font-extrabold">Team</h1>
<p className="text-muted mt-0.5">Live from {data.status.url?.replace(/^https?:\/\//, "")} · Odoo {data.status.version?.split("-")[0]}</p>
</div>
{data.status.allowWrites && <Button variant="primary" icon={Plus} onClick={() => setCreating(true)}>New task</Button>}
</header>
<StatRow className="grid-cols-4">
<StatCell><Stat label="Open tasks" value={fmt(all.length)} note={`${usedProjects.length} projects`} /></StatCell>
<StatCell><Stat label="Overdue" value={fmt(overdue)} tone={overdue ? "bad" : null} note={overdue ? "past their deadline" : "nothing late"} /></StatCell>
<StatCell><Stat label="Unassigned" value={fmt(unassigned)} tone={unassigned ? "warn" : null} note="no one owns these" /></StatCell>
<StatCell><Stat label="Team members" value={fmt(data.workload.length)} note="internal Odoo users" /></StatCell>
</StatRow>
<div className="grid grid-cols-[300px_minmax(0,1fr)] gap-5 items-start">
<Panel title="Workload" subtitle="Open tasks per person">
<ul className="divide-y divide-line">
{data.workload.map((w) => (
<li key={w.id}>
<button
onClick={() => setPerson(person === String(w.id) ? "" : String(w.id))}
className={cx("w-full text-left px-4 py-2.5", person === String(w.id) ? "bg-brand-soft" : "hover:bg-[#f8fafc]")}
>
<div className="flex justify-between items-baseline gap-2">
<span className="font-medium truncate">{w.name}</span>
<span className="num text-[12.5px] text-ink-2 shrink-0">
{w.open}{w.overdue > 0 && <span className="text-bad"> · {w.overdue} late</span>}
</span>
</div>
<div className="h-1.5 rounded-full bg-[#eef0f3] mt-1.5 overflow-hidden flex">
<div className="bg-bad" style={{ width: `${(w.overdue / Math.max(busiest, 1)) * 100}%` }} />
<div className="bg-brand" style={{ width: `${((w.open - w.overdue) / Math.max(busiest, 1)) * 100}%` }} />
</div>
</button>
</li>
))}
</ul>
</Panel>
<Panel
title="Tasks"
subtitle={`${tasks.length} shown`}
action={
<div className="flex items-center gap-2">
<input className="input h-8 w-[180px]" placeholder="Search tasks" value={q} onChange={(e) => setQ(e.target.value)} />
<select className="input h-8 w-[200px]" value={project} onChange={(e) => setProject(e.target.value)}>
<option value="">All projects</option>
{usedProjects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<label className="flex items-center gap-1.5 text-[12.5px] text-ink-2 whitespace-nowrap">
<input type="checkbox" className="accent-brand" checked={overdueOnly} onChange={(e) => setOverdueOnly(e.target.checked)} /> Overdue
</label>
</div>
}
bodyClass="overflow-x-auto"
>
{tasks.length ? (
<table className="tbl">
<thead><tr><th>Task</th><th>Assignee</th><th>Stage</th><th>Deadline</th><th /></tr></thead>
<tbody>
{tasks.map((t) => (
<tr key={t.id}>
<td className="max-w-[360px]">
<div className="flex items-center gap-1.5">
{t.priority === "1" && <Star className="size-3.5 text-warn fill-current shrink-0" />}
<span className="font-medium text-ink truncate">{t.name}</span>
</div>
<div className="text-[12px] text-muted truncate">{t.project?.name || "No project"}</div>
</td>
<td className="text-ink-2">{t.assignees.map((a) => a.name).join(", ") || <Badge tone="warn">unassigned</Badge>}</td>
<td><Badge>{t.stage?.name || "—"}</Badge></td>
<td className={cx("whitespace-nowrap num", t.overdue ? "text-bad font-medium" : "text-ink-2")}>
{t.deadline ? fmtDate(t.deadline.replace(" ", "T") + "Z") : "—"}
{t.overdue && <span className="block text-[11.5px] font-normal">overdue</span>}
</td>
<td className="text-right">
<a href={t.url} target="_blank" rel="noopener" className="text-muted hover:text-brand inline-flex" aria-label="Open in Odoo"><ArrowUpRight className="size-4" /></a>
</td>
</tr>
))}
</tbody>
</table>
) : (
<Empty title="No tasks match">Clear the filters to see every open task.</Empty>
)}
</Panel>
</div>
<NewTaskDialog open={creating} onClose={() => setCreating(false)} data={data} onCreated={load} />
</div>
);
}
function NewTaskDialog({ open, onClose, data, onCreated }) {
const [form, setForm] = useState({ projectId: "", name: "", description: "", userIds: [], deadline: "" });
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
useEffect(() => { if (open) { setForm({ projectId: "", name: "", description: "", userIds: [], deadline: "" }); setError(null); } }, [open]);
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
const toggleUser = (id) => setForm((f) => ({ ...f, userIds: f.userIds.includes(id) ? f.userIds.filter((x) => x !== id) : [...f.userIds, id] }));
const submit = async () => {
setBusy(true);
setError(null);
try {
await api("/api/team/tasks", { method: "POST", body: form });
onClose();
onCreated();
} catch (e) {
setError(e);
} finally {
setBusy(false);
}
};
return (
<Dialog
open={open}
onClose={onClose}
title="New task in Odoo"
footer={<><Button onClick={onClose}>Cancel</Button><Button variant="primary" loading={busy} disabled={!form.projectId || !form.name.trim()} onClick={submit}>Create task</Button></>}
>
<div className="space-y-3.5">
<Field label="Project">
<select className="input" value={form.projectId} onChange={set("projectId")}>
<option value="">Choose a project</option>
{data.projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</Field>
<Field label="Task">
<input className="input" value={form.name} onChange={set("name")} placeholder="e.g. Reel: dry eyes in furnace season (Wallaceton)" />
</Field>
<Field label="Brief" hint="Paste the creative brief: hook, format, length, on-screen text, CTA.">
<textarea className="input" rows={5} value={form.description} onChange={set("description")} />
</Field>
<div className="grid grid-cols-[1fr_160px] gap-3">
<Field label="Assign to">
<div className="border border-line-strong rounded-md max-h-[132px] overflow-y-auto divide-y divide-line">
{data.workload.map((w) => (
<label key={w.id} className="flex items-center justify-between gap-2 px-2.5 py-1.5 text-[13px] cursor-pointer hover:bg-[#f8fafc]">
<span className="flex items-center gap-2"><input type="checkbox" className="accent-brand" checked={form.userIds.includes(w.id)} onChange={() => toggleUser(w.id)} />{w.name}</span>
<span className="text-[11.5px] text-muted num">{w.open} open</span>
</label>
))}
</div>
</Field>
<Field label="Deadline">
<input className="input" type="date" value={form.deadline} onChange={set("deadline")} />
</Field>
</div>
<ErrorNote error={error} />
</div>
</Dialog>
);
}