// 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 (
ERP УКРБУДПРОЄКТ®
Вхід до ERP
ТОВ «Укрбудпроєкт»
setEmail(e.target.value)} autoFocus required /> setPassword(e.target.value)} placeholder="••••••••" required /> {err &&
{err}
}
Демо-доступ: demo1234 (директор)
); } 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 (
ERP УКРБУДПРОЄКТ®
Новий пароль
Перший вхід — придумай власний пароль
setPw(e.target.value)} placeholder="мінімум 8 символів" autoFocus required /> setPw2(e.target.value)} placeholder="••••••••" required />
= 8 ? "is-ok" : "")}> щонайменше 8 символів
{err &&
{err}
}
Тимчасовий пароль із листа більше не знадобиться
); } // Самообслуговувана зміна пароля будь-коли (модалка з хедера) — поточний → новий. 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 (
e.stopPropagation()} onSubmit={submit}>
Зміна пароля
Оновіть пароль свого облікового запису
setCur(e.target.value)} autoFocus required /> setPw(e.target.value)} placeholder="мінімум 8 символів" required /> setPw2(e.target.value)} placeholder="••••••••" required /> {err &&
{err}
}
); } 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();