// tabel-page.jsx — Табель обліку робочого часу (форма П-5), помісячно. // Редагований, зі стандартними кодами (Р/ВД/В/ТН/ВП/НА/ІН). Підсумок місяця // після закриття передається у «Розрахунок ЗП». Перевизначає window.TimePage. const TABEL_PORTAL = (window.ReactDOM && window.ReactDOM.createPortal) ? window.ReactDOM.createPortal : null; // ——— дрібний чип коду ——— function CodeChip({ code }) { const c = window.TABEL_CODES[code]; if (!c) return null; return {code}; } // ——— поповер-редактор клітинки ——— function TabelCellEditor({ day, current, rect, onPick, onClose }) { const [h, setH] = React.useState(typeof current === "number" ? String(current) : ""); const ref = React.useRef(null); React.useEffect(() => { if (ref.current) ref.current.focus(); }, []); const commit = () => { if (h === "") { onPick(null); return; } const n = parseFloat(String(h).replace(",", ".")); if (!isNaN(n) && n >= 0 && n <= 24) onPick(n); else onClose(); }; const codes = ["В", "ТН", "ВП", "НА", "ІН"]; const POP_H = 250; let top = rect.bottom + 6; if (top + POP_H > window.innerHeight) top = Math.max(74, rect.top - POP_H - 6); top = Math.max(74, top); const left = Math.min(rect.left - 40, window.innerWidth - 250); const node = (
e.stopPropagation()}>
Число {day}
setH(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") onClose(); }} placeholder="год" />
{[8, 4, 0].map((q) => ( ))}
Неявка / особливий день
{codes.map((c) => ( ))}
); return TABEL_PORTAL ? TABEL_PORTAL(node, document.body) : node; } function TabelPage({ role }) { const team = window.DATA.TEAM; const tState = window.useTabel(); const months = window.TABEL_MONTHS; const [month, setMonth] = React.useState(months[0].id); const [editing, setEditing] = React.useState(null); // {pid, day, rect} const monthObj = tState[month] || { status: "draft", entries: {} }; const status = monthObj.status; const statusMeta = window.TABEL_STATUS_LABELS[status] || window.TABEL_STATUS_LABELS.draft; const canEdit = (role === "director" || role === "accountant") && status !== "closed"; const meta = window.monthMeta(month); const monthLabel = (months.find((m) => m.id === month) || {}).label || month; const SYS = window.SYS_DATE || "2026-05-29"; const sysMonth = SYS.slice(0, 7); const sysDay = parseInt(SYS.slice(8, 10), 10); const staff = team.filter((p) => p.employment !== "contractor"); const contractors = team.filter((p) => p.employment === "contractor"); // ——— значення клітинки ——— const getVal = (pid, day) => (monthObj.entries[pid] || {})[day]; const setVal = (pid, day, val) => { if (!window.__tabelState[month].entries[pid]) window.__tabelState[month].entries[pid] = {}; const ent = window.__tabelState[month].entries[pid]; if (val == null || val === "") delete ent[day]; else ent[day] = val; window.saveTabelState(); }; const setMonthStatus = (st) => { const m = window.__tabelState[month]; m.status = st; m.closedAt = st === "closed" ? SYS : null; window.saveTabelState(); }; // ——— підсумки ——— const staffSums = staff.map((p) => ({ p, s: window.tabelSummary(month, p.id) })); const totalWorked = staffSums.reduce((a, r) => a + (r.s ? r.s.workedHours : 0), 0); const totalNorm = staffSums.reduce((a, r) => a + (r.s ? r.s.normHours : 0), 0); const totalAbsDays = staffSums.reduce((a, r) => a + (r.s ? Object.values(r.s.codeDays).reduce((x, y) => x + y, 0) : 0), 0); const attendance = totalNorm > 0 ? Math.round((totalWorked / totalNorm) * 100) : 0; const contrHours = contractors.reduce((a, p) => { const s = window.tabelSummary(month, p.id); return a + (s ? s.workedHours : 0); }, 0); const isToday = (day) => month === sysMonth && day === sysDay; // ——— рендер однієї клітинки дня ——— const dayCell = (p, dm, partRate) => { const today = isToday(dm.day); if (!dm.working) { return ( ВД ); } const v = getVal(p.id, dm.day); const editable = canEdit; return ( setEditing({ pid: p.id, day: dm.day, rect: e.currentTarget.getBoundingClientRect() }) : undefined} > {v == null ? {editable ? "+" : "·"} : (typeof v === "number" ? {v} : )} ); }; return (

Табель обліку робочого часу

типова форма П-5 {monthLabel} {statusMeta.label} {status === "closed" && monthObj.closedAt && ( закрито {window.formatDate(monthObj.closedAt)} )}
{/* Підсумок */}
Відпрацьовано
{totalWorked} год
штат і часткова зайнятість
Норма за місяць
{totalNorm} год
{meta.filter((d) => d.working).length} робочих днів
Явка
= 100 ? "var(--c-green-deep)" : "var(--ink)" }}>{attendance}%
факт / норма
Неявки
{totalAbsDays} дн.
відпустки · лікарняні · інше
{/* Тулбар: місяць + легенда */}
{months.map((m) => ( ))}
{["Р", "В", "ТН", "ВП", "НА", "ІН"].map((c) => ( {c} {window.TABEL_CODES[c].label} ))}
{/* Сітка табеля */}
{meta.map((dm) => ( ))} {staffSums.map(({ p, s }) => { const pct = s && s.normHours > 0 ? Math.round((s.workedHours / s.normHours) * 100) : 0; return ( {meta.map((dm) => dayCell(p, dm))} ); })}
Працівник {dm.dowName} {dm.day} Σ год Норма Явка
{p.initials}
{p.name}
{p.role}{p.partRate ? ` · ${p.partRate} ставки` : ""}
{s ? s.workedHours : 0} {s ? s.normHours : 0}
= 100 ? "is-high" : ""}`} style={{ width: Math.min(pct, 100) + "%" }}>
{pct}%
Усього {totalAbsDays > 0 ? `${totalAbsDays} дн. неявок у місяці` : "повна явка"} {totalWorked} {totalNorm} {attendance}%
{/* Субпідряд / ЦПХ */} {contractors.length > 0 && (

Субпідряд · ЦПХ

погодинно за задачами · без норми та кодів неявок
{meta.map((dm) => ( ))} {contractors.map((p) => { const s = window.tabelSummary(month, p.id); const fee = (s ? s.workedHours : 0) * (p.taskRate || 0); return ( {meta.map((dm) => { const today = isToday(dm.day); const v = getVal(p.id, dm.day); return ( ); })} ); })}
Виконавець {dm.dowName} {dm.day} Σ год Гонорар
{p.initials}
{p.name}
{p.role} · {p.taskRate} ₴/год
setEditing({ pid: p.id, day: dm.day, rect: e.currentTarget.getBoundingClientRect() }) : undefined} > {v == null ? {canEdit ? "+" : "·"} : (typeof v === "number" ? {v} : )} {s ? s.workedHours : 0} {window.formatMoney(fee)} ₴
)} {/* Передача у ЗП */}
Підсумок місяця йде у «Розрахунок ЗП»
{status === "closed" ? <>Табель закрито — відпрацьовані години передано в нарахування за {monthLabel}. : <>Після закриття місяця відпрацьовані години автоматично підставляться у розрахунок ЗП (замість ручного вводу). Оклад штатних коригується пропорційно нормі, лікарняні й відпустки рахуються окремо.}
{window.__setPage && } {canEdit && status === "draft" && } {canEdit && status === "submitted" && } {status === "closed" && (role === "director" || role === "accountant") && }
{!canEdit && status !== "closed" && (
Редагувати табель можуть лише директор і бухгалтер. Перемкніть роль угорі, щоб вносити години.
)} {editing && ( setEditing(null)} onPick={(val) => { setVal(editing.pid, editing.day, val); setEditing(null); }} /> )}
); } window.TimePage = TabelPage; window.TabelPage = TabelPage;