// 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 (
);
}
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 (
);
}
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 (
);
}
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;