setFilterAssignee(v === ALL ? "" : v)}
placeholder="All members"
emptyText="No matching members"
getLabel={c => c === ALL ? "All members" : (ROLES[c] && ROLES[c].name) || c}
renderOption={c => c === ALL
? All members
: ({ROLES[c].name}{ROLES[c].label})
}
/>
);
}
function App({ authUser, onLogout, theme, setTheme }) {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const [role, setRoleRaw] = useState(authUser);
const [project, setProject] = useState(RA[authUser] && RA[authUser].type === "dm" ? "all" : "alpha");
const [tasks, setTasks] = useState([]);
const [bonus, setBonus] = useState([]);
const [notifs, setNotifs] = useState([]);
const [deliverables, setDeliverables] = useState([]);
const [delivId, setDelivId] = useState(null);
const [createDelivOpen, setCreateDelivOpen] = useState(false);
const [createCtx, setCreateCtx] = useState(null); // {deliverable, project} when breaking a deliverable down
const [dmTab, setDmTab] = useState(() => { try { return localStorage.getItem("tq_dm_tab") || "backlog"; } catch(e) { return "backlog"; } });
const [filterAssignee, setFilterAssignee] = useState("");
const [booted, setBooted] = useState(false);
const [bootErr, setBootErr] = useState("");
const [detailId, setDetailId] = useState(null);
const [moveModal, setMoveModal] = useState(null); // { kind, taskId }
const [createOpen, setCreateOpen] = useState(false);
const [bonusModal, setBonusModal] = useState(null); // { to, project }
const [notifOpen, setNotifOpen] = useState(false);
const [toasts, setToasts] = useState([]);
const [viewKey, setViewKey] = useState(0);
const [dataVersion, setDataVersion] = useState(0);
const [view, setView] = useState("board"); // board | timesheet/timesheets | delivery
// accent application (theme lives in Root)
useEffect(() => {
const root = document.documentElement.style;
root.setProperty("--accent", t.accent);
root.setProperty("--accent-hover", t.accent);
root.setProperty("--accent-soft", t.accent + "26");
}, [t.accent]);
useEffect(() => { setViewKey(k => k + 1); }, [project]);
// re-render when the timesheet store changes (badges, review queue, card logger)
useEffect(() => window.TQ_TIME.subscribe(() => setDataVersion(v => v + 1)), []);
// ---------- initial load + realtime ----------
async function reloadTasks() { setTasks(await BE.loadTasks()); }
async function reloadDeliverables() { setDeliverables(await BE.loadDeliverables()); }
async function reloadBonus() { setBonus(await BE.loadBonus()); }
async function reloadNotifs() { setNotifs(await BE.loadNotifications()); }
async function reloadProfiles() { const m = await BE.loadProfiles(); if (m) { window.TQ.applyProfiles(m); setDataVersion(v => v + 1); } }
async function reloadProjects() { const m = await BE.loadProjects(); if (m) { window.TQ.applyProjects(m); setDataVersion(v => v + 1); } }
async function reloadConfig() { const c = await BE.loadConfig(); if (c) { window.TQ.applyConfig(c); setDataVersion(v => v + 1); } }
useEffect(() => {
let unsub = function () {};
(async () => {
try {
const profiles = await BE.loadProfiles();
if (profiles) window.TQ.applyProfiles(profiles);
const projects = await BE.loadProjects();
if (projects) window.TQ.applyProjects(projects);
const cfg = await BE.loadConfig();
if (cfg) window.TQ.applyConfig(cfg);
const [tk, dl, bn, nt] = await Promise.all([BE.loadTasks(), BE.loadDeliverables(), BE.loadBonus(), BE.loadNotifications()]);
setTasks(tk); setDeliverables(dl); setBonus(bn); setNotifs(nt);
// Demo hours are seeded ONLY in offline preview — never against a live workspace.
if (!BE.CONFIGURED) { try { window.TQ_TIME.ensureSeed(tk); } catch (e) {} }
// pick a sensible default project for this user
const me = RA[authUser];
if (me && me.type === "dm") setProject("all");
else if (!PA[project]) {
const first = Object.keys(PA)[0];
if (first) setProject(first);
}
setBooted(true);
unsub = BE.subscribe({ reloadTasks, reloadDeliverables, reloadBonus, reloadNotifs, reloadProfiles, reloadProjects, reloadConfig });
} catch (err) {
console.error("[TaskQuest] load failed", err);
setBootErr(err.message || "Could not load your workspace.");
setBooted(true);
}
})();
return () => { try { unsub(); } catch (e) {} };
}, []);
// ---------- persistence helper ----------
function persist(fn) {
Promise.resolve().then(fn).catch(err => {
console.error("[TaskQuest] save failed", err);
addToast("Save failed — change not stored", Ic.Alert, "#eb5757");
});
}
function setRole(next) {
setRoleRaw(next);
setViewKey(k => k + 1);
setView("board");
const nr = RA[next];
if (!nr) return;
if (nr.type === "dm") setProject("all");
else if (nr.type === "pm" || nr.type === "consultant") setProject(window.TQ.projectsOf(nr)[0] || Object.keys(PA)[0] || "alpha");
else if (project === "all") setProject(Object.keys(PA)[0] || "alpha");
}
// ---------- gates ----------
if (!booted) return ;
if (bootErr) return ;
const r = RA[role] || RA[authUser];
if (!r) return ;
const isManager = r.type === "pm" || r.type === "dm";
const canSwitchRole = !BE.CONFIGURED || r.type === "admin"; // prototype tool: locked in prod (admins may impersonate)
const detailTask = detailId ? tasks.find(x => x.id === detailId) : null;
const moveTask = moveModal ? tasks.find(x => x.id === moveModal.taskId) : null;
const detailDeliv = delivId ? deliverables.find(x => x.id === delivId) : null;
// ---------- helpers ----------
function addToast(text, icon, color) {
const id = genId("t");
setToasts(ts => [...ts, { id, text, icon, color: color || "var(--accent)" }]);
setTimeout(() => setToasts(ts => ts.filter(x => x.id !== id)), 3200);
}
function pushNotif(n) {
const full = { id: genId("n"), read: false, time: "just now", ...n };
setNotifs(ns => [full, ...ns]);
persist(() => BE.insertNotif(full));
}
function updateTask(id, fn) {
setTasks(ts => {
const next = ts.map(x => x.id === id ? fn(x) : x);
const changed = next.find(x => x.id === id);
if (changed) persist(() => BE.saveTask(changed));
return next;
});
}
function addComment(id, author, text, system) {
updateTask(id, x => ({ ...x, comments: [...(x.comments || []), { author, text, time: "just now", system: !!system }] }));
}
// ---------- move / drag ----------
function onMove(task, toCol) {
const kind = window.needsModal(task.status, toCol);
if (!kind) {
updateTask(task.id, x => ({ ...x, status: toCol }));
const col = COLA.find(c => c.key === toCol);
addComment(task.id, role, `Moved to ${col.name}.`, true);
addToast(`Moved to ${col.name}`, Ic.Arrow, col.color);
return;
}
setMoveModal({ kind, taskId: task.id });
}
function submitMove(data) {
const task = moveTask;
if (!task) return;
if (moveModal.kind === "date") {
updateTask(task.id, x => ({ ...x, status: "unhappy",
request: { newDate: data.newDate, reason: data.reason || "—", comments: data.comments, status: "pending" } }));
addComment(task.id, role, `Requested a date change to ${window.TQ.fmtDate(data.newDate)}.${data.comments ? " " + data.comments : ""}`);
pushNotif({ type: "date_request", who: role, project: task.project, forRoles: ["pm", "dm"],
text: `${RA[task.assignee].name} requested a date change on ${task.name}` });
addToast("Date change submitted for approval", Ic.Calendar, "#f2c94c");
} else if (moveModal.kind === "hold") {
updateTask(task.id, x => ({ ...x, status: "onhold",
request: { reason: data.reason, comments: data.comments, status: "hold" } }));
addComment(task.id, role, `Moved to On Hold — ${data.reason}.${data.comments ? " " + data.comments : ""}`);
pushNotif({ type: "comment", who: role, project: task.project, forRoles: ["pm", "dm"],
text: `${RA[task.assignee].name} put ${task.name} on hold — ${data.reason}` });
addToast("Task moved to On Hold", Ic.Pause, "#878b94");
} else if (moveModal.kind === "done") {
updateTask(task.id, x => ({ ...x, status: "done",
completion: { onTime: data.onTime, status: "pending", comments: data.comments || "Marked complete." } }));
addComment(task.id, role, `Marked done${data.onTime ? " on time" : " (late)"}.${data.comments ? " " + data.comments : ""}`);
pushNotif({ type: "done", who: role, project: task.project, forRoles: ["pm", "dm"],
text: `${RA[task.assignee].name} marked ${task.name} as done — needs approval` });
addToast(data.onTime ? "Completed on time! Pending sign-off" : "Marked done — pending sign-off",
data.onTime ? Ic.Star : Ic.Check, data.onTime ? "#4cb782" : "#878b94");
}
setMoveModal(null);
}
// ---------- approvals ----------
function approveDate(task, approvedDate) {
updateTask(task.id, x => ({ ...x, due: approvedDate,
request: { ...x.request, status: "approved", approvedDate } }));
addComment(task.id, role, `Approved the date change. New due date: ${window.TQ.fmtDateLong(approvedDate)}. Move it back to Happy Path to resume.`, true);
pushNotif({ type: "date_approved", who: role, project: task.project, forRoles: [task.assignee],
text: `Your date change on ${task.name} was approved — new date ${window.TQ.fmtDate(approvedDate)}` });
addToast("Date change approved", Ic.CheckCircle, "#4cb782");
}
function rejectDate(task) {
updateTask(task.id, x => ({ ...x, request: { ...x.request, status: "rejected" } }));
addComment(task.id, role, `Rejected the date change. Please keep the original due date.`, true);
pushNotif({ type: "rejected", who: role, project: task.project, forRoles: [task.assignee],
text: `Your date change on ${task.name} was rejected` });
addToast("Date change rejected", Ic.X, "#eb5757");
}
function approveDone(task) {
updateTask(task.id, x => ({ ...x, completion: { ...x.completion, status: "approved" } }));
addComment(task.id, role, `Approved the completion.${task.completion.onTime ? " +10 points awarded." : ""}`, true);
pushNotif({ type: "approved", who: role, project: task.project, forRoles: [task.assignee],
text: `Your completion of ${task.name} was approved${task.completion.onTime ? " — +10 points" : ""}` });
addToast(task.completion.onTime ? "Completion approved · +10 points" : "Completion approved", Ic.CheckCircle, "#4cb782");
}
// ---------- delete task (manager removes a task that is no longer needed) ----------
async function removeTask(id) {
setTasks(ts => ts.filter(t => t.id !== id));
try { await BE.deleteTask(id); } catch (err) { console.error(err); }
}
// ---------- reassign (manager moves a task to a different team member) ----------
function reassignTask(task, newAssignee) {
if (!newAssignee || newAssignee === task.assignee) return;
const prev = task.assignee;
updateTask(task.id, x => ({ ...x, assignee: newAssignee }));
addComment(task.id, role, `Reassigned from ${RA[prev] ? RA[prev].name : prev} to ${RA[newAssignee] ? RA[newAssignee].name : newAssignee}.`, true);
pushNotif({ type: "comment", who: role, project: task.project, forRoles: [newAssignee],
text: `${RA[role].name} reassigned ${task.name} to you` });
addToast("Task reassigned", Ic.Users, "#5E6AD2");
}
// ---------- comment ----------
function onComment(task, text) {
addComment(task.id, role, text);
if (r.type === "consultant") {
pushNotif({ type: "comment", who: role, project: task.project, forRoles: ["pm", "dm"],
text: `${RA[role].name} added a progress comment on ${task.name}` });
}
}
// ---------- create ----------
function createTask(data) {
const nt = window.TQ.mkTask({ name: data.name, project: data.project, assignee: data.assignee, due: data.due, status: "happy",
deliverable: data.deliverable || null,
description: data.description || "",
subcategory: data.subcategory || null,
comments: data.comments ? [{ author: role, text: data.comments, time: "just now" }] : [] });
setTasks(ts => [nt, ...ts]);
persist(() => BE.insertTask(nt));
pushNotif({ type: "comment", who: role, project: data.project, forRoles: [data.assignee],
text: `${RA[role].name} assigned you a new task: ${data.name}` });
if (nt.deliverable) {
const dl = deliverables.find(d => d.id === nt.deliverable);
addDeliverableComment(nt.deliverable, role, `Added task “${data.name}” → ${RA[data.assignee] ? RA[data.assignee].name : "the team"}.`, true);
if (dl) pushNotif({ type: "comment", who: role, project: dl.project, forRoles: [dl.createdBy || "dm", "dm"],
text: `${RA[role].name} broke down ${dl.name} into a new task` });
}
addToast("Task created", Ic.Plus, "#5E6AD2");
setCreateOpen(false);
setCreateCtx(null);
}
// ---------- deliverables (DM creates & tracks; PM breaks down) ----------
function addDeliverableComment(id, author, text, system) {
setDeliverables(ds => {
const next = ds.map(x => x.id === id ? { ...x, comments: [...(x.comments || []), { author, text, time: "just now", system: !!system }] } : x);
const ch = next.find(x => x.id === id);
if (ch) persist(() => BE.saveDeliverable(ch));
return next;
});
}
function onDeliverableComment(d, text) { addDeliverableComment(d.id, role, text); }
function createDeliverable(data) {
const nd = window.TQ.mkDeliverable({ name: data.name, project: data.project, pm: data.pm, due: data.due,
priority: data.priority, desc: data.desc, createdBy: role });
setDeliverables(ds => [nd, ...ds]);
persist(() => BE.insertDeliverable(nd));
pushNotif({ type: "comment", who: role, project: data.project, forRoles: [data.pm],
text: `${RA[role].name} assigned you a deliverable: ${data.name}` });
addToast("Deliverable created", Ic.Layers, "var(--accent)");
setCreateDelivOpen(false);
}
function acceptDeliverable(d) {
setDeliverables(ds => {
const next = ds.map(x => x.id === d.id ? { ...x, accepted: true } : x);
const ch = next.find(x => x.id === d.id);
if (ch) persist(() => BE.saveDeliverable(ch));
return next;
});
addDeliverableComment(d.id, role, "Accepted the delivery — signed off and closed.", true);
pushNotif({ type: "approved", who: role, project: d.project, forRoles: [d.pm],
text: `${RA[role].name} accepted your delivery of ${d.name}` });
addToast("Delivery accepted", Ic.CheckCircle, "#8b7cf6");
}
function openCreateForDeliverable(d) {
setCreateCtx({ deliverable: d.id, project: d.project });
setCreateOpen(true);
}
// ---------- bonus ----------
function awardBonus(data) {
const b = { id: genId("b"), to: data.to, pts: data.pts, by: role, comment: data.comment, project: data.project, time: "just now" };
setBonus(bs => [b, ...bs]);
persist(() => BE.insertBonus(b));
pushNotif({ type: "bonus", who: role, project: data.project, forRoles: [data.to],
text: `${RA[role].name} awarded you +${data.pts} points — \u201c${data.comment}\u201d` });
addToast(`Awarded +${data.pts} to ${RA[data.to].short}`, Ic.Sparkle, "#e0902b");
setBonusModal(null);
}
// ---------- notifications ----------
const visibleNotifs = notifs.filter(n => window.TQ.notifVisible(n, role, r.type));
function markAll() {
const ids = visibleNotifs.filter(n => !n.read).map(n => n.id);
setNotifs(ns => ns.map(n => window.TQ.notifVisible(n, role, r.type) ? { ...n, read: true } : n));
persist(() => BE.markNotifsRead(ids));
}
function clickNotif(n) {
setNotifs(ns => ns.map(x => x.id === n.id ? { ...x, read: true } : x));
persist(() => BE.markNotifsRead([n.id]));
setNotifOpen(false);
}
// ---------- admin: users, projects, points config ----------
async function createUser(data) {
try {
await BE.provisionUser(data);
await reloadProfiles();
setDataVersion(v => v + 1);
addToast(`Login created for ${data.name}`, Ic.Users, "#5E6AD2");
return { ok: true };
} catch (err) {
console.error(err);
addToast("Could not create the account", Ic.Alert, "#eb5757");
return { ok: false, message: (err && err.message) || "Could not create the account." };
}
}
async function createProject(data) {
try {
const key = await BE.insertProject(data);
await reloadProjects();
setDataVersion(v => v + 1);
addToast(`Created ${(PA[key] && PA[key].name) || data.name}`, Ic.Folder, "#16a394");
} catch (err) { console.error(err); addToast("Could not create project", Ic.Alert, "#eb5757"); }
}
async function createProjects(list) {
try {
const keys = await BE.insertProjects(list);
await reloadProjects();
setDataVersion(v => v + 1);
const n = (keys && keys.length) || list.length;
addToast(`Created ${n} project${n === 1 ? "" : "s"}`, Ic.Folder, "#16a394");
return { ok: true };
} catch (err) {
console.error(err);
addToast("Could not create projects", Ic.Alert, "#eb5757");
return { ok: false, message: (err && err.message) || "Could not create the projects." };
}
}
async function editProject(key, data) {
try {
await BE.updateProject(key, data);
await reloadProjects();
setDataVersion(v => v + 1);
addToast("Project updated", Ic.Folder, "#16a394");
return { ok: true };
} catch (err) {
console.error(err);
addToast("Could not update project", Ic.Alert, "#eb5757");
return { ok: false, message: (err && err.message) || "Could not update the project." };
}
}
async function removeProject(key) {
try {
await BE.deleteProject(key);
await reloadProjects();
await reloadTasks();
setDataVersion(v => v + 1);
addToast("Project removed", Ic.Check, "#16a394");
return { ok: true };
} catch (err) {
console.error(err);
addToast("Could not remove project", Ic.Alert, "#eb5757");
return { ok: false, message: (err && err.message) || "Could not remove the project." };
}
}
async function updateUser(data) {
try {
await BE.updateProfile(data.id, { name: data.name, type: data.type, username: data.username, projects: data.projects });
if (data.password) await BE.resetPassword(data.id, data.password);
await reloadProfiles();
setDataVersion(v => v + 1);
addToast(`Updated ${data.name}`, Ic.Check, "#16a394");
return { ok: true };
} catch (err) {
console.error(err);
addToast("Could not update the profile", Ic.Alert, "#eb5757");
return { ok: false, message: (err && err.message) || "Could not update the profile." };
}
}
async function deleteUser(id) {
try {
await BE.deleteUser(id);
await reloadProfiles();
await reloadTasks();
setDataVersion(v => v + 1);
addToast("User removed", Ic.Check, "#16a394");
return { ok: true };
} catch (err) {
console.error(err);
addToast("Could not remove the user", Ic.Alert, "#eb5757");
return { ok: false, message: (err && err.message) || "Could not remove the user." };
}
}
function saveConfig(cfg) {
window.TQ.applyConfig(cfg);
setDataVersion(v => v + 1);
persist(() => BE.saveConfig(cfg));
addToast("Points system updated", Ic.Target, "#e0902b");
}
// page heading
const heading = r.type === "consultant" ? "My Board"
: r.type === "pm" ? "Project Board"
: r.type === "admin" ? "System Administration" : "Delivery Backlog";
const subline = r.type === "consultant" ? "Your assigned tasks, points and rank"
: r.type === "pm" ? "Break down deliverables, track the team and approve work"
: r.type === "admin" ? "Manage members, projects and the points system"
: "Assign deliverables to project managers and track delivery";
const tileLayout = t.tileLayout === "comfortable" ? "" : t.tileLayout;
// ---------- view tabs (Board / Timesheet) ----------
const tsWeek = window.TQ_TIME.thisWeekStart();
let tsBadge = 0;
if (isManager) {
const scopeCons = CA.filter(c => (r.type === "dm" && project === "all") ? true : window.TQ.projectsOf(RA[c]).indexOf(project) >= 0);
tsBadge = scopeCons.filter(c => window.TQ_TIME.statusOf(c, tsWeek).status === "submitted").length;
}
const tabs = r.type === "consultant"
? [{ key: "board", label: "My Board", icon: Ic.Layers }, { key: "timesheet", label: "Timesheet", icon: Ic.Clock }]
: r.type === "pm"
? [{ key: "board", label: "Project Board", icon: Ic.Layers }, { key: "timesheets", label: "Timesheets", icon: Ic.Clock, count: tsBadge }]
: r.type === "dm"
? [{ key: "delivery", label: "Delivery", icon: Ic.Target }, { key: "timesheets", label: "Timesheets", icon: Ic.Clock, count: tsBadge }]
: null;
const curView = (tabs && tabs.some(x => x.key === view)) ? view : (tabs ? tabs[0].key : "board");
const onTsView = curView === "timesheet" || curView === "timesheets";
return (
<>
setTheme(th => th === "dark" ? "light" : "dark")}
authUser={authUser} onLogout={onLogout} canSwitchRole={canSwitchRole}
notifications={visibleNotifs}
onOpenNotif={() => setNotifOpen(o => !o)} notifOpen={notifOpen}
notifPanel={ setNotifOpen(false)} onMarkAll={markAll} onClickNotif={clickNotif} />} />