// components.jsx — спільні компоненти const { useState, useEffect, useMemo, useCallback } = React; // ============ ПРИВІТАННЯ (кличний відмінок + час доби) ============ // Привітання на дашборді мусить братися із залогіненого користувача, // а НЕ бути захардкодженим рядком. Бек так само має формувати його // з імені автентифікованого користувача (див. backend-нотатку). const VOCATIVE_MAP = { "Олена": "Олено", "Марія": "Маріє", "Андрій": "Андрію", "Тарас": "Тарасе", "Вікторія": "Вікторіє", "Дмитро": "Дмитре", "Сергій": "Сергію", "Ігор": "Ігорю", "Наталія": "Наталіє", "Олександр": "Олександре", "Михайло": "Михайле", "Оксана": "Оксано", "Тетяна": "Тетяно", "Юлія": "Юліє", "Павло": "Павле", "Петро": "Петре", "Іван": "Іване", "Богдан": "Богдане", "Роман": "Романе", "Володимир": "Володимире", "Анна": "Анно", "Ірина": "Ірино", "Катерина": "Катерино", }; function firstNameVocative(fullName) { const first = String(fullName || "").trim().split(/\s+/)[0] || ""; if (!first) return ""; if (VOCATIVE_MAP[first]) return VOCATIVE_MAP[first]; if (/ій$/.test(first)) return first.replace(/ій$/, "ію"); // Андрій → Андрію if (/ія$/.test(first)) return first.replace(/я$/, "є"); // Марія → Маріє if (/я$/.test(first)) return first.replace(/я$/, "е"); if (/а$/.test(first)) return first.replace(/а$/, "о"); // Олена → Олено if (/о$/.test(first)) return first.replace(/о$/, "е"); // Дмитро → Дмитре if (/[бвгґджзйклмнпрстфхцчшщ]$/i.test(first)) return first + "е"; // Тарас → Тарасе return first; } function timeGreeting(hour) { const h = (hour == null) ? new Date().getHours() : hour; if (h >= 5 && h < 12) return "Доброго ранку"; if (h >= 12 && h < 18) return "Доброго дня"; return "Доброго вечора"; } // user — об'єкт залогіненого користувача ({ name }); hour — необов'язково function dashGreeting(user, hour) { const name = (user && user.name) || "Олена Кравченко"; const voc = firstNameVocative(name); return timeGreeting(hour) + (voc ? ", " + voc : ""); } window.firstNameVocative = firstNameVocative; window.dashGreeting = dashGreeting; // ============ ICONS (мінімум — лиш утиліти) ============ const Icon = { Check: () => , Search: () => , Plus: () => , X: () => , Arrow: () => , Sun: () => , Moon: () => , }; // ============ NAV TREE ============ const NAV_TREE = [ { id: "dashboard-section", label: "Дашборд", children: [ { id: "dashboard", label: "Огляд директора" }, ]}, { id: "work", label: "Задачі", children: [ { id: "today", label: "Сьогодні" }, { id: "week", label: "Тиждень" }, { id: "kanban", label: "Канбан" }, { id: "tree", label: "Дерево" }, { id: "objects", label: "Об'єкти" }, { id: "projects", label: "Проєктна діяльність" }, { id: "admin", label: "Адміністративні" }, ]}, { id: "sales", label: "Продажі", children: [ { id: "leads", label: "Ліди" }, { id: "clients", label: "Замовники" }, { id: "contracts", label: "Договори" }, { id: "suppliers", label: "Підрядники" }, { id: "materials", label: "База матеріалів" }, ]}, { id: "accounting", label: "Бухгалтерія", children: [ // — Фінанси — { id: "receivables", label: "Рахунки і дебіторка" }, { id: "expenses", label: "Витрати" }, { id: "budgets", label: "Бюджети об'єктів" }, { id: "cashflow", label: "Cash flow" }, { id: "cash", label: "Каса" }, { id: "pnl", label: "P&L" }, { id: "vat", label: "ПДВ" }, { id: "fop_tax", label: "Єдиний податок (ФОП)" }, { id: "subpayments", label: "Виплати підрядникам" }, { id: "advances", label: "Авансові звіти" }, { id: "estimates", label: "Кошториси" }, // — Зарплата — { id: "payroll", label: "Розрахунок ЗП" }, { id: "registry", label: "Реєстр виплат" }, { id: "sick_pay", label: "Лікарняні" }, { id: "bonus_pay", label: "13-та і річні" }, { id: "history", label: "Історія ЗП" }, { id: "analytics", label: "Аналітика ЗП" }, // — Склад — { id: "assets", label: "Майно" }, { id: "inventory", label: "Інвентаризація" }, // — Звіти — { id: "internal_reports", label: "Внутрішні звіти" }, { id: "supervision_journal", label: "Журнал авт. нагляду" }, { id: "completion_acts", label: "Акти робіт" }, { id: "report_templates", label: "Шаблони звітів" }, ]}, { id: "hr", label: "Команда", children: [ { id: "profiles", label: "Профілі" }, { id: "qualifications", label: "Кваліфікація" }, { id: "kpi", label: "KPI та 1:1" }, { id: "time", label: "Табель" }, { id: "vacation", label: "Відпустки" }, { id: "documents", label: "Документи" }, { id: "orders", label: "Накази" }, { id: "onboarding",label: "Онбординг" }, { id: "training", label: "Навчання" }, ]}, { id: "permits", label: "Дозвільні", children: [ { id: "tu", label: "Технічні умови" }, { id: "dsns", label: "ДСНС / Узгодж." }, { id: "kep", label: "КЕП-сертифікати" }, ]}, { id: "directory", label: "Довідники", children: [ { id: "contacts", label: "Контакти" }, { id: "ngo_directory", label: "Партнери ГО" }, { id: "templates", label: "Шаблони" }, ]}, { id: "settings", label: "Налаштування", children: [ { id: "roles", label: "Ролі та права" }, { id: "company", label: "Компанія" }, { id: "legal_changes", label: "Юридичні зміни" }, { id: "audit", label: "Журнал аудиту" }, { id: "notifications", label: "Сповіщення" }, // — Інтеграції (перенесено сюди) — { id: "drive", label: "Google Drive" }, { id: "calendar", label: "Calendar" }, { id: "diia", label: "Дія.Підпис" }, { id: "bank", label: "Банк (monobank)" }, { id: "edessb", label: "ЄДЕССБ" }, { id: "telegram", label: "Telegram" }, { id: "vchasno", label: "Vchasno" }, { id: "prozorro", label: "Prozorro" }, { id: "tax", label: "ДПС" }, { id: "maps", label: "Google Maps" }, { id: "ai", label: "AI-асистент" }, ]}, ]; window.NAV_TREE = NAV_TREE; window.findNavSection = (pageId) => NAV_TREE.find(s => s.children.some(c => c.id === pageId)); // ============ APP HEADER ============ function AppHeader({ page, setPage, theme, setTheme, role, setRole, counts, user, onLogout, onChangePassword, onNewTask }) { const section = window.findNavSection(page) || NAV_TREE[0]; const child = section.children.find(c => c.id === page); // Click on top-level section → goes to first child const goToSection = (sec) => { if (sec.id === section.id) return; setPage(sec.children[0].id); }; return (
УКРБУДПРОЄКТ УКРБУДПРОЄКТ ERP
⌘K
{(() => { const ROLE_UA = { director: "Директор", deputy: "Заступник директора", accountant: "Бухгалтер", gip: "ГІП", engineer: "Інженер", contractor: "Субпідряд", employee: "Співробітник" }; const name = (user && user.name) || "Олена Кравченко"; const initials = name.split(/\s+/).map(w => w[0]).slice(0, 2).join("").toUpperCase(); const roleUa = ROLE_UA[role] || "Співробітник"; return (
{onChangePassword && ( )} {onLogout && ( )}
); })()}
{section.label} {child?.label}
); } window.AppHeader = AppHeader; // ============ TASK ROW ============ function TaskRow({ task, onOpen, onToggle, showAssignee = true, dateLabel }) { const obj = window.getObject(task.obj); const team = window.getTeam(task.assignee); const isDone = task.status === "done"; const isLive = task.status === "live"; const isLate = task.status === "late"; return (
onOpen(task)} >
{task.title}
{task.private && <>Приватне{(task.kind === "visit" || obj || task.section) && }} {task.kind === "visit" && window.VisitTypeBadge && <>} {obj ? <> {obj.code} {obj.name} : (task.section && {task.section})} {task.time && <> {task.time} }
{isLate && Прострочено} {isLive && У роботі} {dateLabel ? {dateLabel} : (!isDone && task.est && {task.est}) } {showAssignee && team && (
{team.initials}
)}
); } // ============ ПІДЗАДАЧІ (повноцінні міні-задачі) ============ function SubtaskRow({ s, team, onToggle, onUpdate, onRemove }) { const [editing, setEditing] = useState(false); const [val, setVal] = useState(s.title); useEffect(() => setVal(s.title), [s.title]); const person = s.assignee ? window.getTeam(s.assignee) : null; const saveTitle = () => { const v = val.trim(); if (v) onUpdate({ title: v }); else setVal(s.title); setEditing(false); }; return (
{editing ? setVal(e.target.value)} onBlur={saveTitle} onKeyDown={(e) => { if (e.key === "Enter") saveTitle(); if (e.key === "Escape") { setVal(s.title); setEditing(false); } }} /> : setEditing(true)} title="Редагувати">{s.title}}
{s.status === "done" ? "Виконано" : s.status === "live" ? "У роботі" : s.status === "late" ? "Прострочено" : "To do"} {person && {person.initials}} onUpdate({ due: e.target.value || null })} />
); } function SubtasksBlock({ task, subtasks, done, onTaskPatch }) { const [adding, setAdding] = useState(""); const team = window.DATA.TEAM || []; const patchList = (fn) => onTaskPatch(task.id, (x) => ({ ...x, subtasks: fn(x.subtasks || []) })); const add = () => { const title = adding.trim(); if (!title) return; patchList(list => [...list, { id: "st-" + Math.random().toString(36).slice(2, 8), title, assignee: null, status: "todo", due: null }]); setAdding(""); }; const update = (id, patch) => patchList(list => list.map(s => s.id === id ? { ...s, ...patch } : s)); const remove = (id) => patchList(list => list.filter(s => s.id !== id)); const toggle = (id) => patchList(list => list.map(s => s.id === id ? { ...s, status: s.status === "done" ? "todo" : "done" } : s)); return (
Підзадачі{subtasks.length ? ` · ${done}/${subtasks.length}` : ""}
{subtasks.map(s => ( toggle(s.id)} onUpdate={(p) => update(s.id, p)} onRemove={() => remove(s.id)} /> ))} {subtasks.length === 0 &&
Підзадач ще немає — розбийте задачу на кроки з виконавцем і дедлайном.
}
setAdding(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") add(); }} />
); } // ============ ЧЕКЛІСТИ (групи простих пунктів) ============ function ChecklistItem({ it, onToggle, onRemove, onUpdate }) { const [editing, setEditing] = useState(false); const [val, setVal] = useState(it.text); useEffect(() => setVal(it.text), [it.text]); const save = () => { const v = val.trim(); if (v) onUpdate(v); else setVal(it.text); setEditing(false); }; return (
{it.done && }
{editing ? setVal(e.target.value)} onBlur={save} onKeyDown={(e) => { if (e.key === "Enter") save(); if (e.key === "Escape") { setVal(it.text); setEditing(false); } }} /> : setEditing(true)} style={{ cursor: "text", flex: 1 }} title="Редагувати">{it.text}} {it.hint && !editing && {it.hint}}
); } function ChecklistGroup({ g, onRename, onRemove, onAddItem, onToggle, onRemoveItem, onUpdateItem }) { const [adding, setAdding] = useState(""); const [editTitle, setEditTitle] = useState(false); const [tval, setTval] = useState(g.title); useEffect(() => setTval(g.title), [g.title]); const done = g.items.filter(i => i.done).length; const pct = g.items.length ? Math.round(done / g.items.length * 100) : 0; const add = () => { const t = adding.trim(); if (!t) return; onAddItem(t); setAdding(""); }; const saveTitle = () => { const v = tval.trim(); if (v) onRename(v); else setTval(g.title); setEditTitle(false); }; return (
{editTitle ? setTval(e.target.value)} onBlur={saveTitle} onKeyDown={(e) => { if (e.key === "Enter") saveTitle(); if (e.key === "Escape") { setTval(g.title); setEditTitle(false); } }} /> : setEditTitle(true)} title="Перейменувати">{g.title}} {done}/{g.items.length}
{g.items.length > 0 &&
}
{g.items.map(it => ( onToggle(it.id)} onRemove={() => onRemoveItem(it.id)} onUpdate={(t) => onUpdateItem(it.id, t)} /> ))}
setAdding(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") add(); }} />
); } function ChecklistsBlock({ task, checklists, onTaskPatch }) { const [addingGroup, setAddingGroup] = useState(false); const [groupName, setGroupName] = useState(""); const patch = (fn) => onTaskPatch(task.id, (x) => ({ ...x, checklists: fn(x.checklists || []) })); const addGroup = () => { const title = groupName.trim() || "Чек-ліст"; patch(list => [...list, { id: "cl-" + Math.random().toString(36).slice(2, 8), title, items: [] }]); setGroupName(""); setAddingGroup(false); }; const renameGroup = (gid, title) => patch(list => list.map(g => g.id === gid ? { ...g, title } : g)); const removeGroup = (gid) => patch(list => list.filter(g => g.id !== gid)); const addItem = (gid, text) => patch(list => list.map(g => g.id === gid ? { ...g, items: [...g.items, { id: "ci-" + Math.random().toString(36).slice(2, 8), text, done: false }] } : g)); const toggleItem = (gid, iid) => patch(list => list.map(g => g.id === gid ? { ...g, items: g.items.map(it => it.id === iid ? { ...it, done: !it.done } : it) } : g)); const removeItem = (gid, iid) => patch(list => list.map(g => g.id === gid ? { ...g, items: g.items.filter(it => it.id !== iid) } : g)); const updateItem = (gid, iid, text) => patch(list => list.map(g => g.id === gid ? { ...g, items: g.items.map(it => it.id === iid ? { ...it, text } : it) } : g)); return (
Чеклісти {!addingGroup && }
{addingGroup && (
setGroupName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") addGroup(); if (e.key === "Escape") setAddingGroup(false); }} />
)} {checklists.length === 0 && !addingGroup &&
Чеклістів немає. Додайте групу простих пунктів-галочок (можна кілька).
} {checklists.map(g => ( renameGroup(g.id, t)} onRemove={() => removeGroup(g.id)} onAddItem={(t) => addItem(g.id, t)} onToggle={(iid) => toggleItem(g.id, iid)} onRemoveItem={(iid) => removeItem(g.id, iid)} onUpdateItem={(iid, t) => updateItem(g.id, iid, t)} /> ))}
); } // ============ DRAWER (розгорнута картка задачі) ============ const DRAWER_STATUSES = [ { id: "todo", label: "To Do" }, { id: "live", label: "У роботі" }, { id: "late", label: "Прострочено" }, { id: "done", label: "Виконано" }, ]; function Drawer({ task, onClose, onStatusChange, onTaskPatch, onOpenProject }) { const [tab, setTab] = useState("details"); const [draft, setDraft] = useState(""); const [localComments, setLocalComments] = useState([]); useEffect(() => { const onKey = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); useEffect(() => { setLocalComments([]); setDraft(""); setTab("details"); }, [task && task.id]); if (!task) return null; const isVisit = task.kind === "visit" && !!window.VisitPanel; const obj = window.getObject(task.obj); const team = window.getTeam(task.assignee); const ex = window.PM ? window.PM.taskExtras(task.id) : { timeLogged: 0, timeEst: null, deps: { blockedBy: [], blocks: [] }, files: [], comments: [], activity: [] }; const subtasks = task.subtasks || []; const checklists = task.checklists || []; const assigneeIds = task.assignees && task.assignees.length ? task.assignees : (task.assignee ? [task.assignee] : []); const assigneePeople = assigneeIds.map(id => window.getTeam(id)).filter(Boolean); const subtaskDone = subtasks.filter(s => s.status === "done").length; const comments = [...(ex.comments || []), ...localComments]; const timeEst = ex.timeEst || parseFloat(String(task.est || "0")) || null; const timePct = timeEst ? Math.min(100, Math.round((ex.timeLogged || 0) / timeEst * 100)) : 0; const findTask = (id) => (window.DATA.TASKS || []).find(t => t.id === id); const submitComment = () => { if (!draft.trim()) return; setLocalComments(c => [...c, { by: "ok", date: "щойно", text: draft.trim() }]); setDraft(""); }; const TABS = [ { id: "details", label: "Деталі" }, { id: "comments", label: `Коментарі${comments.length ? " · " + comments.length : ""}` }, { id: "files", label: `Файли${ex.files && ex.files.length ? " · " + ex.files.length : ""}` }, { id: "history", label: "Історія" }, ]; return ( <>
); } window.AppHeader = AppHeader; window.TaskRow = TaskRow; window.Drawer = Drawer; window.Icon = Icon;