// project-workspace.jsx — повноцінний робочий простір ОДНОГО об'єкта (екран ГІПа)
// Вкладки: Огляд · Розділи · Етапи й задачі · Графік · Команда · Фінанси · Нагляд
// Вкладки Етапи/Графік/Команда/Фінанси/Нагляд — у project-workspace-tabs.jsx
const PW_TABS = [
{ id: "overview", label: "Огляд" },
{ id: "sections", label: "Розділи" },
{ id: "stages", label: "Етапи й задачі" },
{ id: "gantt", label: "Графік" },
{ id: "team", label: "Команда" },
{ id: "finance", label: "Фінанси" },
{ id: "supervision", label: "Нагляд" },
];
// ---- Зведення ризиків/уваги через усі дані ERP по об'єкту ----
function pwBuildAlerts(obj, tasks) {
const a = [];
const objTasks = tasks.filter(t => t.obj === obj.id);
objTasks.filter(t => t.status === "late").forEach(t =>
a.push({ tone: "warn", kind: "Прострочено", title: t.title, meta: window.getTeam(t.assignee)?.name || "" }));
window.PM.sectionsFor(obj.id).filter(s => s.status === "remarks").forEach(s =>
a.push({ tone: "warn", kind: "Зауваження", title: `${s.mark} · ${s.name}`, meta: s.note || "" }));
(window.DATA.INVOICES || []).filter(i => i.obj === obj.id && i.status === "overdue").forEach(i =>
a.push({ tone: "warn", kind: "Дебіторка", title: `Рахунок ${i.number} · ${window.formatMoney(i.amount)} ₴`, meta: `прострочка ${i.lateDays} дн` }));
window.PM.permitsFor(obj.id).filter(p => p.status === "pending").forEach(p =>
a.push({ tone: "amber", kind: p.kind, title: p.title, meta: `${p.body}${p.note ? " · " + p.note : ""}` }));
const k = window.PM.contractFor(obj.id);
if (k) k.schedule.filter(s => !s.paidDate).forEach(s => {
const d = window.PM.daysTo(s.dueDate);
if (d >= 0 && d <= 30) a.push({ tone: "blue", kind: "Платіж", title: `${s.stage} · ${window.formatMoney(s.amount)} ₴`, meta: `через ${d} дн · ${window.PM.dm(s.dueDate)}` });
});
(obj.team || []).forEach(id => {
const p = window.getTeam(id);
if (p && p.kep) {
const st = window.kepStatus(p.kep.validUntil);
if (st.state !== "active") a.push({ tone: st.state === "expired" ? "warn" : "amber", kind: "КЕП", title: p.name, meta: `${st.label.toLowerCase()} · до ${window.formatDate(p.kep.validUntil)}` });
}
});
return a;
}
// ---- Інфо по етапах (для смужки етапів) ----
function pwStageInfo(obj, tasks, stage) {
const list = tasks.filter(t => t.obj === obj.id && t.stage === stage.id);
const done = list.filter(t => t.status === "done").length;
const late = list.filter(t => t.status === "late").length;
const live = list.filter(t => t.status === "live").length;
const planned = obj.plan?.[stage.id] || 0;
const isCompleted = obj.completedStages?.includes(stage.id) || (planned > 0 && done >= planned);
const isActive = stage.id === obj.activeStage;
const isSkipped = planned === 0 && !isCompleted && !isActive;
return { stage, list, done, late, live, planned, total: list.length, isCompleted, isActive, isSkipped };
}
// ======================= ШЕЛЛ =======================
function ProjectWorkspace({ objId, tasks, onOpenTask, onToggleTask, onBack, role }) {
const obj = window.getObject(objId);
const [tab, setTab] = React.useState("overview");
React.useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onBack(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onBack]);
if (!obj) return
;
const client = window.DATA.CLIENTS?.find(c => c.short && obj.client.includes(c.short)) || null;
const gip = window.getTeam(obj.team?.[0]);
const scopeLabel = window.EXPERTISE_SCOPE_LABELS?.[obj.expertiseScope]?.label || "—";
const dlDays = obj.deadlineISO ? window.PM.daysTo(obj.deadlineISO) : null;
return (
{/* ── Хедер об'єкта ── */}
Об'єкти
{obj.code}
{obj.grade}
{scopeLabel}
{obj.name}
{obj.client}
{obj.address}
{obj.workType === "reconstruction" ? "Реконструкція" : obj.workType === "schema" ? "Схема намірів" : "Нове будівництво"}
{(obj.team || []).map(id => {
const p = window.getTeam(id);
return
{p?.initials}
;
})}
Drive ↗
{/* ── Вкладки ── */}
{PW_TABS.map(tb => {
let c = null;
if (tb.id === "sections") c = window.PM.sectionsFor(obj.id).length;
if (tb.id === "stages") c = tasks.filter(t => t.obj === obj.id && t.status !== "done").length;
return (
setTab(tb.id)}>
{tb.label}{c != null && c > 0 && {c} }
);
})}
{/* ── Контент ── */}
{tab === "overview" &&
}
{tab === "sections" &&
}
{tab === "stages" && window.PWStages &&
}
{tab === "gantt" && window.PWGantt &&
}
{tab === "team" && window.PWTeam &&
}
{tab === "finance" && window.PWFinance &&
}
{tab === "supervision" && window.PWSupervision &&
}
);
}
// ======================= СМУЖКА ЕТАПІВ =======================
function PWStageStrip({ obj, tasks }) {
const stages = window.DATA.STAGES;
const correction = window.DATA.CORRECTION_STAGES || [];
const hasCorr = correction.some(s => (obj.plan?.[s.id] || 0) > 0 || tasks.some(t => t.obj === obj.id && t.stage === s.id));
const renderStrip = (arr, label) => (
{label}
{arr.map((s, i) => {
const info = pwStageInfo(obj, tasks, s);
const isEstimate = s.id === "expertise" && obj.expertiseScope === "estimate";
return (
{i > 0 &&
}
0 ? "has-late" : ""}`}>
{s.short}
{info.isCompleted &&
}
{info.isActive && !info.isCompleted &&
}
{info.planned > 0 ? {info.done}/{info.planned} : — }
{isEstimate &&
тільки кошторис
}
{info.late > 0 &&
{info.late} прострочено
}
{info.late === 0 && info.live > 0 &&
{info.live} у роботі
}
);
})}
);
return (
Життєвий цикл об'єкта
{renderStrip(stages, "Основний цикл")}
{hasCorr && renderStrip(correction, "Коригування")}
);
}
// ======================= ОГЛЯД =======================
function PWOverview({ obj, tasks, gip, client, dlDays, onOpenTask, onToggleTask, setTab }) {
const prog = window.PM.sectionProgress(obj.id);
const sum = window.PM.sectionSummary(obj.id);
const alerts = pwBuildAlerts(obj, tasks);
const k = window.PM.contractFor(obj.id);
const paid = k ? k.schedule.filter(s => s.paidDate).reduce((a, s) => a + s.amount, 0) : 0;
const total = k ? k.total : 0;
const paidPct = total ? Math.round(paid / total * 100) : 0;
const objTasks = tasks.filter(t => t.obj === obj.id);
const activeTasks = objTasks.filter(t => t.status !== "done").length;
const activity = window.PM.activityFor(obj.id);
// Наступні віхи: майбутні платежі + дедлайни розділів
const milestones = [];
if (k) k.schedule.filter(s => !s.paidDate).forEach(s => milestones.push({ date: s.dueDate, kind: "Платіж", title: s.stage, val: `${window.formatMoney(s.amount)} ₴` }));
window.PM.sectionsFor(obj.id).filter(s => !window.PM.SECTION_STATUS[s.status].done && s.due).forEach(s =>
milestones.push({ date: s.due, kind: "Розділ", title: `${s.mark} · ${s.name}`, val: "" }));
milestones.sort((a, b) => window.PM.ms(a.date) - window.PM.ms(b.date));
const nextMs = milestones.filter(m => window.PM.daysTo(m.date) >= -3).slice(0, 6);
return (
{/* KPI */}
Готовність документації
{prog}%
{sum.sheets} аркушів · {sum.total} розділів
Розділи видано
{sum.issued}/{sum.total}
{sum.remarks > 0 ? {sum.remarks} із зауваженнями : "без зауважень"}
До дедлайну
{dlDays != null ? dlDays : "—"} дн
{obj.activeStage === "supervision" ? "авт. нагляд" : `здача ${obj.deadline}`}
Оплачено за договором
{paidPct}%
{window.formatMoney(paid)} з {window.formatMoney(total)} ₴
{/* Потребує уваги */}
Потребує уваги
{alerts.length > 0 && {alerts.length} }
{alerts.length === 0
?
За об'єктом усе в нормі — ризиків і прострочень немає.
:
{alerts.map((al, i) => (
{al.kind}
{al.title}
{al.meta && {al.meta} }
))}
}
{/* Наступні віхи */}
Наступні віхи
{nextMs.length === 0 ?
Найближчих віх немає.
:
nextMs.map((m, i) => {
const d = window.PM.daysTo(m.date);
const over = d < 0;
return (
{window.PM.dm(m.date)}
{over ? `${Math.abs(d)} дн тому` : d === 0 ? "сьогодні" : `через ${d} дн`}
{m.kind}
{m.title}
{m.val &&
{m.val} }
);
})}
{/* Права колонка */}
Паспорт об'єкта
Шифр {obj.code}
Клас наслідків {obj.grade} · {obj.gradeKind === "calculated" ? "розрахунок" : "попередній"}
Замовник {obj.client}
Договір {obj.contract}
Активний етап {window.getStage(obj.activeStage)?.name}
Ревізія {obj.revision}
ГІП {gip?.name || "—"}
Активних задач {activeTasks}
Команда об'єкта
{(obj.team || []).map(id => {
const p = window.getTeam(id);
const mine = objTasks.filter(t => t.assignee === id && t.status !== "done").length;
const late = objTasks.filter(t => t.assignee === id && t.status === "late").length;
return (
{p?.initials}
{mine} задач
{late > 0 && {late} простр. }
);
})}
Активність
{activity.map((ev, i) => {
const p = window.getTeam(ev.by);
return (
{p?.initials}
{p?.name?.split(" ")[0]} {ev.text}
{ev.date}
);
})}
);
}
// ======================= РОЗДІЛИ (склад документації) =======================
function PWSections({ obj, tasks, onOpenTask, onToggleTask }) {
const sections = window.PM.sectionsFor(obj.id);
const SS = window.PM.SECTION_STATUS;
const [filter, setFilter] = React.useState("all");
const [openMark, setOpenMark] = React.useState(null);
const sum = window.PM.sectionSummary(obj.id);
const filters = [
{ id: "all", label: "Усі" },
{ id: "active", label: "У роботі" },
{ id: "review", label: "Нормоконтроль" },
{ id: "remarks", label: "Зауваження" },
{ id: "issued", label: "Видано" },
];
const match = (s) => {
if (filter === "all") return true;
if (filter === "active") return s.status === "in_progress" || s.status === "not_started";
if (filter === "review") return s.status === "review";
if (filter === "remarks") return s.status === "remarks";
if (filter === "issued") return SS[s.status].done || s.status === "expertise";
return true;
};
const rows = sections.filter(match);
return (
Склад проєктної документації. Реєстр марок (розділів) з виконавцем, нормоконтролем, ревізією та готовністю.
Натисніть на рядок — побачите примітку і задачі по розділу.
{filters.map(f => (
setFilter(f.id)}>
{f.label}
))}
{sum.total} розділів · {sum.issued} видано · {sum.sheets} аркушів
Марка
Розділ
Виконавець · НК
Статус
Зм.
Арк.
Готовність
Дедлайн
{rows.map(s => {
const meta = SS[s.status];
const lead = window.getTeam(s.lead);
const checker = window.getTeam(s.checker);
const open = openMark === s.mark;
const dueDays = s.due ? window.PM.daysTo(s.due) : null;
const dueOver = dueDays != null && dueDays < 0 && !meta.done;
const secTasks = tasks.filter(t => t.obj === obj.id && t.section === s.mark);
return (
setOpenMark(open ? null : s.mark)}>
{s.mark}
{s.name}
{s.note &&
{s.note}
}
{lead?.initials}
·
{checker?.initials}
{meta.label}
{s.rev > 0 ? Зм.{s.rev} : — }
{s.sheets || "—"}
{s.due ? window.PM.dm(s.due) : "—"}
{open && (
Виконавець {lead?.name} · {lead?.role}
Нормоконтроль {checker?.name}
Ревізія {s.rev > 0 ? `Зміна ${s.rev}` : "Перша редакція"}
Дедлайн {s.due ? window.formatDate(s.due) : "—"}{dueDays != null && !meta.done ? ` · ${dueDays < 0 ? Math.abs(dueDays) + " дн прострочки" : "через " + dueDays + " дн"}` : ""}
Задачі розділу{secTasks.length > 0 ? ` · ${secTasks.length}` : ""}
{secTasks.length === 0
?
Окремих задач по розділу ще не створено.
:
{secTasks.map(t => )}
}
+ Задача в розділ {s.mark}
)}
);
})}
);
}
window.ProjectWorkspace = ProjectWorkspace;
window.PWStageStrip = PWStageStrip;