// 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}` : ""}
);
})}
{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.id === obj.team[0] &&
ГІП}
{marks.length > 0 ? marks.map(m => {m})
: розділів не веде}
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}
зауваження
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;