// app.jsx — головний роутер + Tweaks
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "light",
"page": "dashboard",
"density": "normal",
"role": "director"
}/*EDITMODE-END*/;
// Об'єднана сторінка: Розрахунок ЗП + Аванси ЗП під вкладками
function PayrollWithAdvances({ role }) {
const [tab, setTab] = React.useState("calc");
return (
{tab === "calc" ? : }
);
}
function App() {
const [t, setTweak] = window.useTweaks(TWEAK_DEFAULTS);
const liveUser = (window.API && window.API.mode === "live") ? window.API.user : null;
const [tasks, setTasks] = React.useState(window.DATA.TASKS);
const [objects, setObjects] = React.useState(window.DATA.OBJECTS);
const [drawerTask, setDrawerTask] = React.useState(null);
const [openProjectId, setOpenProjectId] = React.useState(null);
const [openGoProjId, setOpenGoProjId] = React.useState(null); // воркспейс проєкту ГО
const [newTask, setNewTask] = React.useState(null); // null | preset-об'єкт (відкриває модалку «Нова задача»)
const [newVisit, setNewVisit] = React.useState(null); // null | preset-об'єкт (відкриває модалку «Новий виїзд»)
const [showPwd, setShowPwd] = React.useState(false); // модалка зміни пароля
const [, setCareerTick] = React.useState(0); // форс-ререндер після кар'єрних змін у window.DATA.TEAM
// Глобальний навігатор для крос-сторінкових переходів (табель ↔ ЗП тощо)
React.useEffect(() => { window.__setPage = (p) => setTweak("page", p); }, [setTweak]);
// LIVE-збереження: у режимі live шле зміну в API (оптимістично; алерт на збій).
// У demo-режимі — нічого не робить (лишається локально).
const liveSave = (resource, id, patch) => {
if (!(window.API && window.API.mode === "live" && window.API.save)) return;
Promise.resolve(window.API.save(resource, id, patch)).catch(err => {
console.error("[ERP] save failed:", resource, id, err);
alert("Не вдалося зберегти на сервері. Перевірте з'єднання і повторіть дію.");
});
};
const onCreateTask = (task) => {
const ready = window.normalizeTask ? window.normalizeTask(task) : task;
setTasks(prev => [ready, ...prev]);
setNewTask(null);
setTweak("page", "kanban"); // переходимо на дошку — нова задача одразу видно карткою
liveSave("tasks", null, ready);
};
const onCreateTasks = (newTasks) => {
if (!newTasks || !newTasks.length) return;
setTasks(prev => [...newTasks, ...prev]);
newTasks.forEach(t => liveSave("tasks", null, t));
};
const onCreateVisit = (task) => {
const ready = window.normalizeTask ? window.normalizeTask(task) : task;
setTasks(prev => [ready, ...prev]);
setNewVisit(null);
setTweak("page", "kanban"); // новий виїзд одразу видно на дошці
liveSave("tasks", null, ready);
};
// Глобальний стор: дозволяє window.createObjectFromTemplate синхронізувати React state
React.useEffect(() => {
window.__erpStore = {
addObject: (newObj, newTasks) => {
setObjects(prev => [newObj, ...prev]);
setTasks(prev => [...prev, ...newTasks]);
},
updateObject: (id, patch, newTasks) => {
window.DATA.OBJECTS = window.DATA.OBJECTS.map(o => o.id === id ? { ...o, ...patch } : o);
setObjects(window.DATA.OBJECTS);
if (newTasks && newTasks.length) {
window.DATA.TASKS = [...window.DATA.TASKS, ...newTasks];
setTasks(window.DATA.TASKS);
}
},
};
// Глобальний тогл статусу задачі за id (для віджетів, напр. нагадувань про ДН)
window.__erpToggleTask = (id) => setTasks(prev => prev.map(x => x.id === id ? { ...x, status: x.status === "done" ? "todo" : "done" } : x));
return () => { window.__erpStore = null; };
}, []);
// Тримаємо window.DATA.OBJECTS / TASKS у синхроні зі state (для функцій типу getObject)
React.useEffect(() => { window.DATA.OBJECTS = objects; }, [objects]);
React.useEffect(() => { window.DATA.TASKS = tasks; }, [tasks]);
React.useEffect(() => { setDrawerTask(null); setOpenProjectId(null); setOpenGoProjId(null); }, [t.page]);
React.useEffect(() => {
document.documentElement.style.setProperty(
"--dens",
t.density === "compact" ? "0.85" : t.density === "spacious" ? "1.15" : "1"
);
}, [t.density]);
React.useEffect(() => {
const cls = t.theme === "dark" ? "theme-dark" : "theme-light";
document.documentElement.classList.remove("theme-light", "theme-dark");
document.documentElement.classList.add(cls);
}, [t.theme]);
const themeClass = t.theme === "dark" ? "theme-dark" : "theme-light";
const onOpenTask = (task) => setDrawerTask(task);
const onCloseTask = () => setDrawerTask(null);
const onToggle = (task) => {
const next = task.status === "done" ? "todo" : "done";
setTasks(prev => prev.map(x => x.id === task.id ? { ...x, status: next } : x));
liveSave("tasks", task.id, { status: next });
};
const onStatusChange = (taskId, status) => {
setTasks(prev => prev.map(x => x.id === taskId ? { ...x, status } : x));
setDrawerTask(d => d && d.id === taskId ? { ...d, status } : d);
liveSave("tasks", taskId, { status });
};
// Універсальний апдейтер задачі: patch — об'єкт або функція (task) => task.
// Через нього йдуть усі правки підзадач і чеклістів.
const onTaskPatch = (taskId, patch) => {
const apply = (x) => (typeof patch === "function" ? patch(x) : { ...x, ...patch });
setTasks(prev => prev.map(x => x.id === taskId ? apply(x) : x));
setDrawerTask(d => (d && d.id === taskId ? apply(d) : d));
// Для функції-патча шлемо повний оновлений об'єкт (бек бере лише відомі поля);
// для об'єкта-патча — самі змінені поля.
const cur = (window.DATA.TASKS || tasks).find(x => x.id === taskId);
if (cur) liveSave("tasks", taskId, typeof patch === "function" ? apply(cur) : patch);
};
// Оформлення кар'єрного переходу: міняє статус людини у window.DATA.TEAM
// і породжує задачі-супровід за HR-шаблоном.
const onPromote = (personId) => {
const person = (window.DATA.TEAM || []).find(p => p.id === personId);
if (!person) return;
const st = window.careerState(person);
if (!st || !st.promotion) return;
const promo = st.promotion;
const today = window.SYS_DATE;
const fromPos = person.positionOfficial || person.role;
// 1) Зміна посади / окладу
if (promo.kind === "gap") {
person.positionOfficial = promo.targetPosition;
person.role = promo.targetRole || person.role;
} else if (promo.kind === "category") {
person.positionOfficial = promo.targetPosition;
}
if (promo.salaryDelta && (person.salaryModel === "fixed" || person.salaryModel === "fixed_bonus")) {
person.baseSalary = (person.baseSalary || 0) + promo.salaryDelta;
}
person.careerLog = person.careerLog || [];
person.careerLog.push({ date: today, from: fromPos, to: promo.targetPosition, delta: promo.salaryDelta, kind: promo.kind });
// 2) Задачі-супровід із HR-шаблону
const tpl = window.getHrProcessTemplate(promo.templateId);
const dayNum = (window.TODAY && window.TODAY.day) || "29";
const newTasks = ((tpl && tpl.taskTemplateIds) || []).map((id, i) => {
const tt = window.getHrTaskTemplate(id);
const cl = (tt && tt.checklist && tt.checklist.length)
? [{ id: `cl-hr-${person.id}-${id}`, title: "Чек-ліст", items: tt.checklist.map((text, j) => ({ id: `ci-hr-${person.id}-${id}-${j}`, text, done: false })) }]
: [];
return {
id: `hr-${person.id}-${id}-${Math.random().toString(36).slice(2, 6)}`,
title: `${tt ? tt.title : "Кадрова задача"} — ${person.name}`,
obj: null, stage: "admin", section: "HR",
assignee: tt && tt.assigneeRole === "accountant" ? "ok" : "ok",
day: dayNum, est: tt ? `${tt.estDays} дн` : "1 дн",
status: "todo", subtasks: [], checklists: cl,
note: tpl ? `Кадровий процес: ${tpl.name}. ${promo.reason}` : promo.reason,
hrFor: person.id,
};
});
if (newTasks.length) setTasks(prev => [...newTasks, ...prev]);
setCareerTick(x => x + 1);
};
// Роль і видимість
const role = liveUser ? liveUser.role : (t.role || "director");
const canSeeSalary = liveUser ? !!liveUser.canSeeSalary : (role === "director" || role === "accountant");
// Приватні задачі (нагадування директора) бачить лише директор
const visTasks = window.visibleTasks ? window.visibleTasks(tasks, role) : tasks;
// Лічильники секцій
const today = window.TODAY.day;
const overdueInvoices = (window.DATA.INVOICES || []).filter(i => i.status === "overdue").length;
const leads = window.DATA.LEADS || [];
const counts = {
dashboard: null,
today: visTasks.filter(t => t.day === today && t.status !== "done").length,
week: visTasks.filter(t => t.status !== "done").length,
objects: window.DATA.OBJECTS.length,
projects: window.DATA.PROJECTS ? window.DATA.PROJECTS.length : null,
admin: visTasks.filter(t => !t.obj && t.status !== "done").length,
leads: leads.filter(l => { const s = window.leadStage(l); return !s.terminal && !s.won; }).length || null,
clients: window.DATA.CLIENTS ? window.DATA.CLIENTS.length : null,
contracts: window.DATA.CONTRACTS ? window.DATA.CONTRACTS.filter(c => c.status === "active" || c.status === "supervision").length : null,
receivables: overdueInvoices > 0 ? overdueInvoices : null,
expenses: window.DATA.EXPENSES ? window.DATA.EXPENSES.length : null,
budgets: null,
estimates: null,
profiles: window.DATA.TEAM.length,
time: null,
vacation: window.DATA.VACATIONS ? window.DATA.VACATIONS.filter(v => v.status === "pending").length : null,
documents: window.DATA.DOCUMENTS ? window.DATA.DOCUMENTS.filter(d => d.status === "draft").length : null,
payroll: null,
registry: null,
analytics: null,
roles: null,
company: null,
};
// Якщо співробітник зайшов у Зарплата — переадресуємо у профілі
React.useEffect(() => {
if (!canSeeSalary && ["payroll", "registry", "analytics", "clients", "contracts", "receivables", "expenses", "cashflow", "cash", "estimates", "leads", "budgets", "pnl", "vat", "fop_tax", "subpayments", "advances", "suppliers"].includes(t.page)) {
setTweak("page", "profiles");
}
}, [role, t.page]);
return (
setTweak("page", p)}
theme={t.theme}
setTheme={(th) => setTweak("theme", th)}
role={role}
setRole={(r) => setTweak("role", r)}
counts={counts}
user={liveUser}
onLogout={window.API ? window.API.logout : null}
onChangePassword={liveUser ? () => setShowPwd(true) : null}
onNewTask={() => setNewTask({})}
/>
{openProjectId ? (
setOpenProjectId(null)}
/>
) : openGoProjId ? (
setOpenGoProjId(null)}
/>
) : (
{/* ДАШБОРД */}
{t.page === "dashboard" && setNewTask({})} user={liveUser} />}
{/* РОБОТА */}
{t.page === "today" && }
{t.page === "week" && }
{t.page === "kanban" && setNewTask(preset || {})} onNewVisit={() => setNewVisit({})} />}
{t.page === "tree" && }
{t.page === "objects" && setOpenProjectId(id)} />}
{t.page === "projects" && setOpenGoProjId(id)} />}
{t.page === "admin" && }
{/* ПЕРСОНАЛ */}
{t.page === "profiles" && setTweak("page", "kanban")} />}
{t.page === "qualifications" && }
{t.page === "kpi" && }
{t.page === "time" && }
{t.page === "vacation" && }
{t.page === "documents" && }
{t.page === "orders" && }
{t.page === "onboarding" && }
{t.page === "training" && }
{/* ФІНАНСИ */}
{t.page === "clients" && canSeeSalary && }
{t.page === "contracts" && canSeeSalary && setTweak("page", "kanban")} />}
{t.page === "receivables" && canSeeSalary && }
{t.page === "expenses" && canSeeSalary && }
{t.page === "cashflow" && canSeeSalary && }
{t.page === "cash" && canSeeSalary && }
{t.page === "estimates" && canSeeSalary && }
{/* ЗАРПЛАТА */}
{t.page === "payroll" && canSeeSalary && }
{t.page === "registry" && canSeeSalary && }
{t.page === "sick_pay" && canSeeSalary && }
{t.page === "bonus_pay" && canSeeSalary && }
{t.page === "history" && canSeeSalary && }
{t.page === "analytics" && canSeeSalary && }
{/* ЗВІТИ */}
{t.page === "internal_reports" && canSeeSalary && }
{t.page === "supervision_journal" && }
{t.page === "completion_acts" && canSeeSalary && }
{t.page === "report_templates" && }
{/* СКЛАД */}
{t.page === "assets" && }
{t.page === "inventory" && }
{/* ДОЗВІЛЬНІ */}
{t.page === "tu" && }
{t.page === "dsns" && }
{t.page === "kep" && }
{/* ІНТЕГРАЦІЇ */}
{t.page === "drive" && }
{t.page === "calendar" && }
{t.page === "diia" && }
{t.page === "bank" && }
{t.page === "edessb" && }
{t.page === "telegram" && }
{t.page === "vchasno" && }
{t.page === "prozorro" && }
{t.page === "tax" && }
{t.page === "maps" && }
{t.page === "ai" && }
{/* ПРОДАЖІ */}
{t.page === "leads" && canSeeSalary && }
{t.page === "suppliers" && canSeeSalary && }
{t.page === "materials" && canSeeSalary && }
{/* ФІНАНСИ — продовження */}
{t.page === "budgets" && canSeeSalary && }
{t.page === "pnl" && canSeeSalary && }
{t.page === "vat" && canSeeSalary && }
{t.page === "fop_tax" && canSeeSalary && }
{t.page === "subpayments" && canSeeSalary && }
{t.page === "advances" && canSeeSalary && }
{/* НАЛАШТУВАННЯ */}
{t.page === "roles" && }
{t.page === "company" && }
{t.page === "legal_changes" && }
{t.page === "task_templates" && setTweak("page", p)} />}
{t.page === "templates" && setTweak("page", p)} />}
{t.page === "contacts" && }
{t.page === "ngo_directory" && }
{t.page === "audit" && }
{t.page === "notifications" && }
)}
{drawerTask &&
{ setDrawerTask(null); setOpenProjectId(id); }} />}
{newTask && setNewTask(null)} onCreate={onCreateTask} onSwitchToVisit={() => { setNewTask(null); setNewVisit({}); }} />}
{newVisit && setNewVisit(null)} onCreate={onCreateVisit} />}
{showPwd && setShowPwd(false)} />}
);
}
// ============ TWEAKS ПАНЕЛЬ ============
function Tweaks({ t, setTweak }) {
return (
setTweak("theme", v)}
options={[
{ value: "light", label: "Світла" },
{ value: "dark", label: "Темна" },
]}
/>
{(window.API && window.API.mode === "live")
? Роль визначається обліковим записом ({window.API.user ? window.API.user.role : "—"}). Вийдіть, щоб увійти під іншим користувачем.
: setTweak("role", v)}
options={[
{ value: "director", label: "Директор" },
{ value: "deputy", label: "Заступник директора" },
{ value: "accountant", label: "Бухгалтер" },
{ value: "employee", label: "Працівник" },
]}
/>}
setTweak("density", v)}
options={[
{ value: "compact", label: "Щільно" },
{ value: "normal", label: "Норма" },
{ value: "spacious", label: "Вільно" },
]}
/>
);
}
// ============ ЕКРАН ЗАВАНТАЖЕННЯ / ВХОДУ ============
function BootSplash() {
return (
ERP УКРБУДПРОЄКТ®
Завантаження…
);
}
function LoginScreen({ onAuthed }) {
const [email, setEmail] = React.useState("okravchenko@ukrbudproiekt.ua");
const [password, setPassword] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const submit = async (e) => {
e.preventDefault();
setBusy(true); setErr(null);
try {
await window.API.login(email.trim(), password);
await window.API.afterLogin();
onAuthed();
} catch (ex) {
setErr(ex && ex.status === 401 ? "Невірний email або пароль" : "Не вдалося увійти. Перевірте з'єднання.");
setBusy(false);
}
};
return (
);
}
function SetPasswordScreen({ onDone }) {
const [pw, setPw] = React.useState("");
const [pw2, setPw2] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const ok = pw.length >= 8 && pw === pw2;
const submit = async (e) => {
e.preventDefault();
if (!ok) { setErr(pw.length < 8 ? "Пароль має містити щонайменше 8 символів" : "Паролі не збігаються"); return; }
setBusy(true); setErr(null);
try {
if (window.API.setPassword) await window.API.setPassword(pw);
onDone && onDone();
} catch (ex) {
setErr("Не вдалося зберегти пароль. Спробуйте ще раз.");
setBusy(false);
}
};
return (
);
}
// Самообслуговувана зміна пароля будь-коли (модалка з хедера) — поточний → новий.
function ChangePasswordModal({ onClose }) {
const [cur, setCur] = React.useState("");
const [pw, setPw] = React.useState("");
const [pw2, setPw2] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const submit = async (e) => {
e.preventDefault();
setErr(null);
if (pw.length < 8) { setErr("Новий пароль — мінімум 8 символів"); return; }
if (pw !== pw2) { setErr("Паролі не збігаються"); return; }
if (pw === cur) { setErr("Новий пароль має відрізнятися від поточного"); return; }
setBusy(true);
try {
await window.API.changePassword(cur, pw);
onClose && onClose();
} catch (ex) {
setErr(ex && ex.data && ex.data.detail ? ex.data.detail
: (ex && ex.status === 400 ? "Поточний пароль невірний" : "Не вдалося змінити пароль"));
setBusy(false);
}
};
return (
);
}
window.ChangePasswordModal = ChangePasswordModal;
function AuthGate() {
const previewPasswd = (typeof location !== "undefined" && location.search.indexOf("passwd=1") !== -1);
const [phase, setPhase] = React.useState(
previewPasswd ? "passwd" :
((typeof location !== "undefined" && location.search.indexOf("login=1") !== -1) ? "login" : "boot")
); // boot | login | passwd | app
const nextAfterAuth = () =>
(window.API.user && window.API.user.mustChangePassword) ? "passwd" : "app";
React.useEffect(() => {
if (phase === "login" || phase === "passwd") return; // прев'ю екранів через ?login=1 / ?passwd=1
let on = true;
window.API.bootstrap().then(mode => {
if (!on) return;
setPhase(mode === "live" && window.API.needAuth ? "login" : nextAfterAuth());
});
const onUnauth = () => { if (on) setPhase("login"); };
window.addEventListener("erp-unauth", onUnauth);
return () => { on = false; window.removeEventListener("erp-unauth", onUnauth); };
}, []);
if (phase === "boot") return ;
if (phase === "login") return setPhase(nextAfterAuth())} />;
if (phase === "passwd") return setPhase("app")} />;
return ;
}
// Mount
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render();