// project-workspace-tabs.jsx — вкладки робочого простору об'єкта: // Етапи й задачі · Графік (Гант) · Команда · Фінанси · Нагляд // ============ ЕТАПИ Й ЗАДАЧІ ============ function PWStages({ obj, tasks, onOpenTask, onToggleTask }) { const stages = [...window.DATA.STAGES, ...(window.DATA.CORRECTION_STAGES || [])]; const objTasks = tasks.filter(t => t.obj === obj.id); const [open, setOpen] = React.useState(() => ({ [obj.activeStage]: true })); const visible = stages.filter(s => { const planned = obj.plan?.[s.id] || 0; const has = objTasks.some(t => t.stage === s.id); return planned > 0 || has; }); return (
{visible.map(s => { const list = objTasks.filter(t => t.stage === s.id); const done = list.filter(t => t.status === "done").length; const planned = obj.plan?.[s.id] || 0; const isActive = s.id === obj.activeStage; const isCompleted = obj.completedStages?.includes(s.id) || (planned > 0 && done >= planned); const isOpen = open[s.id]; const isCorr = (s.id || "").startsWith("c-"); return (
setOpen(o => ({ ...o, [s.id]: !o[s.id] }))}> {s.name} {isCorr && коригування} {isActive && !isCompleted && активний} {isCompleted && завершено} {done} з {planned || list.length} задач
{isOpen && (
{list.length === 0 ?
Задач ще не створено. Декомпозуйте етап з шаблону.
:
{list.map(t => )}
}
)}
); })}
); } // ============ ГРАФІК (реальний Гант по об'єкту) ============ function pwMonths(fromIso, toIso) { const res = []; let [y, m] = fromIso.split("-").map(Number); const [ty, tm] = toIso.split("-").map(Number); let guard = 0; while ((y < ty || (y === ty && m <= tm)) && guard++ < 48) { res.push({ y, m }); m++; if (m > 12) { m = 1; y++; } } return res; } const PW_MON = ["січ", "лют", "бер", "квіт", "трав", "черв", "лип", "серп", "вер", "жовт", "лист", "груд"]; function pwMoneyShort(n) { if (n >= 1e6) return (n / 1e6).toFixed(1).replace(".0", "") + " млн"; if (n >= 1e3) return Math.round(n / 1e3) + "к"; return "" + n; } function PWGantt({ obj, tasks }) { const sched = window.PM.scheduleFor(obj.id); if (!sched) return
Графік для об'єкта ще не сформовано.
; const months = pwMonths(sched.from, sched.to); const span = window.PM.ms(sched.to) - window.PM.ms(sched.from); const xPct = (iso) => Math.max(0, Math.min(100, (window.PM.ms(iso) - window.PM.ms(sched.from)) / span * 100)); const todayFrac = Math.max(0, Math.min(1, (window.PM.ms(window.PM.TODAY_ISO) - window.PM.ms(sched.from)) / span)); const allStages = [...window.DATA.STAGES, ...(window.DATA.CORRECTION_STAGES || [])]; const rows = allStages.filter(s => sched.stages[s.id]).map(s => { const [start, end] = sched.stages[s.id]; const planned = obj.plan?.[s.id] || 0; const done = tasks.filter(t => t.obj === obj.id && t.stage === s.id && t.status === "done").length; const isActive = s.id === obj.activeStage; const isCompleted = obj.completedStages?.includes(s.id) || window.PM.ms(end) < window.PM.ms(window.PM.TODAY_ISO) && !isActive; return { s, start, end, isActive, isCompleted, planned, done }; }); const k = window.PM.contractFor(obj.id); const markers = k ? k.schedule .filter(s => window.PM.ms(s.dueDate) >= window.PM.ms(sched.from) && window.PM.ms(s.dueDate) <= window.PM.ms(sched.to)) .map(s => ({ x: xPct(s.dueDate), amount: s.amount, label: s.stage, paid: !!s.paidDate, date: s.dueDate })) : []; const LABEL_W = 200; return (
Календарний графік по етапах · {window.formatDate(sched.from)} — {window.formatDate(sched.to)}
завершено активний попереду платіж
Етап
{months.map((mo, i) => { const isCur = mo.y === 2026 && mo.m === 5; return
{PW_MON[mo.m - 1]}{String(mo.y).slice(2)}
; })}
сьогодні
{rows.map(({ s, start, end, isActive, isCompleted, planned, done }) => { const left = xPct(start), width = Math.max(2, xPct(end) - xPct(start)); const cls = isCompleted ? "is-completed" : isActive ? "is-active" : "is-future"; return (
{s.name} {window.PM.dm(start)}—{window.PM.dm(end)}{planned > 0 ? ` · ${done}/${planned}` : ""}
{s.short}
); })} {markers.length > 0 && (
Платежі за договором
{markers.map((m, i) => (
{pwMoneyShort(m.amount)}
))}
)}
); } // ============ КОМАНДА ОБ'ЄКТА ============ function PWTeam({ obj, tasks }) { const objTasks = tasks.filter(t => t.obj === obj.id); const sections = window.PM.sectionsFor(obj.id); const members = (obj.team || []).map(id => { const p = window.getTeam(id); const active = objTasks.filter(t => t.assignee === id && t.status !== "done"); const late = active.filter(t => t.status === "late").length; const marks = sections.filter(s => s.lead === id).map(s => s.mark); const hours = objTasks.filter(t => t.assignee === id).reduce((a, t) => a + (window.PM.taskExtras(t.id).timeLogged || 0), 0); return { p, active: active.length, late, marks, hours }; }); const maxActive = Math.max(1, ...members.map(m => m.active)); return (
Команда об'єкта. Хто за які розділи відповідає, скільки активних задач і витрачених годин саме на цьому об'єкті.
{members.map(({ p, active, late, marks, hours }) => (
{p.initials}
{p.name}
{p.role}
{p.id === obj.team[0] && ГІП}
{marks.length > 0 ? marks.map(m => {m}) : розділів не веде}
{active}
активних задач
0 ? "is-late" : ""}`}>{late}
прострочено
{hours ? hours + " год" : "—"}
витрачено
))}
); } // ============ ФІНАНСИ ОБ'ЄКТА ============ function PWFinance({ obj }) { const k = window.PM.contractFor(obj.id); const expenses = (window.DATA.EXPENSES || []).filter(e => e.obj === obj.id); const directCost = expenses.reduce((a, e) => a + (e.amount || 0), 0); if (!k) return
Договір по об'єкту не знайдено.
; const paid = k.schedule.filter(s => s.paidDate).reduce((a, s) => a + s.amount, 0); const overdue = k.schedule.filter(s => !s.paidDate && window.PM.daysTo(s.dueDate) < 0).reduce((a, s) => a + s.amount, 0); const remaining = k.total - paid; return (
Сума договору
{pwMoneyShort(k.total)}
№ {k.number} від {window.formatDate(k.date)}
Оплачено
{pwMoneyShort(paid)}
{Math.round(paid / k.total * 100)}% від суми
0 ? "is-warn" : ""}`}>
Прострочена дебіторка
{overdue > 0 ? pwMoneyShort(overdue) : "0"}
{overdue > 0 ? "потребує дзвінка" : "усе вчасно"}
Прямі витрати
{directCost > 0 ? pwMoneyShort(directCost) : "0"}
субпідряд · експертиза

Графік платежів

{k.schedule.length} траншів
{k.schedule.map((s, i) => { const days = window.PM.daysTo(s.dueDate); let tone = "neutral", label = "Очікується"; if (s.paidDate) { tone = "live"; label = "Сплачено"; } else if (days < 0) { tone = "warn"; label = `Прострочено ${Math.abs(days)} дн`; } else if (days <= 30) { tone = "amber"; label = `Через ${days} дн`; } return ( ); })}
Етап оплати%СумаТермінСтатус
{s.stage}
{s.pct}% {window.formatMoney(s.amount)} ₴ {window.formatDate(s.dueDate)}{s.paidDate &&
сплач. {window.formatDate(s.paidDate)}
}
{label}
Разом100%{window.formatMoney(k.total)} ₴залишок {window.formatMoney(remaining)} ₴
{expenses.length > 0 && ( <>

Прямі витрати об'єкта

{expenses.length}
{expenses.map(e => ( ))}
ВитратаПостачальникСумаСтатус
{e.title}
{e.supplier} {window.formatMoney(e.amount)} ₴ {e.paid ? "сплачено" : "заплановано"}
)}
); } // ============ НАГЛЯД ТА ДОЗВІЛЬНІ ============ const PW_SUPERVISION = { "ukp-89": [ { date: "2026-05-26", who: "ok", title: "Перевірка вузлів примикання покрівлі", photos: 8, remarks: 2 }, { date: "2026-04-15", who: "ok", title: "Контроль монтажу вентиляції", photos: 12, remarks: 0 }, { date: "2026-03-22", who: "ap", title: "Зварювальні роботи каркасу", photos: 15, remarks: 1 }, { date: "2026-02-10", who: "ok", title: "Бетонування плит перекриття", photos: 22, remarks: 0 }, ], }; function PWSupervision({ obj }) { const permits = window.PM.permitsFor(obj.id); const journal = PW_SUPERVISION[obj.id] || []; const PSTATUS = { received: { t: "live", l: "Отримано" }, pending: { t: "warn", l: "Простій" }, in_progress: { t: "amber", l: "В процесі" } }; return (

Дозвільні документи та узгодження

{permits.length}
{permits.length === 0 ?
Дозвільних по об'єкту не зафіксовано.
: {permits.map((p, i) => { const st = PSTATUS[p.status] || PSTATUS.pending; return ( ); })}
ТипДокументОрганДатаСтатус
{p.kind}
{p.title}
{p.note &&
{p.note}
}
{p.body} {window.formatDate(p.date)} {st.l}
}

Журнал авторського нагляду

{journal.length ? `${journal.length} виїздів` : "об'єкт не на нагляді"}
{journal.length === 0 ?
Об'єкт ще не на стадії авторського нагляду. Журнал з'явиться після початку будівництва.
:
{journal.map((e, i) => { const p = window.getTeam(e.who); return (
АН{window.formatDate(e.date)}
{e.title}
{p?.name}
фото
{e.photos}
зауваження
0 ? "var(--late)" : "var(--live)", fontWeight: 600 }}>{e.remarks > 0 ? `${e.remarks}` : "немає"}
); })}
}
); } window.PWStages = PWStages; window.PWGantt = PWGantt; window.PWTeam = PWTeam; window.PWFinance = PWFinance; window.PWSupervision = PWSupervision;