// templates-page.jsx — нова сторінка «Шаблони» з 3 рівнями
function TemplatesPage({ role, onCreateFromTemplate, setPage }) {
const [tab, setTab] = React.useState("projects");
const [openProject, setOpenProject] = React.useState(null);
const [openStage, setOpenStage] = React.useState(null);
const [openTask, setOpenTask] = React.useState(null);
const [openHr, setOpenHr] = React.useState(null);
const [creating, setCreating] = React.useState(null); // PROJECT_TEMPLATE для створення
const projectTpl = window.PROJECT_TEMPLATES;
const stageTpl = window.STAGE_TEMPLATES;
const taskTpl = window.TASK_TEMPLATES;
const hrTpl = window.HR_PROCESS_TEMPLATES || [];
return (
Шаблони
Три рівні: проєкт → етап → задача
обираєш шаблон проєкту — створюються всі задачі
setTab("projects")}>
Шаблони проєктів {projectTpl.length}
setTab("stages")}>
Шаблони етапів {stageTpl.length}
setTab("tasks")}>
Шаблони задач {taskTpl.length}
setTab("hr")}>
HR / Команда {hrTpl.length}
{tab === "projects" && (
setOpenProject(t)}
onCreate={(t) => setCreating(t)}
/>
)}
{tab === "stages" && (
setOpenStage(t)} />
)}
{tab === "tasks" && (
setOpenTask(t)} />
)}
{tab === "hr" && (
setOpenHr(t)} />
)}
{openProject && (
setOpenProject(null)}
onCreate={() => { setCreating(openProject); setOpenProject(null); }}
/>
)}
{openStage && setOpenStage(null)} />}
{openTask && setOpenTask(null)} />}
{openHr && setOpenHr(null)} />}
{creating && (
setCreating(null)}
onCreated={(newObjectId) => {
setCreating(null);
onCreateFromTemplate && onCreateFromTemplate(newObjectId);
setPage && setPage("objects");
}}
/>
)}
);
}
// =========== ТАБ 1: Шаблони проєктів ===========
function ProjectTemplates({ templates, onView, onCreate }) {
const dirs = window.DIRECTION_LABELS;
const WT = window.WORK_TYPE_LABELS;
// Групуємо: схема намірів → невиробниче → виробниче → авторський нагляд
const groupOf = (t) => {
if (t.direction === "supervision") return "supervision";
if (t.workType === "schema") return "schema";
return t.purpose || "other";
};
const groupDefs = [
{ key: "schema", label: "Житлова садибна / блокована забудова" },
{ key: "non_production", label: "Невиробниче призначення" },
{ key: "production", label: "Виробниче призначення" },
{ key: "supervision", label: "Авторський нагляд" },
];
const gradeOrder = (t) => (t.objectGrade === "СС1" ? 0 : 1);
const sortFn = (a, b) =>
((WT[a.workType]?.order ?? 5) - (WT[b.workType]?.order ?? 5)) || (gradeOrder(a) - gradeOrder(b));
const renderCard = (t) => {
const stageCount = t.stageTemplateIds.length;
const taskCount = t.stageTemplateIds.reduce((sum, sid) => {
const s = window.getStageTemplate(sid);
return sum + (s ? s.taskTemplateIds.length : 0);
}, 0);
const dir = dirs[t.direction];
const wt = WT[t.workType];
return (
{t.gradeLabel && {t.gradeLabel} }
{dir.short}
{t.name}
{t.description}
{taskCount} задач
{stageCount} {window.plural(stageCount, "етап", "етапи", "етапів")}
{t.estimatedDays} {window.plural(t.estimatedDays, "день", "дні", "днів")}
onView(t)}>Переглянути
onCreate(t)}>
Створити проєкт →
);
};
return (
{groupDefs.map(g => {
const list = templates.filter(t => groupOf(t) === g.key).sort(sortFn);
if (list.length === 0) return null;
return (
{g.label}
{list.length} {window.plural(list.length, "шаблон", "шаблони", "шаблонів")}
{list.map(renderCard)}
);
})}
Створити шаблон
);
}
// =========== ТАБ 2: Шаблони етапів ===========
function StageTemplates({ templates, onView }) {
const byStage = {};
templates.forEach(t => { (byStage[t.stage] = byStage[t.stage] || []).push(t); });
const allStages = [...window.DATA.STAGES, ...(window.DATA.CORRECTION_STAGES || [])];
return (
{Object.entries(byStage).map(([stageId, list]) => {
const stage = allStages.find(s => s.id === stageId);
return (
{stage?.name || stageId}
{list.length} {window.plural(list.length, "шаблон", "шаблони", "шаблонів")}
{list.map(t => (
onView(t)} style={{cursor: "pointer"}}>
{t.name}
{t.description}
{t.taskTemplateIds.length} задач
))}
);
})}
);
}
// =========== Базові (наскрізні) задачі — застосовуються за правилом ===========
function BaselineTasksSection({ onView }) {
const baseline = window.BASELINE_TASKS || [];
if (baseline.length === 0) return null;
return (
Базові (наскрізні) задачі
описані один раз · підставляються за правилом застосовності
Не дублюються в кожному шаблоні етапу. Рушій додає їх при створенні проєкту залежно від типу робіт — тому правило можна змінити в одному місці.
{baseline.map(b => {
const tt = window.getTaskTemplate(b.taskTemplateId);
if (!tt) return null;
const role = window.ASSIGNEE_ROLE_LABELS[tt.assigneeRole];
return (
onView(tt)} style={{cursor: "pointer"}}>
{tt.title}
Застосовується до
{window.appliesToLabel(b.appliesTo)}
{b.rationale}
{role &&
{role.label}
}
);
})}
);
}
// =========== ТАБ 3: Шаблони задач ===========
function TaskTemplates({ templates, onView }) {
const [filterSection, setFilterSection] = React.useState("all");
const sections = ["all", ...Array.from(new Set(templates.map(t => t.section)))];
const filtered = filterSection === "all" ? templates : templates.filter(t => t.section === filterSection);
return (
{sections.map(s => (
setFilterSection(s)}
>
{s === "all" ? `Усі (${templates.length})` : `${s} (${templates.filter(t => t.section === s).length})`}
))}
Задача
Розділ
Виконавець
Тривалість
Дедлайн
Виїзд
Залежності
Файли
{filtered.map(t => {
const role = window.ASSIGNEE_ROLE_LABELS[t.assigneeRole];
return (
onView(t)} style={{cursor: "pointer"}}>
{t.icon || "•"}
{t.title}
{t.description}
{t.section}
{role ? {role.label} : "—"}
{t.estDays} дн
+{t.deadlineOffset} дн
{t.requiresSiteVisit ? так : — }
{t.dependencies?.length || 0}
{t.attachments?.length || 0}
);
})}
);
}
// =========== МОДАЛКА: ПЕРЕГЛЯД ШАБЛОНУ ПРОЄКТУ ===========
function ProjectTemplateModal({ template, onClose, onCreate }) {
React.useEffect(() => {
const onKey = (e) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const allStages = [...window.DATA.STAGES, ...(window.DATA.CORRECTION_STAGES || [])];
const stages = template.stageTemplateIds.map(id => window.getStageTemplate(id)).filter(Boolean);
const totalTasks = stages.reduce((s, st) => s + st.taskTemplateIds.length, 0);
const dir = window.DIRECTION_LABELS[template.direction];
return (
<>
{template.name}
{template.description}
Дублювати
Редагувати
Створити проєкт →
Тривалість
{template.estimatedDays}дн.
{stages.map((st, i) => {
const stage = allStages.find(s => s.id === st.stage);
const tasks = st.taskTemplateIds.map(id => window.getTaskTemplate(id)).filter(Boolean);
return (
Етап {i + 1}.
{stage?.name || st.stage}
· {st.name}
{tasks.length} {window.plural(tasks.length, "задача", "задачі", "задач")}
№
Задача
Розділ
Виконавець
Оцінка
Дедлайн
{tasks.map((t, j) => {
const r = window.ASSIGNEE_ROLE_LABELS[t.assigneeRole];
return (
{j + 1}
{t.title}
{t.section}
{r ? {r.label} : "—"}
{t.estDays} дн
+{t.deadlineOffset} дн
);
})}
);
})}
>
);
}
// =========== МОДАЛКА: ШАБЛОН ЕТАПУ ===========
function StageTemplateModal({ template, onClose }) {
React.useEffect(() => {
const onKey = (e) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const allStages = [...window.DATA.STAGES, ...(window.DATA.CORRECTION_STAGES || [])];
const stage = allStages.find(s => s.id === template.stage);
const tasks = template.taskTemplateIds.map(id => window.getTaskTemplate(id)).filter(Boolean);
return (
<>
{template.name}
Етап: {stage?.name || template.stage} · {template.description}
Редагувати
№
Задача
Розділ
Виконавець
Оцінка
Дедлайн
{tasks.map((t, j) => {
const r = window.ASSIGNEE_ROLE_LABELS[t.assigneeRole];
return (
{j + 1}
{t.title}
{t.description}
{t.section}
{r ? {r.label} : "—"}
{t.estDays} дн
+{t.deadlineOffset} дн
);
})}
>
);
}
// =========== МОДАЛКА: ШАБЛОН ЗАДАЧІ ===========
function TaskTemplateModal({ template, onClose }) {
React.useEffect(() => {
const onKey = (e) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const r = window.ASSIGNEE_ROLE_LABELS[template.assigneeRole];
const deps = (template.dependencies || []).map(id => window.getTaskTemplate(id)).filter(Boolean);
return (
<>
{template.section}
{template.requiresSiteVisit && виїзд на майданчик }
{template.title}
{template.description}
Тривалість
{template.estDays}дн
Дедлайн
+{template.deadlineOffset}дн
Виконавець
{r ? {r.label} : "—"}
{template.subtasks && template.subtasks.length > 0 && (
Чек-лист підзадач
{template.subtasks.length} {window.plural(template.subtasks.length, "пункт", "пункти", "пунктів")}
{template.subtasks.map((s, i) => (
{s.title}
))}
)}
{deps.length > 0 && (
Залежності
{deps.map(d => (
{d.title}
{d.section}
))}
)}
{template.attachments && template.attachments.length > 0 && (
Прикріплені шаблони документів
{template.attachments.map((a, i) => (
{a.name}
{window.ATTACHMENT_TYPE_LABELS[a.type] || a.type}
))}
)}
>
);
}
window.TemplatesPage = TemplatesPage;
// =========== ТАБ 4: HR / Команда (кадрові процеси) ===========
function HrTemplates({ templates, onView }) {
const cats = window.HR_CATEGORY_LABELS || [{ key: "career", label: "Кадрові процеси" }];
const renderCard = (t) => (
onView(t)} style={{cursor: "pointer"}}>
{t.name}
{t.description}
{t.effect}
{t.taskTemplateIds.length} {window.plural(t.taskTemplateIds.length, "задача", "задачі", "задач")}
);
return (
Кадрові процеси. Кар'єрні переходи запускаються з картки людини у розділі «Команда»
(архітектор → ГАП; інженер-проєктувальник СС1 → ІІ, СС2 → І, СС3 → провідний; надбавка за сертифікат).
Решта процесів — прийняття, відпустки, відрядження, звільнення, кваліфікація — стандартні набори задач
із дедлайнами та чек-листами під загальну систему оподаткування (ТОВ, платник ПДВ).
{cats.map(cat => {
const list = templates.filter(t => (t.category || "career") === cat.key);
if (list.length === 0) return null;
return (
{cat.label}
{list.length} {window.plural(list.length, "процес", "процеси", "процесів")}
{list.map(renderCard)}
);
})}
);
}
function HrTemplateModal({ template, onClose }) {
React.useEffect(() => {
const onKey = (e) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const tasks = template.taskTemplateIds.map(id => window.getHrTaskTemplate(id)).filter(Boolean);
return (
<>
{template.name}
Кадровий процес · {tasks.length} {window.plural(tasks.length, "задача", "задачі", "задач")}
Редагувати
Тригер {template.trigger}
Ефект {template.effect}
Задачі-супровід
{tasks.length}
{tasks.map((t, i) => {
const r = window.ASSIGNEE_ROLE_LABELS[t.assigneeRole];
return (
{i + 1}. {t.title}
{t.description}
{r &&
{r.label}}
{t.checklist && t.checklist.length > 0 && (
{t.checklist.map((c, j) => (
{c}
))}
)}
);
})}
>
);
}
// ============ ProjectTemplatePicker (для кнопки "+ Новий об'єкт") ============
function ProjectTemplatePicker({ onClose, onPick }) {
React.useEffect(() => {
const onKey = (e) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const templates = window.PROJECT_TEMPLATES;
const dirs = window.DIRECTION_LABELS;
return (
<>
Створити об'єкт з шаблону
Оберіть тип — задачі і етапи створяться автоматично
{templates.map(t => {
const dir = dirs[t.direction];
const stageCount = t.stageTemplateIds.length;
const taskCount = t.stageTemplateIds.reduce((sum, sid) => {
const s = window.getStageTemplate(sid);
return sum + (s ? s.taskTemplateIds.length : 0);
}, 0);
return (
onPick(t)}>
{t.name}
{dir.short}
{t.gradeLabel && {t.gradeLabel} }
{t.purpose && window.PURPOSE_LABELS[t.purpose] && {window.PURPOSE_LABELS[t.purpose].short} }
{t.description}
{taskCount} задач
·
{stageCount} {window.plural(stageCount, "етап", "етапи", "етапів")}
·
{t.estimatedDays} {window.plural(t.estimatedDays, "день", "дні", "днів")}
→
);
})}
>
);
}
window.ProjectTemplatePicker = ProjectTemplatePicker;