// hr-pages.jsx — Персонал: Профілі · Час · Відпустки · Документи // ============ ПРОФІЛІ ============ function ProfilesPage({ tasks, role, onPromote, onGoToTasks }) { const team = window.DATA.TEAM; const [openId, setOpenId] = React.useState(null); const [certFor, setCertFor] = React.useState(null); // людина для довідки про доходи const [view, setView] = React.useState("table"); // table | cards const canSeeSalary = role === "director" || role === "accountant"; // КЕП, що потребують уваги (для шапки) const kepExpiring = team.filter(p => p.kep && window.kepStatus(p.kep.validUntil).state === "expiring").length; const kepExpired = team.filter(p => p.kep && window.kepStatus(p.kep.validUntil).state === "expired").length; // Сортування const [sort, setSort] = React.useState("role"); const sorted = React.useMemo(() => { const arr = [...team]; if (sort === "name") arr.sort((a,b) => a.name.localeCompare(b.name)); if (sort === "load") arr.sort((a,b) => b.load - a.load); if (sort === "role") arr.sort((a,b) => a.role.localeCompare(b.role)); return arr; }, [sort]); return (

Профілі

{team.length} {window.plural(team.length, "людина", "людини", "людей")} у штаті {team.filter(t => t.employment === "staff").length} штат {team.filter(t => t.employment === "part").length} часткова {team.filter(t => t.employment === "contractor").length} субпідряд {(kepExpiring + kepExpired) > 0 && <> 0 ? "var(--late)" : "var(--warn, #b8860b)"}}> {kepExpired > 0 ? `${kepExpired} КЕП прострочено` : `${kepExpiring} КЕП завершується`} }
{view === "table" && ( {sorted.map(p => { const empl = window.EMPLOYMENT_LABELS[p.employment]; const active = tasks.filter(t => t.assignee === p.id && t.status !== "done").length; const late = tasks.filter(t => t.assignee === p.id && t.status === "late").length; return ( setOpenId(p.id)} className="is-clickable"> ); })}
Прізвище ім'я Статус Активних задач Завантаження КЕП Телефон Email
{p.initials}
{p.name}
{p.role}
{empl?.label} {active} {late > 0 && +{late} прострочено}
= 90 ? "is-high" : ""}`} style={{width: p.load + "%"}} >
{p.load}%
{p.phone} e.stopPropagation()}>{p.email}
)} {view === "cards" && (
{sorted.map(p => { const empl = window.EMPLOYMENT_LABELS[p.employment]; const active = tasks.filter(t => t.assignee === p.id && t.status !== "done").length; return (
setOpenId(p.id)}>
{p.initials}
{empl?.label}
{p.name}
{p.role}
Активних: {active}
Завантаження: = 90 ? "high" : ""}>{p.load}%
{p.phone}
); })}
)} {openId && ( p.id === openId)} tasks={tasks} canSeeSalary={canSeeSalary} onClose={() => setOpenId(null)} onIssueCert={(p) => setCertFor(p)} onPromote={onPromote} onGoToTasks={onGoToTasks} /> )} {certFor && ( setCertFor(null)} /> )}
); } // ============ КЕП — компактний індикатор статусу ============ function KepCell({ person, compact }) { const kep = person.kep; if (!kep) return ; const st = window.kepStatus(kep.validUntil); const title = st.state === "expired" ? `Прострочено ${formatDate(kep.validUntil)}` : `Дійсний до ${formatDate(kep.validUntil)} · залишилось ${st.days} дн.`; if (compact) { return ( {st.state === "expired" ? "КЕП прострочено" : `до ${shortDate(kep.validUntil)}`} ); } return (
{shortDate(kep.validUntil)} {st.state !== "active" && ( {st.state === "expired" ? "прострочено" : `${st.days} дн.`} )}
); } // ============ КЕП — секція в профілі з нагадуванням ============ function KepSection({ kep }) { const st = window.kepStatus(kep.validUntil); return (
КЕП — електронний підпис {st.label}
{st.state === "expired" ? `Прострочено ${Math.abs(st.days)} дн. тому — потрібно перевипустити` : st.state === "expiring" ? `Завершується через ${st.days} дн. — час оновити` : `Дійсний ще ${st.days} дн.`}
{st.state === "active" ? "Сповіщення прийде автоматично за 30 днів до завершення." : "Перевипуск через Дія.Підпис або АЦСК провайдера."}
{st.state !== "active" && }
Тип
{kep.type}
Провайдер
{kep.provider}
Серійний №
{kep.serial}
Видано
{formatDate(kep.issuedDate)}
Дійсний до
{formatDate(kep.validUntil)}
); } // ============ Кваліфікаційний сертифікат — картка в профілі ============ function QualCertCard({ qc }) { const training = window.qualTrainingStatus(qc); const cont = window.edessbContinuityStatus(qc); const upgrade = window.qualUpgrade(qc); return (
{qc.kind}
№{qc.number}{qc.scope ? ` · ${qc.scope}` : ""}
{upgrade && (
Можливе підвищення класу наслідків: {upgrade.current} → {upgrade.next}
)}
Підвищення кваліфікації раз на 5 років · дедлайн {formatDate(training.dueDate)}
{training.state === "expired" ? "Прострочено" : training.state === "expiring" ? `${training.days} дн.` : "За графіком"}
Безперервність стажу · ЄДЕССБ щороку · наступне до {formatDate(cont.dueDate)}
{cont.state === "expired" ? "Час подавати" : cont.state === "expiring" ? `${cont.days} дн.` : "Подано"}
{qc.scan && (
Сканкопія сертифіката
)}
); } // ============ КАР'ЄРА ТА РОЗВИТОК ============ // Драбина інженерної категорії — візуальна шкала function EngineerLadder({ person }) { const ladder = window.ENGINEER_LADDER || []; const curRank = (function () { const r = (person.positionOfficial || person.role || "").toLowerCase(); if (/провідн/.test(r)) return 3; if (/(іі|ii|2)\s*катег/.test(r)) return 1; if (/(і|i|1)\s*катег/.test(r)) return 2; return 0; })(); return (
{ladder.map((step, i) => (
{i <= curRank ? : null}
{i === 3 ? "Провідний інженер-проєктувальник" : (i === 0 ? "Без категорії" : `${step.label}`)}
{step.needs &&
потрібен сертифікат {step.needs}
}
))}
); } function CareerSection({ person, canSeeSalary, onPromote, onGoToTasks }) { const [done, setDone] = React.useState(null); const st = window.careerState(person); const promo = st.promotion; const tpl = promo ? window.getHrProcessTemplate(promo.templateId) : null; const part = person.partRate || 1; const curOklad = (person.baseSalary || 0) * part; const newOklad = curOklad + (promo ? promo.salaryDelta : 0) * part; const apply = () => { if (!promo) return; const summary = { kind: promo.kind, target: promo.targetPosition, delta: promo.salaryDelta, tasks: tpl ? tpl.taskTemplateIds.length : 0, tplName: tpl ? tpl.name : "", }; onPromote && onPromote(person.id); setDone(summary); }; const trackChip = ( {st.trackLabel} ); return (
Кар'єра та розвиток {trackChip}
{/* Після оформлення */} {done && (
Оформлено
{done.kind === "cert" ? <>Нараховано надбавку за кваліфікаційний сертифікат. : <>Працівника переведено на посаду {done.target}.} {canSeeSalary && done.delta > 0 && <> Оклад підвищено на {formatMoney(done.delta)} ₴.}
Створено {done.tasks} {window.plural(done.tasks, "задачу-супровід", "задачі-супровід", "задач-супровід")} ({done.tplName}).
{onGoToTasks && }
)} {/* Доступне підвищення */} {!done && promo && (
{promo.badge}
Зараз {st.currentPosition}
{promo.kind === "cert" ? "Посада без змін" : "Нова посада"} {promo.kind === "cert" ? st.currentPosition : promo.targetPosition}
{promo.reason}
{canSeeSalary && promo.salaryDelta > 0 && (
Оклад {formatMoney(curOklad)} ₴ {formatMoney(newOklad)} ₴ +{formatMoney(promo.salaryDelta * part)} ₴
)} {tpl && (
Оформлення за шаблоном «{tpl.name}» — {tpl.taskTemplateIds.length} {window.plural(tpl.taskTemplateIds.length, "задача", "задачі", "задач")}-супровід
)}
)} {/* Наступний крок (без поточного підвищення) */} {!done && !promo && st.nextStep && (
Наступний щабель
{st.nextStep.text}
{st.nextStep.requires}
)} {/* Вершина / поза прогресією */} {!done && !promo && !st.nextStep && (st.topNote || st.note) && (
{st.topNote || st.note}
)} {/* Драбина для інженерів */} {st.track === "engineer" && } {/* Історія переходів */} {person.careerLog && person.careerLog.length > 0 && (
Історія
{person.careerLog.map((l, i) => (
{formatDate(l.date)} {l.kind === "cert" ? "Надбавка за сертифікат" : `${l.from} → ${l.to}`}{l.delta ? `, +${formatMoney(l.delta)} ₴` : ""}
))}
)}
); } // ============ DRAWER ПРОФІЛЮ ============ function ProfileDrawer({ person, tasks, canSeeSalary, onClose, onIssueCert, onPromote, onGoToTasks }) { const cashVisible = window.useCashVisible(); React.useEffect(() => { const onKey = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); if (!person) return null; const empl = window.EMPLOYMENT_LABELS[person.employment]; const active = tasks.filter(t => t.assignee === person.id && t.status !== "done"); const late = active.filter(t => t.status === "late"); const vacations = window.DATA.VACATIONS.filter(v => v.who === person.id); const career = window.careerState ? window.careerState(person) : null; return ( <>
); } // ============ ДОВІДКА ПРО ДОХОДИ ============ function IncomeCertificate({ person, onClose }) { React.useEffect(() => { const onKey = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const co = window.COMPANY; const rows = window.incomeRows(person, 6); const tot = rows.reduce((a, r) => ({ gross: a.gross + r.gross, pdfo: a.pdfo + r.pdfo, vz: a.vz + r.vz, net: a.net + r.net, }), { gross: 0, pdfo: 0, vz: 0, net: 0 }); const period = `${rows[0].label} – ${rows[rows.length - 1].label}`; const outNo = `${person.id.toUpperCase()}-${rows.length}/${new Date(window.SYS_DATE).getFullYear()}`; return ( <>
Довідка про доходи
{person.name} · період {period}
{co.shortName}
ЄДРПОУ {co.edrpou}
{co.address}
IBAN {co.iban}, {co.bank}
Тел. {co.phone}
Вих. № {outNo}
від {formatLongDate(window.SYS_DATE)}

ДОВІДКА ПРО ДОХОДИ

Видана {person.name} (РНОКПП {person.taxId}) про те, що він/вона {person.employment === "contractor" ? "співпрацює з" : "працює в"} {co.fullName} на посаді «{person.positionOfficial || person.role}» з {formatLongDate(person.hireDate)} {person.hireOrder ? ` (${person.hireOrder})` : ""}.

Розмір нарахованого доходу за період {period} наведено в таблиці:

{rows.map((r, i) => ( ))}
Місяць Нараховано, ₴ ПДФО (18%) Військовий збір (5%) До виплати, ₴
{r.label} {formatMoney(r.gross)} {formatMoney(r.pdfo)} {formatMoney(r.vz)} {formatMoney(r.net)}
Усього {formatMoney(tot.gross)} {formatMoney(tot.pdfo)} {formatMoney(tot.vz)} {formatMoney(tot.net)}

Середньомісячний дохід — {formatMoney(Math.round(tot.gross / rows.length))} ₴. Податки та збори утримано й перераховано до бюджету в повному обсязі. Довідку видано для подання за місцем вимоги.

Директор
{co.director}
Головний бухгалтер
{co.accountant}
М.П.
); } // ============ СКАНКОПІЇ ДОКУМЕНТІВ ============ function ScanViewer({ scan, onClose }) { React.useEffect(() => { const onKey = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); return ( <>
{scan.name}
{scan.uploaded ? `Завантажено ${formatDate(scan.uploaded)}` : "Перетягніть файл, щоб додати скан"}
); } function ScanChip({ scan }) { const [open, setOpen] = React.useState(false); if (!scan) return null; return ( <> {open && setOpen(false)} />} ); } // ============ КВАЛІФІКАЦІЙНІ СЕРТИФІКАТИ ============ function QualClassChip({ cls, upgrade }) { return ( {cls} {upgrade && → {upgrade.next}} ); } function QualificationsPage({ role }) { const team = window.DATA.TEAM; // розгортаємо в плоский список сертифікатів const certs = []; team.forEach(p => (p.qualCerts || []).forEach(qc => { certs.push({ person: p, qc, training: window.qualTrainingStatus(qc), cont: window.edessbContinuityStatus(qc), upgrade: window.qualUpgrade(qc), }); })); const without = team.filter(p => !p.qualCerts || p.qualCerts.length === 0); // фахівці з сертифікатами — для контролю страхування та членських внесків const specialists = team .filter(p => p.qualCerts && p.qualCerts.length > 0) .map(p => ({ person: p, ins: window.insuranceStatus(p.insurance), mem: window.membershipStatus(p.membership), })); // нагадування — усе, що прострочено або скоро const order = { expired: 0, expiring: 1, active: 2 }; const reminders = []; certs.forEach(c => { if (c.training.state !== "active") reminders.push({ ...c, kind: "training", st: c.training }); if (c.cont.state !== "active") reminders.push({ ...c, kind: "cont", st: c.cont }); }); specialists.forEach(s => { if (s.ins && s.ins.state !== "active") reminders.push({ person: s.person, kind: "insurance", st: s.ins, ref: s.person.insurance }); if (s.mem && s.mem.state !== "active") reminders.push({ person: s.person, kind: "membership", st: s.mem, ref: s.person.membership }); }); reminders.sort((a, b) => order[a.st.state] - order[b.st.state] || a.st.days - b.st.days); return (

Кваліфікаційні сертифікати

{certs.length} {window.plural(certs.length, "сертифікат", "сертифікати", "сертифікатів")} у {certs.length ? new Set(certs.map(c => c.person.id)).size : 0} фахівців {without.length} без сертифіката {reminders.length > 0 && <> {reminders.length} {window.plural(reminders.length, "нагадування", "нагадування", "нагадувань")} }
Правила: підвищення кваліфікації — раз на 5 років від останньої атестації; відомості про безперервність стажу до ЄДЕССБ подаються щороку. Договір страхування професійної відповідальності та членські внески у профоб'єднанні поновлюються щороку — без них сертифікат може втратити чинність.
{reminders.length > 0 && (

Потребує уваги

{reminders.length}
{reminders.map((r, i) => { const tagLabel = { training: "Підвищення", cont: "ЄДЕССБ", insurance: "Страхування", membership: "Внесок" }[r.kind]; const btnLabel = { training: "Записати на атестацію", cont: "Подати в ЄДЕССБ", insurance: "Поновити поліс", membership: "Сплатити внесок" }[r.kind]; let sub; if (r.kind === "training") { sub = r.st.state === "expired" ? `Підвищення кваліфікації прострочено на ${Math.abs(r.st.days)} дн. (дедлайн ${formatDate(r.st.dueDate)})` : `Підвищення кваліфікації — дедлайн ${formatDate(r.st.dueDate)} (через ${r.st.days} дн.)`; } else if (r.kind === "cont") { sub = r.st.state === "expired" ? `Відомості в ЄДЕССБ не подані — термін минув ${formatDate(r.st.dueDate)}` : `Відомості в ЄДЕССБ подати до ${formatDate(r.st.dueDate)} (через ${r.st.days} дн.)`; } else if (r.kind === "insurance") { sub = r.st.state === "expired" ? `Поліс ${r.ref.insurer} (${r.ref.policyNumber}) недійсний з ${formatDate(r.st.dueDate)}` : `Поліс ${r.ref.insurer} спливає ${formatDate(r.st.dueDate)} (через ${r.st.days} дн.)`; } else { sub = r.st.state === "expired" ? `Членський внесок (${r.ref.org}) не сплачено з ${formatDate(r.st.dueDate)}` : `Членський внесок ${formatMoney(r.ref.feeAmount)} ₴ сплатити до ${formatDate(r.st.dueDate)} (через ${r.st.days} дн.)`; } return (
{r.person.initials}
{r.person.name}{r.qc ? <> · {r.qc.number} : null}
{sub}
{tagLabel}
); })}
)}

Усі сертифікати

{certs.length}
{certs.map((c, i) => ( ))}
Фахівець / сертифікат Клас наслідків Отримано Підвищення кваліфікації Безперервність (ЄДЕССБ)
{c.person.initials}
{c.person.name}
{c.qc.kind} · №{c.qc.number}
{c.qc.scan &&
}
{formatDate(c.qc.issuedDate)}
{formatDate(c.training.dueDate)} {c.training.label}
{formatDate(c.cont.dueDate)} {c.cont.label}
{specialists.length > 0 && (

Страхування та членські внески

щорічний контроль · {specialists.length}
{specialists.map((s, i) => ( ))}
Фахівець Страхування профвідповідальності Чинний до Членські внески Сплачено до
{s.person.initials}
{s.person.name}
{s.person.role}
{s.person.insurance ? (
{s.person.insurance.insurer}
{s.person.insurance.policyNumber} · {formatMoney(s.person.insurance.sumInsured)} ₴
{s.person.insurance.scan &&
}
) : }
{s.ins ? (
{formatDate(s.ins.dueDate)} {s.ins.label}
) : }
{s.person.membership ? (
{s.person.membership.org}
{formatMoney(s.person.membership.feeAmount)} ₴ / рік · з {s.person.membership.memberSince}
{s.person.membership.scan &&
}
) : }
{s.mem ? (
{formatDate(s.mem.dueDate)} {s.mem.label}
) : }
)} {without.length > 0 && (

Без кваліфікаційного сертифіката

{without.length}
{without.map(p => (
{p.initials}
{p.name}
{p.role}
))}
)}
); } // ============ ОБЛІК ЧАСУ ============ function TimePage({ tasks, role }) { const team = window.DATA.TEAM; // Тижні рахуються від ПОТОЧНОГО (window.CURRENT_WEEK з бекенду), а не захардкоджені. const MON_UA = ["січ", "лют", "бер", "кві", "трав", "черв", "лип", "серп", "вер", "жовт", "лист", "груд"]; const isoMonday = (y, w) => { const d = new Date(Date.UTC(y, 0, 4)); const dn = (d.getUTCDay() + 6) % 7; d.setUTCDate(d.getUTCDate() - dn + (w - 1) * 7); return d; }; const isoWeekOf = (d) => { const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); const dn = (t.getUTCDay() + 6) % 7; t.setUTCDate(t.getUTCDate() - dn + 3); const ft = new Date(Date.UTC(t.getUTCFullYear(), 0, 4)); const w = 1 + Math.round(((t - ft) / 864e5 - 3 + ((ft.getUTCDay() + 6) % 7)) / 7); return { y: t.getUTCFullYear(), w }; }; const curM = /(\d{4})-W(\d{2})/.exec(window.CURRENT_WEEK || ""); const baseMon = curM ? isoMonday(+curM[1], +curM[2]) : (() => { const t = new Date((window.SYS_DATE || new Date().toISOString().slice(0, 10)) + "T00:00:00Z"); const dn = (t.getUTCDay() + 6) % 7; t.setUTCDate(t.getUTCDate() - dn); return t; })(); const weekFor = (offset) => { const mon = new Date(baseMon); mon.setUTCDate(baseMon.getUTCDate() - offset * 7); const wk = isoWeekOf(mon), nums = [], iso = []; for (let i = 0; i < 5; i++) { const dd = new Date(mon); dd.setUTCDate(mon.getUTCDate() + i); nums.push(String(dd.getUTCDate())); iso.push(dd.toISOString().slice(0, 10)); } const fri = new Date(mon); fri.setUTCDate(mon.getUTCDate() + 4); return { id: `${wk.y}-W${String(wk.w).padStart(2, "0")}`, nums, iso, label: `W${String(wk.w).padStart(2, "0")} · ${mon.getUTCDate()}–${fri.getUTCDate()} ${MON_UA[fri.getUTCMonth()]}` }; }; const weekList = [weekFor(0), weekFor(1), weekFor(2)]; weekList[0].label = "Поточний · " + weekList[0].label; weekList[1].label = "Попередній · " + weekList[1].label; const [week, setWeek] = React.useState(weekList[0].id); const sheet = window.TIMESHEET[week] || {}; const selected = weekList.find(x => x.id === week) || weekList[0]; const dayNames = ["Пн", "Вт", "Ср", "Чт", "Пт"]; const dayNums = selected.nums; const todayIdx = selected.iso.indexOf(window.SYS_DATE); // загальні підсумки const totals = team.map(p => { const row = sheet[p.id]; if (!row) return { person: p, sum: 0, planned: 0 }; const sum = row.hours.reduce((a,b) => a + b, 0); return { person: p, sum, planned: row.planned, note: row.note }; }); const sumAll = totals.reduce((s, r) => s + r.sum, 0); const planAll = totals.reduce((s, r) => s + r.planned, 0); const weeks = weekList; return (

Облік часу

тижневий timesheet відпрацьовано {sumAll} з {planAll} планових год
{weeks.map(w => ( ))}
{dayNames.map((d, i) => ( ))} {team.map(p => { const row = sheet[p.id]; if (!row) return null; const sum = row.hours.reduce((a,b) => a + b, 0); const pct = row.planned > 0 ? Math.round((sum / row.planned) * 100) : 0; return ( {row.hours.map((h, i) => ( ))} ); })}
Працівник
{d}
{dayNums[i]}
Сума План
{p.initials}
{p.name}
{p.role}
{h > 0 ? {h} : } {sum} {row.planned > 0 ?
= 100 ? "is-high" : ""}`} style={{width: Math.min(pct, 100) + "%"}}>
{row.planned}
: }
Усього {sumAll} {planAll}
Як працює timesheet: кожен працівник заповнює фактичні години в кінці дня. Для штатних — порівнюємо з планом (40 год/тиждень) та коригуємо ЗП. Для субпідрядників — це години для розрахунку гонорару за задачами.
); } // ============ ВІДПУСТКИ ============ function VacationPage({ role }) { const team = window.DATA.TEAM; const vacations = window.DATA.VACATIONS; const pending = vacations.filter(v => v.status === "pending"); const approved = vacations.filter(v => v.status === "approved"); return (

Відпустки і лікарняні

{pending.length} {window.plural(pending.length, "заявка", "заявки", "заявок")} очікують {approved.length} затверджено
{/* Баланс по людях */}

Баланс днів

залишок на 2026 рік
{team.map(p => { const upcoming = vacations.find(v => v.who === p.id && v.type === "vacation" && v.status === "approved" && v.from > "2026-05-26"); return ( ); })}
Працівник Залишок відпустки Лікарняних взято Запланована відпустка
{p.initials}
{p.name}
{p.role}
{p.vacationBalance} дн. {p.sickBalance > 0 ? {p.sickBalance} дн. : } {upcoming ? {formatDate(upcoming.from)} – {formatDate(upcoming.to)} ({upcoming.days} дн.) : немає }
{/* Очікують підтвердження */} {pending.length > 0 && (

Очікують підтвердження

{pending.length}
{pending.map(v => { const p = window.getTeam(v.who); return (
{p?.initials}
{p?.name}
{v.note}
{formatDate(v.from)} – {formatDate(v.to)}
{v.days} {window.plural(v.days, "день", "дні", "днів")} · {v.type === "vacation" ? "відпустка" : "лікарняний"}
); })}
)} {/* Затверджені — короткий список */}

Затверджені

{approved.length}
{approved.map(v => { const p = window.getTeam(v.who); return (
{p?.initials}
{p?.name}
{v.note}
{formatDate(v.from)} – {formatDate(v.to)}
{v.days} {window.plural(v.days, "день", "дні", "днів")} · {v.type === "vacation" ? "відпустка" : "лікарняний"}
затверджено
); })}
); } // ============ ДОКУМЕНТИ ============ function DocumentsPage({ role }) { const docs = window.DATA.DOCUMENTS; const [filter, setFilter] = React.useState("all"); const filtered = filter === "all" ? docs : docs.filter(d => d.type === filter); return (

Документи

{docs.length} {window.plural(docs.length, "документ", "документи", "документів")} {docs.filter(d => d.status === "draft").length} чернеток {docs.filter(d => d.status === "pending").length} на підписанні
{/* Drive integration banner */}
Усі документи зберігаються в Google Drive
Папка HR / Накази 2026 · автоматична синхронізація · підписання через Дія.Підпис
{filtered.map(d => { const st = window.DOC_STATUS_LABELS[d.status]; return ( ); })}
Тип Назва Стосується Дата Статус Файл
{window.DOC_TYPE_LABELS[d.type]}
{d.title}
{d.note}
{d.who.slice(0,3).map((id, i) => { const p = window.getTeam(id); return {p?.initials[0]}; })} {d.who.length > 3 && +{d.who.length - 3}}
{formatDate(d.date)} {st?.label} в Drive
); } // ============ HELPERS ============ function formatDate(iso) { if (!iso) return "—"; const [y, m, d] = iso.split("-"); const months = ["січ", "лют", "бер", "квіт", "трав", "черв", "лип", "серп", "вер", "жовт", "лист", "груд"]; return `${parseInt(d, 10)} ${months[parseInt(m, 10) - 1]} ${y}`; } function shortDate(iso) { if (!iso) return "—"; const [y, m, d] = iso.split("-"); return `${d}.${m}.${y.slice(2)}`; } function formatLongDate(iso) { if (!iso) return "—"; const [y, m, d] = iso.split("-"); const months = ["січня","лютого","березня","квітня","травня","червня","липня","серпня","вересня","жовтня","листопада","грудня"]; return `${parseInt(d, 10)} ${months[parseInt(m, 10) - 1]} ${y} р.`; } function yearsBetween(fromIso, toIso) { const [fy, fm, fd] = fromIso.split("-").map(Number); const [ty, tm, td] = toIso.split("-").map(Number); let y = ty - fy; if (tm < fm || (tm === fm && td < fd)) y--; return y; } function formatMoney(n) { return new Intl.NumberFormat("uk-UA", { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(n); } window.ProfilesPage = ProfilesPage; window.QualificationsPage = QualificationsPage; window.TimePage = TimePage; window.VacationPage = VacationPage; window.DocumentsPage = DocumentsPage; window.formatDate = formatDate; window.formatMoney = formatMoney; window.yearsBetween = yearsBetween;