- Server settings (Settings page): SMTP, AI, publishing, Meta, platform keys and, on the control centre only, Stripe/billing. Stored sealed in data/settings.json and applied over .env in every process; hosted agency workspaces don't see our email, platform keys or billing. - Odoo connection per workspace in Connections (login checked before saving). - provision-tenants.sh marks agency containers with QUORLA_HOSTED_TENANT=true. - docs/wildcard-subdomains.html: one-time setup for automatic agency subdomains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
10 KiB
JavaScript
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 || "An admin can connect it in Connections → Odoo (address, database, username and API key)."}
|
|
</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>
|
|
);
|
|
}
|