// projects-store.jsx — стор проєктної діяльності: мутації + збереження між // перезавантаженнями (localStorage, demo-режим) + модалка створення проєкту. // На бекенді (v4) це замінюється API; localStorage — лише для пілота в demo. (function () { const LS_KEY = "ubp.projects.v2"; const SYS = window.SYS_DATE || "2026-05-29"; const isLive = () => !!(window.API && window.API.mode === "live"); let subs = []; const notify = () => subs.forEach(f => { try { f(); } catch (_) {} }); function persist() { if (isLive()) return; try { localStorage.setItem(LS_KEY, JSON.stringify({ v: 1, projects: window.DATA.PROJECTS, partners: window.DATA.NGO_PARTNERS, donors: window.DATA.DONORS, })); } catch (_) {} } // Гідрація збереженого стану (demo). Викликається один раз при завантаженні. (function hydrate() { if (isLive()) return; try { const raw = localStorage.getItem(LS_KEY); if (!raw) return; const d = JSON.parse(raw); if (Array.isArray(d.projects)) window.DATA.PROJECTS = d.projects; if (Array.isArray(d.partners)) window.DATA.NGO_PARTNERS = d.partners; if (Array.isArray(d.donors)) window.DATA.DONORS = d.donors; } catch (_) {} })(); window.ProjectsStore = { subscribe(f) { subs.push(f); return () => { subs = subs.filter(x => x !== f); }; }, save() { persist(); notify(); }, mutate(fn) { fn(window.DATA.PROJECTS); persist(); notify(); }, reset() { try { localStorage.removeItem(LS_KEY); } catch (_) {} location.reload(); }, }; // Хук перерендеру при зміні проєктів window.useProjects = function () { const [, force] = React.useState(0); React.useEffect(() => window.ProjectsStore.subscribe(() => force(x => x + 1)), []); return window.DATA.PROJECTS; }; // ── Дії життєвого циклу ── window.closeProject = function (id, note) { window.ProjectsStore.mutate(list => { const p = list.find(x => x.id === id); if (!p) return; p.status = "closed"; p.closedDate = SYS; p.closeNote = note || "Проєкт донора завершено — контракт закрито, переведено в архів."; }); }; window.reopenProject = function (id) { window.ProjectsStore.mutate(list => { const p = list.find(x => x.id === id); if (!p) return; p.status = "active"; delete p.closedDate; delete p.closeNote; }); }; // ── Створення проєкту ── window.createProject = function (data) { const id = "prj-" + Math.random().toString(36).slice(2, 7); const year = (data.start || SYS).slice(0, 4); const seq = String((window.DATA.PROJECTS || []).length + 1).padStart(2, "0"); // новий партнер / донор за потреби let partnerId = data.partner; if (data.partner === "__new" && data.partnerName) { partnerId = "ngo-" + Math.random().toString(36).slice(2, 6); window.DATA.NGO_PARTNERS.push({ id: partnerId, name: data.partnerName, short: data.partnerName.replace(/^ГО\s*«?|»$/g, "").trim() || data.partnerName, edrpou: data.partnerEdrpou || "—", city: data.partnerCity || "—", focus: "—", contact: null, note: "Додано вручну.", }); } let donorId = data.donor; if (data.donor === "__new" && data.donorName) { donorId = "dn-" + Math.random().toString(36).slice(2, 6); window.DATA.DONORS.push({ id: donorId, name: data.donorName, nameUk: data.donorName, country: "—", kind: "Донор", note: "Додано вручну." }); } const total = Number(data.total) || 0; const adv = Math.round(total * 0.3); const schedule = total > 0 ? [ { stage: "Аванс при підписанні", pct: 30, amount: adv, dueDate: data.start || SYS, billing: "advance" }, { stage: "Фінальний — передача + звіт донору", pct: 70, amount: total - adv, dueDate: data.end || SYS, billing: "work" }, ] : []; const project = { id, code: data.code || `ПД-${year}-${seq}`, name: data.name, number: data.number || `${seq}/${year}`, partner: partnerId, donor: donorId, execEntity: data.execEntity || "fop", role: data.role || "", scope: data.scope || "", start: data.start || SYS, end: data.end || SYS, status: "active", manager: data.manager || "ok", team: data.manager ? [data.manager] : ["ok"], communities: [], deliverables: [], docs: [], contract: { number: data.number || `${seq}/${year}`, date: data.start || SYS, total, parties: { ourRole: "executor", counterRole: "customer", subject: "services" }, schedule, }, tasks: [], }; window.DATA.PROJECTS.unshift(project); persist(); notify(); return project; }; // ════════════ МОДАЛКА СТВОРЕННЯ ════════════ window.updateProject = function (id, patch) { window.ProjectsStore.mutate(list => { const p = list.find(x => x.id === id); if (!p) return; Object.assign(p, patch); if (p.contract) { p.contract.execEntity = p.execEntity; p.contract.projectName = p.name; p.contract.partnerId = p.partner; if (patch.number) p.contract.number = patch.number; } }); }; function CreateGoProjectModal({ onClose, onCreated, project }) { const isEdit = !!project; const partners = window.DATA.NGO_PARTNERS || []; const donors = window.DATA.DONORS || []; const team = window.DATA.TEAM || []; const [f, setF] = React.useState(isEdit ? { name: project.name || "", number: project.number || "", partner: project.partner, partnerName: "", partnerCity: "", partnerEdrpou: "", donor: project.donor, donorName: "", execEntity: project.execEntity || "fop", scope: project.scope || "", role: project.role || "", start: project.start || SYS, end: project.end || "", total: "", manager: project.manager || "ok", } : { name: "", number: "", partner: partners[0] ? partners[0].id : "__new", partnerName: "", partnerCity: "", partnerEdrpou: "", donor: donors[0] ? donors[0].id : "__new", donorName: "", execEntity: "fop", scope: "", role: "", start: SYS, end: "", total: "", manager: "ok", }); const up = (k, v) => setF(prev => ({ ...prev, [k]: v })); React.useEffect(() => { const onKey = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const needPartnerName = f.partner === "__new"; const needDonorName = f.donor === "__new"; const canSubmit = f.name.trim() && (!needPartnerName || f.partnerName.trim()) && (!needDonorName || f.donorName.trim()); const submit = () => { if (!canSubmit) return; if (isEdit) { window.updateProject(project.id, { name: f.name.trim(), number: f.number.trim() || project.number, partner: f.partner === "__new" ? project.partner : f.partner, donor: f.donor === "__new" ? project.donor : f.donor, execEntity: f.execEntity, scope: f.scope.trim(), role: f.role.trim(), start: f.start, end: f.end, manager: f.manager, }); onCreated && onCreated(project); } else { const p = window.createProject(f); onCreated && onCreated(p); } }; return (
{isEdit ? "Редагувати проєкт" : "Новий проєкт ГО"}
{isEdit ? project.code : "Грантова / донорська співпраця"}
up("name", e.target.value)} placeholder="напр. Підготовка громад до опалювального сезону" />
up("number", e.target.value)} placeholder="напр. ЕК-07/2024" />
{needPartnerName &&
up("partnerName", e.target.value)} placeholder="ГО «…»" />
up("partnerCity", e.target.value)} placeholder="місто" /> up("partnerEdrpou", e.target.value)} placeholder="ЄДРПОУ" />
} {needDonorName &&
up("donorName", e.target.value)} placeholder="напр. People in Need" />
}
up("scope", e.target.value)} placeholder="напр. 6 громад Дніпропетровської області" />
up("role", e.target.value)} placeholder="напр. Розробка ПКД для пілотних закладів громад" />
up("start", e.target.value)} />
up("end", e.target.value)} />
{!isEdit &&
up("total", e.target.value)} placeholder="напр. 360000" />
}
{!isEdit &&
За сумою автоматично створиться графік: аванс 30% + фінал 70%. Транші, деліверабли й документи додасте у воркспейсі проєкту.
}
); } window.CreateGoProjectModal = CreateGoProjectModal; })();