// tree-page.jsx — «Дерево задач»: ієрархія Об'єкт → Етап → Задача → Підзадача // з розгортанням/згортанням, агрегацією прогресу, фільтрами та інлайн-діями. function TreePage({ tasks, onOpenTask, onToggle, onTaskPatch, role }) { const { useState, useMemo, useCallback } = React; const objects = window.DATA.OBJECTS; const team = window.DATA.TEAM; const allStages = [...window.DATA.STAGES, ...(window.DATA.CORRECTION_STAGES || [])]; const stageById = useMemo(() => { const m = {}; allStages.forEach(s => m[s.id] = s); return m; }, []); // Поточний користувач (для фільтра «Тільки мої») const meId = (window.API && window.API.mode === "live" && window.API.user && window.API.user.teamId) || "ok"; // ── Фільтри ───────────────────────────────────────────── const [filterAssignee, setFilterAssignee] = useState("all"); // all | mine | const [filterStatus, setFilterStatus] = useState("all"); // all | todo | live | late | done const [query, setQuery] = useState(""); // ── Стан розгорнутих гілок (persist) ──────────────────── const STORE_KEY = "erp_tree_expanded_v1"; const [expanded, setExpanded] = useState(() => { try { const raw = localStorage.getItem(STORE_KEY); if (raw) return JSON.parse(raw); } catch (e) {} // дефолт: усі об'єкти й етапи розгорнуті, задачі згорнуті const init = {}; objects.forEach(o => { init["obj:" + o.id] = true; }); init["obj:admin"] = true; return init; }); const persist = (next) => { setExpanded(next); try { localStorage.setItem(STORE_KEY, JSON.stringify(next)); } catch (e) {} }; const isOpen = (key, def) => key in expanded ? expanded[key] : !!def; const toggleKey = (key, def) => { const cur = key in expanded ? expanded[key] : !!def; persist({ ...expanded, [key]: !cur }); }; // ── Фільтрація задач ──────────────────────────────────── const q = query.trim().toLowerCase(); const matchTask = (t) => { if (filterAssignee === "mine" && t.assignee !== meId) return false; if (filterAssignee !== "all" && filterAssignee !== "mine" && t.assignee !== filterAssignee) return false; if (filterStatus !== "all" && t.status !== filterStatus) return false; if (q) { const obj = window.getObject(t.obj); const hay = (t.title + " " + (obj?.code || "") + " " + (obj?.name || "") + " " + (t.section || "")).toLowerCase(); if (!hay.includes(q)) return false; } return true; }; const filtered = tasks.filter(matchTask); // ── Агрегація ─────────────────────────────────────────── const agg = (list) => { const a = { total: list.length, done: 0, live: 0, late: 0, todo: 0 }; list.forEach(t => { a[t.status] = (a[t.status] || 0) + 1; }); a.pct = a.total ? Math.round(a.done / a.total * 100) : 0; return a; }; // ── Групування: об'єкт → етап → задачі ────────────────── const groups = useMemo(() => { const byObj = {}; filtered.forEach(t => { const key = t.obj || "admin"; (byObj[key] = byObj[key] || []).push(t); }); // порядок об'єктів як у DATA, адмін — в кінці const ordered = objects .filter(o => byObj[o.id]) .map(o => ({ obj: o, tasks: byObj[o.id] })); if (byObj["admin"]) { ordered.push({ obj: { id: "admin", code: "АДМ", name: "Поза об'єктами", admin: true }, tasks: byObj["admin"], }); } return ordered; }, [filtered, objects]); const totalAgg = agg(filtered); // ── Розгорнути / згорнути все ─────────────────────────── const setAll = (open) => { const next = {}; groups.forEach(g => { next["obj:" + g.obj.id] = open; const stages = [...new Set(g.tasks.map(t => t.stage || "admin"))]; stages.forEach(sid => { next["stage:" + g.obj.id + ":" + sid] = open; }); if (open) g.tasks.forEach(t => { if ((t.subtasks || []).length) next["task:" + t.id] = true; }); }); persist(next); }; // ── Інлайн: тогл підзадачі ────────────────────────────── const toggleSub = (taskId, subId) => { if (!onTaskPatch) return; onTaskPatch(taskId, (x) => ({ ...x, subtasks: (x.subtasks || []).map(s => s.id === subId ? { ...s, status: s.status === "done" ? "todo" : "done" } : s), })); }; // стейт-пілюлі const statusPills = [ { id: "all", label: "Усі" }, { id: "todo", label: "До виконання" }, { id: "live", label: "У роботі" }, { id: "late", label: "Прострочено" }, { id: "done", label: "Виконано" }, ]; const activeFilter = filterAssignee !== "all" || filterStatus !== "all" || q; return (

Дерево задач

{groups.length} {window.plural(groups.length, "об'єкт", "об'єкти", "об'єктів")} {totalAgg.total} {window.plural(totalAgg.total, "задача", "задачі", "задач")} {totalAgg.late > 0 && <> {totalAgg.late} прострочено } {totalAgg.pct}% виконано
{/* Тулбар */}
setQuery(e.target.value)} /> {query && }
{team.filter(p => p.employment !== "contractor").map(p => ( ))}
{statusPills.map(s => ( ))}
{/* Дерево */}
{groups.length === 0 && (
{activeFilter ? "За цим фільтром задач немає." : "Задач ще немає."}
)} {groups.map(g => { const objKey = "obj:" + g.obj.id; const objOpen = isOpen(objKey, true); const objAgg = agg(g.tasks); // етапи цього об'єкта в порядку allStages, admin — як псевдоетап const stageIds = [...new Set(g.tasks.map(t => t.stage || "admin"))]; const orderedStages = allStages .map(s => s.id) .filter(id => stageIds.includes(id)); if (stageIds.includes("admin")) orderedStages.push("admin"); return (
{/* ── Об'єкт ── */}
toggleKey(objKey, true)}> {g.obj.code} {g.obj.name} {g.obj.deadline && Дедлайн {g.obj.deadline}} {objAgg.late > 0 && {objAgg.late} прострочено} {objAgg.live > 0 && {objAgg.live} у роботі} {objAgg.done}/{objAgg.total}
{/* ── Етапи ── */} {objOpen && (
{orderedStages.map(sid => { const stage = sid === "admin" ? { id: "admin", name: "Адміністративні", short: "Адмін" } : stageById[sid]; const stageTasks = g.tasks.filter(t => (t.stage || "admin") === sid); const stageKey = "stage:" + g.obj.id + ":" + sid; const stageOpen = isOpen(stageKey, true); const sAgg = agg(stageTasks); const color = stageColor(sid); return (
toggleKey(stageKey, true)}> {stage?.name || sid} {sAgg.late > 0 && } {sAgg.done}/{sAgg.total}
{/* ── Задачі ── */} {stageOpen && (
{stageTasks.map(t => ( toggleKey("task:" + t.id, false)} onToggleDone={() => onToggle(t)} onOpen={() => onOpenTask(t)} onToggleSub={(sid2) => toggleSub(t.id, sid2)} /> ))}
)}
); })}
)}
); })}
); } // ── Вузол задачі ────────────────────────────────────────── function TreeTask({ task, open, onToggleOpen, onToggleDone, onOpen, onToggleSub }) { const team = window.getTeam(task.assignee); const subs = task.subtasks || []; const subDone = subs.filter(s => s.status === "done").length; const isDone = task.status === "done"; const isLive = task.status === "live"; const isLate = task.status === "late"; const hasSubs = subs.length > 0; return (
{hasSubs ? : } {task.title} {task.section && {task.section}} {hasSubs && ( {subDone}/{subs.length} )} {isLate && Прострочено} {isLive && У роботі} {!isDone && task.est && {task.est}} {team && {team.initials}}
{/* ── Підзадачі ── */} {open && hasSubs && (
{subs.map(s => { const sp = s.assignee ? window.getTeam(s.assignee) : null; const sd = s.status === "done"; return (
{s.title} {s.due && {s.due}} {sp && {sp.initials}}
); })}
)}
); } // ── Дрібнички ───────────────────────────────────────────── function Chevron({ open, small }) { return ( ); } function MiniBar({ pct, small, color }) { return (
{pct}%
); } function stageColor(sid) { const base = sid.replace(/^c-/, ""); const map = { project: "#068B49", estimate: "#015B76", expertise: "oklch(0.66 0.13 65)", edessb: "oklch(0.58 0.12 300)", supervision: "oklch(0.6 0.11 250)", admin: "var(--ink-3)", }; return map[base] || "var(--ink-3)"; } // ── Стилі (інжектуються разом зі сторінкою) ─────────────── function TreeStyles() { return ( ); } window.TreePage = TreePage;