krona-staff/src/portal-ui.ts
Ochenstarik 331e4909c9
Some checks are pending
ci / verify (push) Waiting to run
feat(portal): rebuild staff operational workspace
2026-09-26 14:13:21 +07:00

542 lines
87 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { portalDesignCss } from './portal-design.js';
import { portalWorkspaceHtml } from './portal-workspace.js';
const legacyPortalHtml = `<!doctype html>
<html lang="ru" data-omni-theme="noble-green" data-omni-density="workspace">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#657255">
<title>Krona Staff</title>
<link rel="manifest" href="/staff/manifest.webmanifest">
<link rel="icon" type="image/svg+xml" href="/staff/icon-192.svg">
<link rel="apple-touch-icon" href="/staff/icon-192.svg">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="stylesheet" href="/staff/styles.css">
</head>
<body>
<section id="auth-screen" class="auth-screen" aria-labelledby="login-title">
<div class="auth-card">
<div class="auth-brand"><span class="mark">К</span><span>Krona <b>Staff</b></span></div>
<p class="eyebrow">Защищённый кабинет</p>
<h1 id="login-title">Вход в Krona Staff</h1>
<p class="auth-copy">Введите ключ руководителя или личный ключ сотрудника. После входа будут доступны только разрешённые вам данные.</p>
<form id="login-form">
<label for="login-token">Ключ доступа</label>
<input id="login-token" type="password" autocomplete="current-password" required minlength="32" autofocus>
<button id="login-submit" type="submit">Войти</button>
</form>
<div id="login-error" class="login-error" role="alert" hidden></div>
<small>Сессия действует 8 часов и хранится только в защищённой cookie.</small>
</div>
</section>
<div id="app-shell" hidden>
<header class="topbar">
<a class="brand" href="/staff" aria-label="Krona Staff"><span class="mark">К</span><span>Krona <b>Staff</b></span></a>
<div class="actions"><span id="connection" class="status">Подключение…</span><label class="theme-control">Тема<select id="theme-select" aria-label="Тема интерфейса"><option value="noble-green">Зелёная</option><option value="parchment-light">Пергамент</option><option value="graphite-forest-dark">Тёмная</option></select></label><button id="notify" class="quiet" type="button">Уведомления</button><button id="logout" class="quiet" type="button">Выйти</button><button id="refresh" type="button">Обновить</button></div>
</header>
<nav class="section-nav" aria-label="Разделы кабинета">
<button class="nav-item selected" type="button" data-target="overview-view" data-route="/staff/dashboard">Дашборд</button>
<button class="nav-item manager-only" type="button" data-target="employees-view" data-route="/staff/employees">Сотрудники</button>
<button class="nav-item manager-only" type="button" data-target="finance-view" data-route="/staff/finance">Финансы</button>
<button class="nav-item" type="button" data-target="schedule-view" data-route="/staff/schedules">Графики</button>
<button class="nav-item" type="button" data-target="payroll-view" data-route="/staff/payroll">Зарплата</button>
<label class="period-control">Месяц <input id="period-month" type="month" aria-label="Месяц данных"></label>
</nav>
<main>
<div id="overview-view" class="app-view">
<section class="hero">
<div><p class="eyebrow" id="viewer">Личный кабинет</p><h1 id="greeting">Рабочий день — под контролем</h1><p>Отметки терминала, сотрудники и табель в одном защищённом пространстве.</p></div>
<div class="today"><span>Сегодня</span><strong id="today">—</strong><small id="last-refresh">Данные загружаются</small></div>
</section>
<div id="message" class="message" hidden></div>
<section class="stats" aria-label="Сводка">
<article><span>Сотрудников</span><strong id="employees-count">—</strong></article>
<article><span>Отметок сегодня</span><strong id="today-count">—</strong></article>
<article><span>Часов в табеле</span><strong id="hours-count">—</strong></article>
</section>
<section class="layout">
<article class="panel attendance-panel"><div class="panel-head"><div><p class="eyebrow">Последние события</p><h2>Отметки терминала</h2></div><span class="live">● в реальном времени</span></div><div id="attendance" class="feed"></div></article>
<aside class="panel people-panel manager-only"><div class="panel-head"><div><p class="eyebrow">Команда</p><h2>Сотрудники</h2></div><div class="actions"><button id="manage-professions" class="quiet compact" type="button">Профессии</button><button id="add-employee" class="quiet compact" type="button">Добавить</button></div></div><div id="employees" class="people"></div></aside>
</section>
<section class="panel sessions-panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">Сверка терминала</p><h2>Рабочие смены</h2></div><button id="reconcile-sessions" class="quiet" type="button">Сверить 7 дней</button></div><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Подразделение</th><th>Интервал</th><th>Часы</th><th>Состояние</th><th>Причина правки</th><th></th></tr></thead><tbody id="work-sessions"></tbody></table></div></section>
<section class="panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">Учёт времени</p><h2>Табель за 40 дней</h2></div><span id="timesheet-help">Изменения сохраняет руководитель</span></div><form id="timesheet-create" class="timesheet-create"><label>Сотрудник<select id="new-time-employee" required></select></label><label>Дата<input id="new-time-date" type="date" required></label><label>Часы<input id="new-time-hours" type="number" min="0" max="24" step="0.25" value="8" required></label><label>Статус<select id="new-time-status"><option value="draft">Черновик</option><option value="submitted">На проверке</option><option value="approved">Утверждён</option><option value="rejected">Возвращён</option></select></label><label class="grow">Примечание<input id="new-time-note" maxlength="1000" placeholder="Необязательно"></label><button type="submit">Добавить строку</button></form><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Часы</th><th>Статус</th><th>Примечание</th><th></th></tr></thead><tbody id="timesheet"></tbody></table></div></section>
</div>
<div id="employees-view" class="app-view manager-only" hidden>
<section class="view-head"><div><p class="eyebrow">Команда</p><h1>Сотрудники</h1><p>Кадровые данные, профессии, статусы и зарплатные профили.</p></div><div class="actions"><button id="manage-professions-directory" class="quiet" type="button">Профессии</button><button id="add-employee-directory" type="button">Добавить сотрудника</button></div></section>
<section class="stats" aria-label="Сводка сотрудников"><article><span>Всего сотрудников</span><strong id="directory-total">—</strong></article><article><span>Показано</span><strong id="directory-active">—</strong></article><article><span>Требуют внимания</span><strong id="directory-attention">—</strong></article></section>
<section class="panel"><div class="directory-toolbar"><input id="employee-search" type="search" placeholder="Поиск по имени, должности или табельному номеру" aria-label="Поиск сотрудников"><span id="employee-search-result" class="status">Все сотрудники</span></div><div id="employees-directory" class="people directory-list"></div></section>
</div>
<div id="finance-view" class="app-view" hidden>
<section class="view-head"><div><p class="eyebrow">Управленческий учёт</p><h1>Доходы и расходы</h1><p>Выручка по каналам, расходы по категориям и прибыль за выбранный месяц.</p></div><button id="finance-refresh" class="quiet" type="button">Обновить</button></section>
<section class="finance-stats stats" aria-label="Финансовая сводка"><article><span>Доход</span><strong id="finance-income">—</strong></article><article><span>Расход</span><strong id="finance-expense">—</strong></article><article><span>Прибыль</span><strong id="finance-profit">—</strong></article></section>
<section class="finance-layout"><article class="panel chart-panel"><div class="panel-head"><div><p class="eyebrow">Динамика по дням</p><h2>Выручка и расходы</h2></div><span>₽</span></div><div id="finance-chart" class="finance-chart" role="img" aria-label="График доходов и расходов"></div></article><aside class="panel manager-only"><div class="panel-head"><div><p class="eyebrow">Новая операция</p><h2>Внести сумму</h2></div></div><form id="finance-form" class="stack-form"><label>Дата<input id="finance-date" type="date" required></label><label>Торговая точка<select id="finance-unit" required></select></label><label>Категория<select id="finance-category" required></select></label><label>Сумма, ₽<input id="finance-amount" type="number" inputmode="decimal" min="0" step="0.01" required></label><button type="submit">Сохранить</button></form></aside></section>
<section class="panel"><div class="panel-head"><div><p class="eyebrow">Операции</p><h2>Данные за месяц</h2></div><span id="finance-count">0 записей</span></div><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Точка</th><th>Категория</th><th>Тип</th><th>Сумма</th></tr></thead><tbody id="finance-entries"></tbody></table></div></section>
</div>
<div id="schedule-view" class="app-view" hidden>
<section class="view-head"><div><p class="eyebrow">План и факт</p><h1>Календарь смен</h1><p>Плановые часы рядом с фактическими приходами и уходами со сканера.</p></div><button id="schedule-refresh" class="quiet" type="button">Обновить</button></section>
<section class="panel manager-only"><form id="schedule-form" class="inline-form"><label>Сотрудник<select id="schedule-employee" required></select></label><label>Точка<select id="schedule-unit" required></select></label><label>Дата<input id="schedule-date" type="date" required></label><label>Часы<input id="schedule-hours" type="number" inputmode="decimal" min="0" max="24" step="0.5" value="12" required></label><label>Начало<input id="schedule-start" type="time" value="10:00"></label><label>Конец<input id="schedule-end" type="time" value="22:00"></label><button type="submit">Запланировать</button></form></section>
<section class="panel calendar-panel"><div class="calendar-weekdays" aria-hidden="true"><span>Пн</span><span>Вт</span><span>Ср</span><span>Чт</span><span>Пт</span><span>Сб</span><span>Вс</span></div><div id="calendar-grid" class="calendar-grid"></div></section>
</div>
<div id="payroll-view" class="app-view" hidden>
<section class="view-head"><div><p class="eyebrow">Расчётный лист</p><h1>Зарплата</h1><p>Начисления, выплаты, остаток и фактически подтверждённые часы.</p></div><div class="actions"><button id="payroll-calc-btn" class="manager-only" type="button">Рассчитать зарплату</button><button id="payroll-refresh" class="quiet" type="button">Обновить</button></div></section>
<div id="payroll-calc-status" class="message" hidden></div>
<div id="payroll-warnings" class="payroll-warnings message" hidden></div>
<section id="payroll-preview-card" class="panel manager-only" hidden><div class="panel-head"><div><p class="eyebrow">Предварительный результат</p><h2 id="payroll-preview-title">Расчёт зарплаты</h2></div><button id="payroll-approve-btn" type="button">Утвердить расчёт</button></div><div class="table-wrap"><table><thead><tr><th>Сотрудник</th><th>Должность</th><th>Дней</th><th>Факт, часов</th><th>Начислено (расчёт)</th></tr></thead><tbody id="payroll-preview-rows"></tbody></table></div></section>
<section class="payroll-stats stats" aria-label="Сводка зарплаты"><article><span>Начислено</span><strong id="payroll-accrued">—</strong></article><article><span>Выплачено</span><strong id="payroll-paid">—</strong></article><article><span>Остаток</span><strong id="payroll-balance">—</strong></article></section>
<section class="panel"><div class="panel-head"><div><p class="eyebrow">Сотрудники</p><h2>Расчёт за месяц</h2></div><span>По подтверждённым сменам и начислениям</span></div><div class="table-wrap"><table><thead><tr><th>Сотрудник</th><th>Должность</th><th>Факт, часов</th><th>Начислено</th><th>Выплачено</th><th>Остаток</th></tr></thead><tbody id="payroll-rows"></tbody></table></div></section>
<section class="panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">История</p><h2>Начисления и выплаты</h2></div></div><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Операция</th><th>Сумма</th><th>Примечание</th></tr></thead><tbody id="payroll-entries"></tbody></table></div></section>
</div>
</main>
<input id="employee-position" type="hidden">
<dialog id="employee-dialog" class="employee-dialog"><form id="employee-form" method="dialog"><div class="dialog-head"><div><p class="eyebrow">Карточка команды</p><h2 id="employee-dialog-title">Новый сотрудник</h2></div><button id="employee-close" class="icon-button" type="button" aria-label="Закрыть">×</button></div><input id="employee-id" type="hidden"><label>Табельный номер<input id="employee-number" maxlength="80" required></label><label>Фамилия, имя и отчество<input id="employee-name" maxlength="240" required></label><label>Профессия<select id="employee-profession"><option value="">Не указана</option></select></label><p class="role-note">Роль доступа: сотрудник. Роли и права платформы управляются в Krona Core отдельно от кадровой профессии.</p><label>Пол<select id="employee-gender"><option value="">Не указан</option><option value="female">Женский</option><option value="male">Мужской</option></select></label><label id="employee-status-wrap" hidden>Статус<select id="employee-status"><option value="active">Работает</option><option value="blocked">Заблокирован</option><option value="dismissed">Уволен</option></select></label><div id="employee-pay-section" class="pay-profile-section manager-only"><p class="eyebrow">Зарплатный профиль</p><label>Торговая точка<select id="employee-pay-unit"></select></label><label>Схема расчёта<select id="employee-pay-rule"></select></label><div class="form-row-2"><label>Ставка в час, ₽<input id="employee-pay-rate" type="number" min="0" step="1" value="100"></label><label>Процент от выручки, %<input id="employee-pay-percent" type="number" min="0" max="100" step="0.1" value="0"></label></div><div class="form-row-2"><label>Гарантия за смену, ₽<input id="employee-pay-guarantee" type="number" min="0" step="100" value="0"></label><label>Доля в фонде, %<input id="employee-pay-pool-weight" type="number" min="0" max="200" step="10" value="100"></label></div><label>Действует с даты<input id="employee-pay-effective" type="date"></label><small>Новая версия действует с выбранной даты; предыдущая автоматически закрывается днём ранее.</small></div><div id="employee-error" class="login-error" role="alert" hidden></div><div class="dialog-actions"><button id="employee-cancel" class="quiet" type="button">Отмена</button><button id="employee-save" type="submit">Сохранить</button></div></form></dialog>
<dialog id="profession-dialog" class="employee-dialog"><form id="profession-form" method="dialog"><div class="dialog-head"><div><p class="eyebrow">Кадровый справочник</p><h2>Профессии</h2></div><button id="profession-close" class="icon-button" type="button" aria-label="Закрыть">×</button></div><label>Новая профессия<input id="profession-name" maxlength="160" required placeholder="Например, Повар"></label><button type="submit">Добавить профессию</button><div id="profession-list" class="people"></div><div id="profession-error" class="login-error" role="alert" hidden></div></form></dialog>
<dialog id="access-dialog" class="employee-dialog"><form method="dialog"><div class="dialog-head"><div><p class="eyebrow">Личный кабинет</p><h2>Ключ сотрудника</h2></div><button id="access-close" class="icon-button" type="submit" aria-label="Закрыть">×</button></div><p>Скопируйте ключ сейчас. В программе хранится только его защищённый отпечаток, повторно показать ключ нельзя.</p><label>Ключ доступа<input id="access-token" readonly></label><div class="dialog-actions"><button id="access-copy" type="button">Скопировать</button><button class="quiet" type="submit">Закрыть</button></div></form></dialog>
<footer>Krona Business Platform · Staff</footer>
</div>
<script src="/staff/app.js" defer></script>
</body>
</html>`;
void legacyPortalHtml;
export const portalHtml = portalWorkspaceHtml;
export const portalCss = `:root{--bg:#EFE3CC;--workspace:#F3EAD7;--surface:#F8F2E6;--raised:#FCF8EF;--text:#302A23;--muted:#756A5B;--border:#D9C9AC;--primary:#657255;--gold:#B78335;--danger:#A44E3F;--info:#5F7480;--shadow:0 14px 40px rgba(48,42,35,.09);font-family:Inter,"Segoe UI",system-ui,sans-serif;color:var(--text);background:var(--workspace);font-synthesis:none}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at 85% 0,rgba(183,131,53,.12),transparent 28rem),var(--workspace)}button,input,select{font:inherit}.topbar{height:72px;display:flex;align-items:center;justify-content:space-between;padding:0 max(20px,calc((100vw - 1240px)/2));background:rgba(248,242,230,.94);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:5;backdrop-filter:blur(14px)}.brand{display:flex;align-items:center;gap:12px;color:var(--text);text-decoration:none;font-size:19px;letter-spacing:.01em}.mark{width:40px;height:40px;border-radius:12px;display:grid;place-items:center;background:linear-gradient(145deg,var(--gold),#8a642f);color:white;font-family:Georgia,serif;font-size:22px;box-shadow:0 6px 16px rgba(154,116,61,.22)}.actions{display:flex;align-items:center;gap:10px}.token{width:180px;min-height:44px;border:1px solid var(--border);border-radius:10px;background:var(--raised);padding:0 12px;color:var(--text)}.status{font-size:13px;color:var(--muted);margin-right:4px}.status.ok{color:var(--primary)}button{min-height:44px;padding:0 16px;border:0;border-radius:10px;background:var(--primary);color:white;font-weight:650;cursor:pointer}button:hover{filter:brightness(.95)}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid rgba(95,116,128,.35);outline-offset:2px}.quiet{background:transparent;color:var(--primary);border:1px solid var(--border)}.compact{min-height:38px;padding:0 12px}main{max-width:1240px;margin:auto;padding:36px 20px 56px}.hero{display:grid;grid-template-columns:1fr auto;gap:28px;align-items:end;padding:34px 38px;border-radius:16px;background:linear-gradient(120deg,#3b4536,#657255);color:#fff;box-shadow:var(--shadow)}.hero h1{margin:4px 0 10px;font:500 clamp(30px,4vw,52px)/1.04 Georgia,serif;max-width:720px}.hero p{margin:0;color:rgba(255,255,255,.75)}.eyebrow{text-transform:uppercase;letter-spacing:.13em;font-size:11px!important;font-weight:750;color:var(--gold)!important}.today{min-width:185px;padding:18px 20px;background:rgba(255,255,255,.09);border:1px solid rgba(255,255,255,.18);border-radius:12px}.today span,.today small{display:block;color:rgba(255,255,255,.7)}.today strong{display:block;font:500 25px/1.3 Georgia,serif;margin:3px 0}.message{margin-top:18px;padding:14px 16px;background:#fff1ee;border:1px solid #dfb2aa;border-radius:10px;color:#78382d}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:24px 0}.stats article,.panel{background:var(--surface);border:1px solid var(--border);border-radius:12px;box-shadow:0 5px 18px rgba(48,42,35,.045)}.stats article{padding:20px 22px}.stats span{display:block;color:var(--muted);font-size:13px}.stats strong{font:500 30px/1.2 Georgia,serif}.layout{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(270px,.75fr);gap:18px}.panel{padding:22px}.panel-head{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;margin-bottom:18px}.panel h2{font:500 25px/1.2 Georgia,serif;margin:2px 0}.panel-head span{font-size:12px;color:var(--muted)}.live{color:var(--primary)!important}.feed,.people{display:grid;gap:8px}.event,.person{display:grid;align-items:center;gap:12px;padding:13px 14px;border-radius:10px;background:var(--raised)}.event{grid-template-columns:42px 1fr auto}.person{grid-template-columns:38px 1fr auto}.avatar,.event-icon{width:38px;height:38px;border-radius:50%;display:grid;place-items:center;background:#e7dcc4;color:var(--gold);font-weight:750}.event-icon{border-radius:10px}.event.out .event-icon{background:#e4e7df;color:var(--primary)}.event strong,.person strong{display:block}.event small,.person small{color:var(--muted)}.event time{font-variant-numeric:tabular-nums;color:var(--muted);font-size:13px}.empty{padding:24px;text-align:center;color:var(--muted);background:var(--raised);border-radius:10px}.timesheet-panel{margin-top:18px}.timesheet-create{display:flex;align-items:end;gap:10px;padding:14px;margin-bottom:12px;background:var(--raised);border:1px solid var(--border);border-radius:10px}.timesheet-create label,.employee-dialog label{display:grid;gap:6px;color:var(--muted);font-size:12px;font-weight:700}.timesheet-create input,.timesheet-create select,.employee-dialog input,.employee-dialog select{min-height:42px;border:1px solid var(--border);border-radius:8px;background:#fffdf8;color:var(--text);padding:7px 9px}.timesheet-create .grow{flex:1}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;min-width:760px}th,td{text-align:left;padding:12px 10px;border-bottom:1px solid var(--border);font-size:14px}th{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.06em}td input,td select{width:100%;min-height:40px;border:1px solid var(--border);border-radius:8px;background:var(--raised);color:var(--text);padding:7px 9px}td button{min-height:40px}.tag{display:inline-flex;padding:5px 9px;border-radius:99px;background:#e5e8df;color:var(--primary);font-size:12px}.employee-dialog{width:min(92vw,560px);padding:0;border:1px solid var(--border);border-radius:16px;background:var(--surface);color:var(--text);box-shadow:0 28px 90px rgba(48,42,35,.25)}.employee-dialog::backdrop{background:rgba(34,31,27,.48);backdrop-filter:blur(3px)}.employee-dialog form{display:grid;gap:14px;padding:26px}.dialog-head{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:4px}.dialog-head h2{margin:2px 0;font:500 28px/1.15 Georgia,serif}.icon-button{min-width:42px;padding:0;background:transparent;border:1px solid var(--border);color:var(--text);font-size:24px}.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:8px}footer{text-align:center;padding:24px;color:var(--muted);font-size:12px}@media(max-width:800px){.topbar{height:auto;min-height:68px;padding:12px 16px;flex-wrap:wrap}.actions{flex-wrap:wrap;justify-content:flex-end}.status,#notify{display:none}.token{width:150px}.hero{grid-template-columns:1fr;padding:26px 22px}.today{min-width:0}.stats{grid-template-columns:1fr 1fr}.stats article:last-child{grid-column:1/-1}.layout{grid-template-columns:1fr}.people-panel{order:-1}main{padding:22px 14px 42px}.panel{padding:18px}.panel-head{align-items:center}.live{display:none}.timesheet-create{display:grid;grid-template-columns:1fr 1fr}.timesheet-create .grow,.timesheet-create button{grid-column:1/-1}}@media(prefers-reduced-motion:no-preference){.panel,.stats article{transition:transform .18s ease,box-shadow .18s ease}.panel:hover,.stats article:hover{transform:translateY(-1px);box-shadow:var(--shadow)}}`;
export const portalJs = `(() => {
const api = '/api/v1/staff'; let latestId = null; let viewerRole = 'employee'; let employeesCache = [];
const el = id => document.getElementById(id); const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const localDate = value => new Intl.DateTimeFormat('ru-RU',{day:'2-digit',month:'short',year:'numeric'}).format(new Date(value));
const localTime = value => new Intl.DateTimeFormat('ru-RU',{hour:'2-digit',minute:'2-digit'}).format(new Date(value));
el('today').textContent = new Intl.DateTimeFormat('ru-RU',{weekday:'long',day:'numeric',month:'long'}).format(new Date());
const jsonHeaders = {'content-type':'application/json'};
function showLogin(message){el('app-shell').hidden=true;el('auth-screen').hidden=false;el('login-error').textContent=message||'';el('login-error').hidden=!message;setTimeout(()=>el('login-token').focus(),0);}
function showApp(){el('auth-screen').hidden=true;el('app-shell').hidden=false;}
async function load(notify){ try { const response=await fetch(api+'/portal',{credentials:'same-origin',cache:'no-store'}); if(response.status===401){showLogin('Введите ключ администратора.');return;} if(!response.ok) throw new Error('Сервер вернул '+response.status); const data=await response.json(); showApp(); render(data,notify); el('connection').textContent='Синхронизировано'; el('connection').className='status ok'; el('message').hidden=true; } catch(error){if(el('app-shell').hidden){showLogin('Сервер сейчас недоступен. Повторите попытку позже.');return;}el('connection').textContent='Нет связи';el('connection').className='status';el('message').textContent='Не удалось загрузить данные. Проверьте подключение.';el('message').hidden=false;} }
function render(data,notify){ viewerRole=data.viewer.role; employeesCache=data.employees; el('viewer').textContent=viewerRole==='manager'?'Кабинет руководителя':'Кабинет сотрудника'; el('greeting').textContent=viewerRole==='manager'?'Команда и рабочее время':'Ваш рабочий день'; el('last-refresh').textContent='Обновлено '+localTime(data.refreshed_at); el('employees-count').textContent=data.employees.length; const today=new Date().toDateString(); const todayEvents=data.attendance.filter(x=>new Date(x.occurred_at).toDateString()===today); el('today-count').textContent=todayEvents.length; el('hours-count').textContent=(data.timesheet.reduce((sum,x)=>sum+Number(x.minutes_worked),0)/60).toLocaleString('ru-RU',{maximumFractionDigits:1});
const first=data.attendance[0]; if(notify&&first&&latestId&&first.id!==latestId&&Notification.permission==='granted') new Notification('Krona Staff',{body:first.display_name+': '+(first.event_type==='check_in'?'приход':'уход')+' в '+localTime(first.occurred_at),tag:first.id}); if(first) latestId=first.id;
el('attendance').innerHTML=data.attendance.length?data.attendance.slice(0,20).map(x=>'<div class="event '+(x.event_type==='check_out'?'out':'')+'" data-event-type="'+esc(x.event_type)+'"><span class="event-icon">'+(x.event_type==='check_in'?'↘':'↗')+'</span><div><strong>'+esc(x.display_name)+'</strong><small>'+(x.event_type==='check_in'?'Приход':'Уход')+' · '+esc(x.source)+'</small></div><time>'+localDate(x.occurred_at)+' · '+localTime(x.occurred_at)+'</time></div>').join(''):'<div class="empty">Отметок пока нет</div>';
el('employees').innerHTML=data.employees.length?data.employees.map(x=>'<div class="person" data-id="'+esc(x.id)+'" data-status="'+esc(x.status||'active')+'"><span class="avatar">'+esc(x.display_name).slice(0,1).toUpperCase()+'</span><div><strong>'+esc(x.display_name)+'</strong><small>'+esc(x.position||'Должность не указана')+' · № '+esc(x.personnel_number)+'</small></div><span class="tag">'+esc(x.status==='dismissed'?'Уволен':x.status==='blocked'?'Заблокирован':'Работает')+'</span>'+(viewerRole==='manager'?'<button class="quiet compact edit-employee" type="button" data-id="'+esc(x.id)+'">Изменить</button>':'')+'</div>').join(''):'<div class="empty">Сотрудники не найдены</div>';
el('add-employee').hidden=viewerRole!=='manager'; el('timesheet-create').hidden=viewerRole!=='manager'; el('new-time-employee').innerHTML=data.employees.map(x=>'<option value="'+esc(x.id)+'">'+esc(x.display_name)+'</option>').join('');
renderWorkSessions(data.work_sessions||[]); el('reconcile-sessions').hidden=viewerRole!=='manager'; el('timesheet-help').textContent=viewerRole==='manager'?'Можно добавлять, редактировать и утверждать':'Только просмотр'; el('timesheet').innerHTML=data.timesheet.length?data.timesheet.map(row=>timesheetRow(row)).join(''):'<tr><td colspan="6" class="empty">Табель пока пуст — добавьте первую строку выше</td></tr>'; bindSaves(); bindEmployeeEdits(); }
function timesheetRow(row){const editable=viewerRole==='manager'; const statuses={draft:'Черновик',submitted:'На проверке',approved:'Утверждён',rejected:'Возвращён'}; return '<tr data-employee="'+esc(row.employee_id)+'" data-date="'+esc(String(row.work_date).slice(0,10))+'"><td>'+localDate(row.work_date)+'</td><td>'+esc(row.display_name)+'</td><td>'+(editable?'<input class="hours" type="number" min="0" max="24" step="0.25" value="'+(Number(row.minutes_worked)/60)+'" aria-label="Часы">':(Number(row.minutes_worked)/60).toLocaleString('ru-RU'))+'</td><td>'+(editable?'<select class="state">'+Object.entries(statuses).map(([k,v])=>'<option value="'+k+'" '+(k===row.status?'selected':'')+'>'+v+'</option>').join('')+'</select>':'<span class="tag">'+esc(statuses[row.status]||row.status)+'</span>')+'</td><td>'+(editable?'<input class="note" maxlength="1000" value="'+esc(row.note||'')+'" aria-label="Примечание">':esc(row.note||'—'))+'</td><td>'+(editable?'<button class="save" type="button">Сохранить</button>':'')+'</td></tr>';}
function renderWorkSessions(rows){const states={open:'Открыта',pending_review:'Нужна проверка',confirmed:'Подтверждена',rejected:'Отклонена'};const anomaly={auto_closed:'Автозакрытие',missing_check_in:'Нет прихода',missing_check_out:'Нет ухода',repeated_check_in:'Повторный приход'};el('work-sessions').innerHTML=rows.length?rows.map(row=>{const review=viewerRole==='manager'&&row.status!=='confirmed';const issue=String(row.anomaly||'').split(',').filter(Boolean).map(x=>anomaly[x]||x).join(', ');const interval=(row.started_at?localTime(row.started_at):'—')+'–'+(row.ended_at?localTime(row.ended_at):'…');return '<tr data-session="'+esc(row.id)+'"><td>'+localDate(row.business_date)+'</td><td>'+esc(row.display_name)+'</td><td>'+esc(row.org_unit_name||'—')+'</td><td>'+interval+'</td><td>'+(review?'<input class="session-hours" type="number" min="0" max="48" step="0.25" value="'+(Number(row.worked_minutes)/60)+'" aria-label="Часы смены">':(Number(row.worked_minutes)/60).toLocaleString('ru-RU'))+'</td><td><span class="tag">'+esc(states[row.status]||row.status)+'</span>'+(issue?'<small class="anomaly">'+esc(issue)+'</small>':'')+'</td><td>'+(review?'<input class="session-note" maxlength="1000" value="'+esc(row.correction_note||'')+'" placeholder="Причина при изменении">':esc(row.correction_note||'—'))+'</td><td>'+(review?'<div class="session-actions"><button class="confirm-session" type="button">Подтвердить</button><button class="reject-session quiet" type="button">Отклонить</button></div>':'')+'</td></tr>';}).join(''):'<tr><td colspan="8" class="empty">Смен пока нет — выполните сверку отметок</td></tr>';bindSessionReviews();}
function bindSessionReviews(){document.querySelectorAll('.confirm-session,.reject-session').forEach(button=>button.addEventListener('click',async()=>{const row=button.closest('tr');const confirmed=button.classList.contains('confirm-session');button.disabled=true;try{const body={status:confirmed?'confirmed':'rejected',worked_minutes:Math.round(Number(row.querySelector('.session-hours').value)*60),correction_note:row.querySelector('.session-note').value};const response=await fetch(api+'/work-sessions/'+row.dataset.session,{method:'PATCH',credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify(body)});if(!response.ok){const problem=await response.json().catch(()=>({}));throw new Error(problem.message||'Не удалось проверить смену.');}await load(false);}catch(error){el('message').textContent=error.message||'Не удалось проверить смену.';el('message').hidden=false;}finally{button.disabled=false;}}));}
function bindSaves(){document.querySelectorAll('.save').forEach(button=>button.addEventListener('click',async()=>{const row=button.closest('tr'); button.disabled=true; try{const body={minutes_worked:Math.round(Number(row.querySelector('.hours').value)*60),status:row.querySelector('.state').value,note:row.querySelector('.note').value};const response=await fetch(api+'/timesheets/'+row.dataset.employee+'/'+row.dataset.date,{method:'PUT',credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify(body)});if(response.status===401){showLogin('Сессия завершена. Войдите снова.');return;}if(!response.ok)throw new Error();button.textContent='Сохранено';setTimeout(()=>button.textContent='Сохранить',1400);}catch{button.textContent='Ошибка';}finally{button.disabled=false;}}));}
function bindEmployeeEdits(){document.querySelectorAll('.edit-employee').forEach(button=>button.addEventListener('click',()=>openEmployee(employeesCache.find(x=>x.id===button.dataset.id))));}
function openEmployee(employee){const editing=Boolean(employee);el('employee-dialog-title').textContent=editing?'Редактирование сотрудника':'Новый сотрудник';el('employee-id').value=employee?.id||'';el('employee-number').value=employee?.personnel_number||'';el('employee-name').value=employee?.display_name||'';el('employee-position').value=employee?.position||'';el('employee-gender').value=employee?.gender||'';el('employee-status').value=employee?.status||'active';el('employee-status-wrap').hidden=!editing;el('employee-error').hidden=true;el('employee-dialog').showModal();setTimeout(()=>el('employee-number').focus(),0);}
el('reconcile-sessions').addEventListener('click',async()=>{const button=el('reconcile-sessions');const end=new Date();const start=new Date(end);start.setUTCDate(start.getUTCDate()-6);button.disabled=true;try{const response=await fetch(api+'/work-sessions/reconcile',{method:'POST',credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify({date_from:start.toISOString().slice(0,10),date_to:end.toISOString().slice(0,10)})});const result=await response.json().catch(()=>({}));if(!response.ok)throw new Error(result.message||'Не удалось сверить отметки.');el('message').textContent='Сверка завершена: смен '+result.sessions_created_or_updated+', аномалий '+result.anomalous_sessions+', без подразделения '+result.unresolved_events+'.';el('message').hidden=false;await load(false);}catch(error){el('message').textContent=error.message||'Не удалось сверить отметки.';el('message').hidden=false;}finally{button.disabled=false;}});
el('new-time-date').value=new Date().toISOString().slice(0,10);
el('timesheet-create').addEventListener('submit',async event=>{event.preventDefault();const button=event.submitter;button.disabled=true;try{const employeeId=el('new-time-employee').value;const workDate=el('new-time-date').value;const body={minutes_worked:Math.round(Number(el('new-time-hours').value)*60),status:el('new-time-status').value,note:el('new-time-note').value};const response=await fetch(api+'/timesheets/'+employeeId+'/'+workDate,{method:'PUT',credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify(body)});if(response.status===401){showLogin('Сессия завершена. Войдите снова.');return;}if(!response.ok)throw new Error();el('new-time-note').value='';await load(false);}catch{el('message').textContent='Не удалось добавить строку табеля.';el('message').hidden=false;}finally{button.disabled=false;}});
el('add-employee').addEventListener('click',()=>openEmployee());
el('employee-close').addEventListener('click',()=>el('employee-dialog').close());
el('employee-cancel').addEventListener('click',()=>el('employee-dialog').close());
el('employee-form').addEventListener('submit',async event=>{event.preventDefault();const employeeId=el('employee-id').value;const button=el('employee-save');const body={personnel_number:el('employee-number').value.trim(),display_name:el('employee-name').value.trim(),position:el('employee-position').value.trim(),gender:el('employee-gender').value};if(employeeId)body.status=el('employee-status').value;button.disabled=true;el('employee-error').hidden=true;try{const response=await fetch(api+'/employees'+(employeeId?'/'+employeeId:''),{method:employeeId?'PATCH':'POST',credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify(body)});if(response.status===401){el('employee-dialog').close();showLogin('Сессия завершена. Войдите снова.');return;}if(!response.ok){const problem=await response.json().catch(()=>({}));throw new Error(problem.message||'Не удалось сохранить сотрудника.');}el('employee-dialog').close();await load(false);}catch(error){el('employee-error').textContent=error.message||'Не удалось сохранить сотрудника.';el('employee-error').hidden=false;}finally{button.disabled=false;}});
el('login-form').addEventListener('submit',async event=>{event.preventDefault();const button=el('login-submit');const token=el('login-token').value.trim();button.disabled=true;el('login-error').hidden=true;try{const response=await fetch(api+'/session',{method:'POST',credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify({access_token:token})});if(!response.ok){showLogin(response.status===401||response.status===403?'Неверный ключ администратора.':'Не удалось выполнить вход.');return;}el('login-token').value='';await load(false);}catch{showLogin('Сервер сейчас недоступен.');}finally{button.disabled=false;}});
el('logout').addEventListener('click',async()=>{try{await fetch(api+'/session',{method:'DELETE',credentials:'same-origin'});}finally{latestId=null;showLogin('Вы вышли из кабинета.');}});
el('refresh').addEventListener('click',()=>load(false)); el('notify').addEventListener('click',async()=>{if(!('Notification' in window)){el('message').textContent='Этот браузер не поддерживает системные уведомления.';el('message').hidden=false;return;} const permission=await Notification.requestPermission(); el('notify').textContent=permission==='granted'?'Уведомления включены':'Уведомления запрещены';});
if('serviceWorker' in navigator) navigator.serviceWorker.register('/staff/sw.js'); load(false); setInterval(()=>{if(!el('app-shell').hidden)load(true);},15000);
})();`;
const legacyPortalCssEnhanced =
portalCss +
`
[data-omni-theme="parchment-light"]{--omni-bg-app:#EFE3CC;--omni-bg-workspace:#F3EAD7;--omni-surface:#F8F2E6;--omni-surface-raised:#FCF8EF;--omni-surface-muted:#E9DDC5;--omni-surface-hover:#EDE0C7;--omni-text-primary:#302A23;--omni-text-secondary:#756A5B;--omni-text-inverse:#FFF;--omni-border:#D9C9AC;--omni-border-strong:#CBB99B;--omni-border-focus:#9A743D;--omni-primary:#9A743D;--omni-primary-hover:#7D5B2D;--omni-primary-soft:#EADBC1;--omni-gold:#B78335;--omni-gold-soft:#EFE0C2;--omni-danger:#A44E3F;--omni-danger-soft:#F1DDD5;--omni-info:#5F7480;--omni-shadow-medium:0 6px 18px rgb(72 55 34/.10);--omni-shadow-strong:0 14px 36px rgb(52 39 24/.16)}
[data-omni-theme="noble-green"]{--omni-bg-app:#EDE5D3;--omni-bg-workspace:#F2EAD9;--omni-surface:#F8F2E6;--omni-surface-raised:#FCF8EF;--omni-surface-muted:#E6DBC5;--omni-surface-hover:#E3E9DE;--omni-text-primary:#2E302A;--omni-text-secondary:#6B6C60;--omni-text-inverse:#FFF;--omni-border:#D2CCB9;--omni-border-strong:#9DAA98;--omni-border-focus:#315F46;--omni-primary:#315F46;--omni-primary-hover:#294F3B;--omni-primary-soft:#DDE7DB;--omni-gold:#B78335;--omni-gold-soft:#EFE0C2;--omni-danger:#A44E3F;--omni-danger-soft:#F1DDD5;--omni-info:#527286;--omni-shadow-medium:0 6px 18px rgb(72 55 34/.10);--omni-shadow-strong:0 14px 36px rgb(52 39 24/.16)}
[data-omni-theme="graphite-forest-dark"]{--omni-bg-app:#171A18;--omni-bg-workspace:#1D211E;--omni-surface:#252A26;--omni-surface-raised:#2B312C;--omni-surface-muted:#212621;--omni-surface-hover:#2B342D;--omni-text-primary:#EEE7DA;--omni-text-secondary:#B7B3A8;--omni-text-inverse:#171A18;--omni-border:#39423A;--omni-border-strong:#465249;--omni-border-focus:#6F9A79;--omni-primary:#527D60;--omni-primary-hover:#648E70;--omni-primary-soft:#29382E;--omni-gold:#C99A4D;--omni-gold-soft:#403522;--omni-danger:#C06B5F;--omni-danger-soft:#432B28;--omni-info:#7693A0;--omni-shadow-medium:0 8px 22px rgb(0 0 0/.24);--omni-shadow-strong:0 16px 40px rgb(0 0 0/.34)}
:root{--omni-radius-sm:6px;--omni-radius-md:8px;--omni-radius-lg:12px;--omni-radius-xl:16px;--omni-font-sans:Inter,"Segoe UI",system-ui,sans-serif;--omni-font-serif:Georgia,serif;--omni-warning:#7A5B16;--omni-warning-soft:#F7EAC9;--bg:var(--omni-bg-app);--workspace:var(--omni-bg-workspace);--surface:var(--omni-surface);--raised:var(--omni-surface-raised);--text:var(--omni-text-primary);--muted:var(--omni-text-secondary);--border:var(--omni-border);--primary:var(--omni-primary);--gold:var(--omni-gold);--danger:var(--omni-danger);--info:var(--omni-info);--shadow:var(--omni-shadow-medium)}
body,.auth-screen{background:var(--omni-bg-workspace);color:var(--omni-text-primary)}.topbar,.section-nav{background:color-mix(in srgb,var(--omni-surface) 94%,transparent)}.hero{background:linear-gradient(120deg,var(--omni-primary-active,var(--omni-primary)),var(--omni-primary))}.stats article,.panel,.employee-dialog{background:var(--omni-surface);border-color:var(--omni-border);box-shadow:var(--omni-shadow-medium)}button{background:var(--omni-primary);color:var(--omni-text-inverse)}button:hover{background:var(--omni-primary-hover);filter:none}.quiet{background:transparent;color:var(--omni-primary);border-color:var(--omni-border)}input,select,.timesheet-create input,.timesheet-create select,.employee-dialog input,.employee-dialog select,.period-control input,.stack-form input,.stack-form select,.inline-form input,.inline-form select{background:var(--omni-surface-raised);color:var(--omni-text-primary);border-color:var(--omni-border)}button:focus-visible,input:focus-visible,select:focus-visible{outline-color:var(--omni-border-focus)}.theme-control{display:flex;align-items:center;gap:6px;color:var(--omni-text-secondary);font-size:12px}.theme-control select{min-height:36px;border:1px solid var(--omni-border);border-radius:var(--omni-radius-md);padding:0 8px}.role-note{margin:0;padding:10px 12px;border-radius:var(--omni-radius-md);background:var(--omni-primary-soft);color:var(--omni-text-secondary);font-size:12px;line-height:1.45}.payroll-warnings{background:var(--omni-warning-soft)!important;color:var(--omni-warning)!important}.message,.login-error{background:var(--omni-danger-soft);color:var(--omni-danger);border-color:var(--omni-danger)}
.section-nav{position:sticky;top:72px;z-index:4;display:flex;align-items:center;gap:8px;padding:10px max(20px,calc((100vw - 1240px)/2));border-bottom:1px solid var(--border);background:rgba(243,234,215,.96);backdrop-filter:blur(12px);overflow-x:auto}.nav-item{flex:0 0 auto;background:transparent;color:var(--muted);border:1px solid transparent}.nav-item.selected{background:var(--surface);color:var(--primary);border-color:var(--border)}.period-control{margin-left:auto;display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px;font-weight:700;white-space:nowrap}.period-control input,.stack-form input,.stack-form select,.inline-form input,.inline-form select{min-height:42px;border:1px solid var(--border);border-radius:8px;background:#fffdf8;color:var(--text);padding:7px 9px}.view-head{display:flex;justify-content:space-between;align-items:end;gap:24px;margin-bottom:22px}.view-head h1{margin:4px 0 8px;font:500 clamp(30px,4vw,46px)/1.05 Georgia,serif}.view-head p{margin:0;color:var(--muted)}.finance-layout{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(260px,.7fr);gap:18px;margin-bottom:18px}.stack-form{display:grid;gap:13px}.stack-form label,.inline-form label{display:grid;gap:6px;color:var(--muted);font-size:12px;font-weight:700}.inline-form{display:grid;grid-template-columns:repeat(6,minmax(120px,1fr)) auto;align-items:end;gap:10px}.finance-chart{min-height:280px}.finance-chart svg{display:block;width:100%;height:280px}.chart-legend{display:flex;gap:16px;margin-top:10px;color:var(--muted);font-size:12px}.legend-dot{display:inline-block;width:9px;height:9px;margin-right:5px;border-radius:50%}.income-dot{background:var(--primary)}.expense-dot{background:var(--gold)}.calendar-panel{overflow:auto}.calendar-weekdays,.calendar-grid{display:grid;grid-template-columns:repeat(7,minmax(145px,1fr));min-width:1015px}.calendar-weekdays span{padding:8px 10px;color:var(--muted);font-size:12px;font-weight:750;text-align:center}.calendar-day{min-height:142px;padding:10px;border-top:1px solid var(--border);border-right:1px solid var(--border);background:var(--raised)}.calendar-day.outside{opacity:.45}.calendar-day.today{outline:2px solid var(--gold);outline-offset:-2px}.day-number{display:block;margin-bottom:8px;font-weight:750}.shift-card{margin:5px 0;padding:7px;border-left:3px solid var(--primary);border-radius:6px;background:#e8eadf;font-size:11px;line-height:1.35}.shift-card.actual{border-left-color:var(--gold);background:#f3e5cd}.shift-card strong,.shift-card span{display:block}.positive{color:var(--primary)}.negative{color:var(--danger)}.person-access{min-height:36px;padding:0 10px;margin-left:5px}.app-view[hidden]{display:none!important}@media(max-width:900px){.section-nav{top:68px;padding-inline:14px}.period-control{margin-left:8px}.finance-layout{grid-template-columns:1fr}.inline-form{grid-template-columns:repeat(2,minmax(0,1fr))}.inline-form button{grid-column:1/-1}.view-head{align-items:flex-start}.calendar-panel{padding:12px}}@media(max-width:520px){.view-head{display:block}.view-head>button{margin-top:14px}.stats{grid-template-columns:1fr}.stats article:last-child{grid-column:auto}.inline-form{grid-template-columns:1fr}.period-control{order:-1;margin-left:0}.section-nav{top:92px}.finance-chart{min-height:220px}.finance-chart svg{height:220px}}
.form-row-2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.pay-profile-section{display:grid;gap:10px;padding:14px;background:var(--raised);border:1px solid var(--border);border-radius:10px;margin-top:6px}
.payroll-warnings{background:#fff8e6!important;border:1px solid #e2cb91!important;color:#6b5314!important;margin-bottom:18px}
.payroll-warnings ul{margin:6px 0 0 18px;padding:0}
.payroll-warnings li{margin-bottom:4px}
.section-nav{background:color-mix(in srgb,var(--omni-surface) 94%,transparent)}.period-control input,.stack-form input,.stack-form select,.inline-form input,.inline-form select{background:var(--omni-surface-raised)}.shift-card{background:var(--omni-primary-soft)}.shift-card.actual{background:var(--omni-gold-soft)}.payroll-warnings{background:var(--omni-warning-soft)!important;border-color:color-mix(in srgb,var(--omni-warning) 35%,transparent)!important;color:var(--omni-warning)!important}[data-omni-theme="graphite-forest-dark"]{--omni-warning:#E0B96C;--omni-warning-soft:#403522}
@media(max-width:680px){
body{padding-bottom:76px}
.section-nav{position:fixed;bottom:0;top:auto;left:0;right:0;z-index:90;display:flex;justify-content:space-around;padding:8px 8px calc(8px + env(safe-area-inset-bottom,0px));background:var(--omni-surface);border-top:1px solid var(--omni-border);border-bottom:none;box-shadow:0 -4px 18px rgba(0,0,0,.08);gap:4px}
.section-nav .nav-item{flex:1 1 auto;text-align:center;min-height:42px;padding:6px 4px;font-size:13px;border-radius:8px}
.period-control{display:none}
.status.offline{color:var(--omni-warning,#B78335);font-weight:700;font-size:12px}
.people-panel{order:unset}
.status,#notify{display:inline-flex}
}
#notify.active{background:var(--omni-primary-soft,#DDE7DB);color:var(--omni-primary,#315F46)}
.status.offline{color:var(--omni-warning,#B78335);font-weight:600}
`;
void legacyPortalCssEnhanced;
export const portalCssEnhanced = portalDesignCss;
export const portalJsEnhanced =
portalJs.replace('Неверный ключ администратора.', 'Неверный ключ доступа.') +
`
(() => {
const api='/api/v1/staff',byId=id=>document.getElementById(id),esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])),rub=value=>(Number(value||0)/100).toLocaleString('ru-RU',{style:'currency',currency:'RUB',maximumFractionDigits:2}),hours=value=>(Number(value||0)/60).toLocaleString('ru-RU',{maximumFractionDigits:1}),jsonHeaders={'content-type':'application/json'};
let role='manager',units=[],initialized=false,active='overview-view',payRulesCache=[],currentRunId=null,staffState=null,selectedEmployeeId=null;
const period=()=>{const month=byId('period-month').value||new Date().toISOString().slice(0,7),start=month+'-01',d=new Date(start+'T00:00:00Z');d.setUTCMonth(d.getUTCMonth()+1);d.setUTCDate(0);return{from:start,to:d.toISOString().slice(0,10)}},query=()=>{const p=period();return'?from='+p.from+'&to='+p.to};
async function get(path){const response=await fetch(api+path,{credentials:'same-origin'});if(!response.ok)throw new Error((await response.json().catch(()=>({}))).message||'Не удалось загрузить данные.');return response.json()}
async function send(path,method,body){const response=await fetch(api+path,{method,credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify(body)});if(!response.ok)throw new Error((await response.json().catch(()=>({}))).message||'Не удалось сохранить данные.');return response.json()}
const option=(value,label)=>'<option value="'+esc(value)+'">'+esc(label)+'</option>';
function fillSelects(){
const unitHtml=units.map(x=>option(x.id,x.name)).join('');
['finance-unit','schedule-unit'].forEach(id=>{if(byId(id))byId(id).innerHTML=unitHtml});
if(byId('employee-pay-unit')) byId('employee-pay-unit').innerHTML='<option value="">Все точки</option>'+unitHtml;
if(byId('employee-pay-rule')&&payRulesCache.length) byId('employee-pay-rule').innerHTML=payRulesCache.map(r=>option(r.id,r.name)).join('');
const source=byId('new-time-employee');if(source&&byId('schedule-employee'))byId('schedule-employee').innerHTML=source.innerHTML;
}
async function initialize(){
if(byId('app-shell').hidden||initialized)return;
initialized=true;
try{
const state=await get('/portal');staffState=state;
role=state.viewer&&state.viewer.role||'manager';
if(role!=='manager'&&['employees-view','finance-view','rules-view','terminal-view'].includes(active))show('overview-view',false);
document.querySelectorAll('.manager-only').forEach(node=>node.hidden=role!=='manager');
byId('manage-professions').hidden=role!=='manager';
byId('viewer').textContent=role==='employee'?'Личный кабинет сотрудника':'Кабинет руководителя';
units=(await get('/org-units')).org_units||[];
try{payRulesCache=(await get('/pay-rules')).pay_rules||[]}catch{}
fillSelects();
syncWorkspaceMirrors();
renderRuleWorkspace();
await loadActive();
}catch{initialized=false}
}
const routeToView={'/staff':'overview-view','/staff/':'overview-view','/staff/dashboard':'overview-view','/staff/employees':'employees-view','/staff/attendance':'attendance-view','/staff/shifts':'shifts-view','/staff/schedules':'schedule-view','/staff/finance':'finance-view','/staff/payroll':'payroll-view','/staff/rules':'rules-view','/staff/terminal':'terminal-view','/staff/notifications':'notifications-view','/staff/reports':'reports-view','/staff/settings':'settings-view'};
function show(target,updateHistory=true){
if(role!=='manager'&&['employees-view','finance-view','rules-view','terminal-view'].includes(target))target='overview-view';
active=target;
document.querySelectorAll('.app-view').forEach(view=>view.hidden=view.id!==target);
document.querySelectorAll('.nav-item').forEach(button=>button.classList.toggle('selected',button.dataset.target===target));
const button=document.querySelector('.nav-item[data-target="'+target+'"]');
if(updateHistory&&button?.dataset.route&&location.pathname!==button.dataset.route)history.pushState({view:target},'',button.dataset.route);
loadActive();
}
async function loadActive(){try{if(active==='overview-view'&&role==='manager')await Promise.all([loadFinance(),loadPayroll()]);if(active==='finance-view')await loadFinance();if(active==='schedule-view')await loadSchedule();if(active==='payroll-view')await loadPayroll();if(active==='rules-view'){await loadProfessions();renderRuleWorkspace()}}catch(error){const box=byId('message');if(box){box.textContent=error.message;box.hidden=false}}}
async function loadFinance(){if(role!=='manager')return;const data=await get('/finance'+query()),income=data.daily.reduce((sum,x)=>sum+Number(x.income_minor),0),expense=data.daily.reduce((sum,x)=>sum+Number(x.expense_minor),0);byId('finance-income').textContent=rub(income);byId('finance-expense').textContent=rub(expense);byId('finance-profit').textContent=rub(income-expense);byId('dashboard-profit').textContent=rub(income-expense);byId('finance-count').textContent=data.entries.length+' записей';byId('finance-category').innerHTML=data.categories.map(x=>option(x.key,(x.kind==='income'?'Доход · ':'Расход · ')+x.label)).join('');byId('finance-entries').innerHTML=data.entries.length?data.entries.map(x=>'<tr><td>'+esc(x.business_date)+'</td><td>'+esc(x.org_unit_name)+'</td><td>'+esc(x.label)+'</td><td><span class="tag">'+(x.metric_key.startsWith('income_')?'Доход':'Расход')+'</span></td><td>'+rub(x.value_minor)+'</td></tr>').join(''):'<tr><td colspan="5" class="empty">За месяц операций нет</td></tr>';drawChart(data.daily)}
function drawChart(rows){const sums={};rows.forEach(x=>{const key=String(x.business_date).slice(0,10);sums[key]||={income:0,expense:0};sums[key].income+=Number(x.income_minor);sums[key].expense+=Number(x.expense_minor)});const points=Object.entries(sums).sort((a,b)=>a[0].localeCompare(b[0]));let html;if(!points.length)html='<div class="empty">Добавьте выручку или расходы — здесь появится график</div>';else{const width=900,height=250,pad=28,max=Math.max(1,...points.flatMap(x=>[x[1].income,x[1].expense])),x=i=>pad+(points.length===1?0:i*(width-pad*2)/(points.length-1)),y=v=>height-pad-v*(height-pad*2)/max,path=kind=>points.map((p,i)=>(i?'L':'M')+x(i).toFixed(1)+' '+y(p[1][kind]).toFixed(1)).join(' ');html='<svg viewBox="0 0 '+width+' '+height+'" aria-hidden="true"><path d="'+path('income')+'" fill="none" stroke="var(--omni-primary)" stroke-width="4"/><path d="'+path('expense')+'" fill="none" stroke="var(--omni-gold)" stroke-width="4"/></svg><div class="chart-legend"><span><i class="legend-dot income-dot"></i>Доход</span><span><i class="legend-dot expense-dot"></i>Расход</span></div>'}byId('finance-chart').innerHTML=html;byId('dashboard-finance-chart').innerHTML=html}
async function loadSchedule(){const data=await get('/schedule'+query()),p=period(),start=new Date(p.from+'T00:00:00Z'),end=new Date(p.to+'T00:00:00Z'),first=new Date(start),last=new Date(end),grouped={};first.setUTCDate(first.getUTCDate()-((first.getUTCDay()+6)%7));last.setUTCDate(last.getUTCDate()+(7-last.getUTCDay())%7);(data.shifts||[]).forEach(x=>(grouped[String(x.business_date).slice(0,10)]||=[]).push(x));let html='';for(const d=new Date(first);d<=last;d.setUTCDate(d.getUTCDate()+1)){const key=d.toISOString().slice(0,10),inside=key>=p.from&&key<=p.to,today=key===new Date().toISOString().slice(0,10);html+='<article class="calendar-day '+(!inside?'outside ':'')+(today?'today':'')+'"><span class="day-number">'+d.getUTCDate()+'</span>'+((grouped[key]||[]).map(x=>'<div class="shift-card"><strong>'+esc(x.display_name)+'</strong><span>'+esc(x.org_unit_name)+' · план '+hours(x.planned_minutes)+' ч</span>'+(x.actual_minutes!==null?'<span class="actual">факт '+hours(x.actual_minutes)+' ч'+(x.actual_started_at?' · '+new Date(x.actual_started_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}):'')+(x.actual_ended_at?'–'+new Date(x.actual_ended_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}):'')+'</span>':'<span>факт: нет отметки</span>')+'</div>').join(''))+'</article>'}byId('calendar-grid').innerHTML=html;fillSelects()}
async function loadPayroll(){const data=await get('/payroll'+query()),accrued=data.totals.reduce((s,x)=>s+Number(x.accrued_minor),0),paid=data.totals.reduce((s,x)=>s+Number(x.paid_minor),0),balance=data.totals.reduce((s,x)=>s+Number(x.balance_minor),0);byId('payroll-accrued').textContent=rub(accrued);byId('payroll-paid').textContent=rub(paid);byId('payroll-balance').textContent=rub(balance);byId('dashboard-accrued').textContent=rub(accrued);byId('dashboard-paid').textContent=rub(paid);byId('dashboard-balance').textContent=rub(balance);byId('payroll-rows').innerHTML=data.totals.length?data.totals.map(x=>'<tr class="payroll-row" data-employee-id="'+esc(x.employee_id||'')+'"><td>'+esc(x.display_name)+'</td><td>'+esc(x.position||'—')+'</td><td>'+hours(x.worked_minutes)+'</td><td>'+rub(x.accrued_minor)+'</td><td>'+rub(x.paid_minor)+'</td><td class="'+(Number(x.balance_minor)>=0?'positive':'negative')+'">'+rub(x.balance_minor)+'</td></tr>').join(''):'<tr><td colspan="6" class="empty">Нет данных для расчёта</td></tr>';byId('payroll-entries').innerHTML=data.entries.length?data.entries.map(x=>'<tr><td>'+esc(x.business_date)+'</td><td>'+esc(x.display_name)+'</td><td>'+({accrual:'Начисление',payout:'Выплата',adjustment:'Корректировка'}[x.kind]||esc(x.kind))+'</td><td>'+rub(Math.abs(Number(x.amount_minor)))+'</td><td>'+esc(x.note||'—')+'</td></tr>').join(''):'<tr><td colspan="5" class="empty">Операций пока нет</td></tr>'}
function renderRuleWorkspace(){
if(!byId('rules-list'))return;
byId('rules-list').innerHTML=payRulesCache.length?payRulesCache.map(rule=>'<article class="rule-card"><span class="metric-icon">◎</span><div><strong>'+esc(rule.name)+'</strong><small>'+esc(rule.code||'Схема начисления')+'</small></div><span class="tag">Активно</span></article>').join(''):'<div class="empty">Схемы начислений не настроены</div>';
byId('dashboard-rules').innerHTML=payRulesCache.slice(0,6).map(rule=>'<span class="tag">'+esc(rule.name)+'</span>').join('');
if(byId('rules-professions'))byId('rules-professions').innerHTML=professionsCache.length?professionsCache.map(x=>'<div class="dense-row"><span class="avatar">'+esc(x.name).slice(0,1).toUpperCase()+'</span><div><strong>'+esc(x.name)+'</strong><small>'+(x.status==='active'?'Активная профессия':'В архиве')+'</small></div><span class="tag">'+(x.status==='active'?'Активна':'Архив')+'</span></div>').join(''):'<div class="empty">Профессии ещё не добавлены</div>';
}
function syncWorkspaceMirrors(){
if(!staffState)return;
const employees=staffState.employees||[],events=staffState.attendance||[],sessions=staffState.work_sessions||[],today=new Date().toDateString(),todayEvents=events.filter(x=>new Date(x.occurred_at).toDateString()===today),issues=sessions.filter(x=>x.status==='pending_review'||x.anomaly);
byId('dashboard-attention').textContent=issues.length;const terminalStatus=document.querySelector('.dashboard-terminal .status');if(terminalStatus)terminalStatus.textContent='Сервер онлайн';
byId('dashboard-employees').innerHTML=employees.length?employees.slice(0,6).map(x=>'<div class="dense-row"><span class="avatar">'+esc(x.display_name).slice(0,1).toUpperCase()+'</span><div><strong>'+esc(x.display_name)+'</strong><small>'+esc(x.position||'Должность не указана')+'</small></div><span class="tag">'+(x.status==='active'?'Работает':x.status==='blocked'?'Заблокирован':'Уволен')+'</span></div>').join(''):'<div class="empty">Сотрудников пока нет</div>';
byId('dashboard-attendance').innerHTML=todayEvents.length?todayEvents.slice(0,6).map(x=>'<div class="dense-row"><span class="event-icon">'+(x.event_type==='check_in'?'↘':'↗')+'</span><div><strong>'+esc(x.display_name)+'</strong><small>'+(x.event_type==='check_in'?'Приход':'Уход')+' · '+esc(x.source)+'</small></div><time>'+new Date(x.occurred_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'})+'</time></div>').join(''):'<div class="empty">Сегодня отметок нет</div>';
const first=events[0];const lastText=first?first.display_name+' — '+(first.event_type==='check_in'?'приход':'уход')+' '+new Date(first.occurred_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}):'Событий пока нет';
byId('terminal-last-event').textContent=lastText;byId('terminal-event-detail').textContent=lastText;byId('terminal-last-sync').textContent=staffState.refreshed_at?new Date(staffState.refreshed_at).toLocaleString('ru-RU'):'Ожидание данных';byId('terminal-sync-detail').textContent=byId('terminal-last-sync').textContent;
byId('terminal-events').innerHTML=events.length?events.slice(0,10).map(x=>'<div class="dense-row"><span class="event-icon">'+(x.event_type==='check_in'?'↘':'↗')+'</span><div><strong>'+esc(x.display_name)+'</strong><small>'+esc(x.source)+' · '+new Date(x.occurred_at).toLocaleString('ru-RU')+'</small></div></div>').join(''):'<div class="empty">Отметок пока нет</div>';
const alerts=issues.map(x=>'<div class="alert-row"><span class="alert-mark">!</span><div><strong>'+esc(x.display_name||'Смена требует проверки')+'</strong><small>'+esc(x.anomaly||'Нужна проверка рабочего времени')+'</small></div></div>');
const alertsHtml=alerts.length?alerts.join(''):'<div class="empty">Нет событий, требующих внимания</div>';byId('dashboard-alert-list').innerHTML=alertsHtml;byId('notifications-list').innerHTML=alertsHtml;
const ins=todayEvents.filter(x=>x.event_type==='check_in').length,outs=todayEvents.filter(x=>x.event_type==='check_out').length;byId('attendance-summary').innerHTML='<dl class="summary-dl"><div><dt>Приходов</dt><dd>'+ins+'</dd></div><div><dt>Уходов</dt><dd>'+outs+'</dd></div><div><dt>Всего отметок</dt><dd>'+todayEvents.length+'</dd></div><div><dt>Смен требуют проверки</dt><dd>'+issues.length+'</dd></div></dl>';
}
document.querySelectorAll('.nav-item').forEach(button=>button.addEventListener('click',()=>show(button.dataset.target)));
window.addEventListener('popstate',()=>show(routeToView[location.pathname]||'overview-view',false));
active=routeToView[location.pathname]||'overview-view';show(active,false);
byId('period-month').value=new Date().toISOString().slice(0,7);byId('period-month').addEventListener('change',loadActive);byId('finance-refresh').addEventListener('click',loadFinance);byId('schedule-refresh').addEventListener('click',loadSchedule);byId('payroll-refresh').addEventListener('click',loadPayroll);byId('finance-date').value=new Date().toISOString().slice(0,10);byId('schedule-date').value=new Date().toISOString().slice(0,10);
function syncEmployeeDirectory(){
const source=byId('employees'),target=byId('employees-directory');if(!source||!target)return;
target.innerHTML=source.innerHTML;
const people=[...target.querySelectorAll('.person')],query=(byId('employee-search')?.value||'').trim().toLocaleLowerCase('ru-RU'),status=byId('employee-status-filter')?.value||'';
people.forEach(person=>person.hidden=Boolean((query&&!person.textContent.toLocaleLowerCase('ru-RU').includes(query))||(status&&person.dataset.status!==status)));
const visible=people.filter(person=>!person.hidden).length;
byId('employee-search-result').textContent=query||status?'Найдено: '+visible:'Все сотрудники';
byId('directory-total').textContent=people.length;
byId('directory-active').textContent=visible;
byId('directory-attention').textContent=people.filter(person=>person.dataset.status&&person.dataset.status!=='active').length;
if(!selectedEmployeeId&&people[0])selectEmployee(people[0].dataset.id);
}
function selectEmployee(id){const employee=staffState?.employees?.find(x=>x.id===id);if(!employee)return;selectedEmployeeId=id;document.querySelectorAll('#employees-directory .person').forEach(x=>x.classList.toggle('selected',x.dataset.id===id));const label=employee.status==='active'?'Работает':employee.status==='blocked'?'Заблокирован':'Уволен';byId('employee-inspector').innerHTML='<div class="inspector-head"><span class="avatar large">'+esc(employee.display_name).slice(0,1).toUpperCase()+'</span><div><p class="eyebrow">Профиль сотрудника</p><h2>'+esc(employee.display_name)+'</h2><span class="tag">'+label+'</span></div></div><dl class="inspector-list"><div><dt>Табельный номер</dt><dd>'+esc(employee.personnel_number)+'</dd></div><div><dt>Должность</dt><dd>'+esc(employee.position||'Не указана')+'</dd></div><div><dt>Пол</dt><dd>'+esc(employee.gender==='female'?'Женский':employee.gender==='male'?'Мужской':'Не указан')+'</dd></div></dl><div class="inspector-actions"><button class="edit-employee" type="button" data-id="'+esc(employee.id)+'">Редактировать</button><button class="quiet person-access" type="button" data-id="'+esc(employee.id)+'">Личный вход</button></div>'}
byId('employee-search').addEventListener('input',syncEmployeeDirectory);
byId('employee-status-filter').addEventListener('change',syncEmployeeDirectory);
byId('add-employee-directory').addEventListener('click',()=>byId('add-employee').click());
byId('manage-professions-directory').addEventListener('click',()=>byId('manage-professions').click());
byId('employees-directory').addEventListener('click',event=>{const row=event.target.closest('.person'),edit=event.target.closest('.edit-employee');if(row)selectEmployee(row.dataset.id);if(edit)byId('employees').querySelector('.edit-employee[data-id="'+edit.dataset.id+'"]').click()});
byId('employee-inspector').addEventListener('click',event=>{const edit=event.target.closest('.edit-employee');if(edit)byId('employees').querySelector('.edit-employee[data-id="'+edit.dataset.id+'"]').click()});
document.querySelectorAll('[data-open-view]').forEach(button=>button.addEventListener('click',()=>show(button.dataset.openView)));
byId('dashboard-add-employee').addEventListener('click',()=>byId('add-employee').click());
byId('global-search').addEventListener('search',event=>{if(role!=='manager')return;show('employees-view');byId('employee-search').value=event.target.value;syncEmployeeDirectory()});
document.querySelectorAll('[data-event-filter]').forEach(button=>button.addEventListener('click',()=>{document.querySelectorAll('[data-event-filter]').forEach(x=>x.classList.toggle('selected',x===button));document.querySelectorAll('#attendance .event').forEach(row=>row.hidden=Boolean(button.dataset.eventFilter&&row.dataset.eventType!==button.dataset.eventFilter))}));
byId('finance-form').addEventListener('submit',async event=>{event.preventDefault();const button=event.submitter;button.disabled=true;try{await send('/finance/'+byId('finance-unit').value+'/'+byId('finance-date').value+'/'+byId('finance-category').value,'PUT',{value_minor:Math.round(Number(byId('finance-amount').value)*100),currency_code:'RUB'});byId('finance-amount').value='';await loadFinance()}catch(error){alert(error.message)}finally{button.disabled=false}});
byId('schedule-form').addEventListener('submit',async event=>{event.preventDefault();const button=event.submitter;button.disabled=true;try{await send('/schedule/'+byId('schedule-employee').value+'/'+byId('schedule-date').value,'PUT',{org_unit_id:byId('schedule-unit').value,planned_minutes:Math.round(Number(byId('schedule-hours').value)*60),start_time:byId('schedule-start').value,end_time:byId('schedule-end').value});await loadSchedule()}catch(error){alert(error.message)}finally{button.disabled=false}});
document.addEventListener('click',async event=>{const button=event.target.closest('.person-access');if(!button)return;button.disabled=true;try{const result=await send('/employees/'+button.dataset.id+'/access-token','POST',{});byId('access-token').value=result.access_token;byId('access-dialog').showModal()}catch(error){alert(error.message)}finally{button.disabled=false}});byId('access-copy').addEventListener('click',async()=>{await navigator.clipboard.writeText(byId('access-token').value);byId('access-copy').textContent='Скопировано'});
document.addEventListener('click',async event=>{
const editBtn=event.target.closest('.edit-employee');
if(!editBtn||role!=='manager')return;
const empId=editBtn.dataset.id;
if(byId('employee-pay-section')) byId('employee-pay-section').hidden=false;
try{
const a=await get('/employees/'+empId+'/pay-assignment');
byId('employee-pay-unit').value=a.org_unit_id||'';
byId('employee-pay-rule').value=a.pay_rule_id;
byId('employee-pay-rate').value=Math.round(Number(a.rate_minor||0)/100);
byId('employee-pay-percent').value=(Number(a.percent_bp||0)/100).toFixed(1);
byId('employee-pay-guarantee').value=Math.round(Number(a.guarantee_minor||0)/100);
byId('employee-pay-pool-weight').value=Math.round(Number(a.pool_weight_bp||0)/100);
byId('employee-pay-effective').value=a.effective_from||'2026-01-01';
}catch{
if(payRulesCache.length>0) byId('employee-pay-rule').value=payRulesCache[0].id;
byId('employee-pay-unit').value='';
byId('employee-pay-rate').value=100;
byId('employee-pay-percent').value=0;
byId('employee-pay-guarantee').value=0;
byId('employee-pay-pool-weight').value=100;
byId('employee-pay-effective').value='2026-01-01';
}
});
if(byId('add-employee')){
byId('add-employee').addEventListener('click',()=>{
if(byId('employee-pay-section')) byId('employee-pay-section').hidden=false;
if(payRulesCache.length>0) byId('employee-pay-rule').value=payRulesCache[0].id;
byId('employee-pay-unit').value='';
byId('employee-pay-rate').value=100;
byId('employee-pay-percent').value=0;
byId('employee-pay-guarantee').value=0;
byId('employee-pay-pool-weight').value=100;
byId('employee-pay-effective').value='2026-01-01';
});
}
if(byId('employee-pay-rule')){
byId('employee-pay-rule').addEventListener('change',()=>{
const rule=payRulesCache.find(r=>r.id===byId('employee-pay-rule').value);
if(rule){
if(rule.code==='pool_member'){
byId('employee-pay-rate').value=100;
byId('employee-pay-percent').value=0;
byId('employee-pay-guarantee').value=0;
byId('employee-pay-pool-weight').value=100;
}else if(rule.code==='revenue_percent'){
byId('employee-pay-rate').value=100;
byId('employee-pay-percent').value=2.5;
byId('employee-pay-guarantee').value=3000;
byId('employee-pay-pool-weight').value=0;
}else if(rule.code==='hourly'){
byId('employee-pay-rate').value=100;
byId('employee-pay-percent').value=0;
byId('employee-pay-guarantee').value=0;
byId('employee-pay-pool-weight').value=0;
}
}
});
}
if(byId('employee-form')){
byId('employee-form').addEventListener('submit',async()=>{
const empId=byId('employee-id').value;
if(role==='manager'&&empId&&byId('employee-pay-rule')&&byId('employee-pay-rule').value){
try{
await send('/employees/'+empId+'/pay-assignment','PUT',{
org_unit_id:byId('employee-pay-unit').value||null,
pay_rule_id:byId('employee-pay-rule').value,
rate_minor:Math.round(Number(byId('employee-pay-rate').value||0)*100),
percent_bp:Math.round(Number(byId('employee-pay-percent').value||0)*100),
guarantee_minor:Math.round(Number(byId('employee-pay-guarantee').value||0)*100),
pool_weight_bp:Math.round(Number(byId('employee-pay-pool-weight').value||0)*100),
effective_from:byId('employee-pay-effective').value||'2026-01-01'
});
}catch(e){console.error('Pay assignment save error:',e)}
}
});
}
if(byId('payroll-calc-btn')){
byId('payroll-calc-btn').addEventListener('click',async()=>{
const btn=byId('payroll-calc-btn');
btn.disabled=true;
byId('payroll-warnings').hidden=true;
byId('payroll-calc-status').hidden=true;
try{
const month=byId('period-month').value||new Date().toISOString().slice(0,7);
const res=await send('/payroll/calculate','POST',{month});
currentRunId=res.run_id;
if(res.warnings&&res.warnings.length){
byId('payroll-warnings').innerHTML='<strong>Предупреждения при расчёте:</strong><ul>'+res.warnings.map(w=>'<li>'+esc(w.message)+'</li>').join('')+'</ul>';
byId('payroll-warnings').hidden=false;
}
byId('payroll-preview-title').textContent='Предварительный результат: '+rub(res.total_accrued_minor);
byId('payroll-preview-rows').innerHTML=res.totals.length?res.totals.map(x=>'<tr><td>'+esc(x.display_name)+'</td><td>'+esc(x.position||'—')+'</td><td>'+x.days+'</td><td>'+hours(x.worked_minutes)+'</td><td>'+rub(x.accrued_minor)+'</td></tr>').join(''):'<tr><td colspan="5" class="empty">Нет начислений</td></tr>';
byId('payroll-preview-card').hidden=false;
}catch(err){alert(err.message||'Ошибка расчёта зарплаты')}finally{btn.disabled=false}
});
}
const themeSelect=byId('theme-select');
const allowedThemes=['noble-green','parchment-light','graphite-forest-dark'];
const savedTheme=localStorage.getItem('krona-staff-theme');
if(savedTheme&&allowedThemes.includes(savedTheme))document.documentElement.dataset.omniTheme=savedTheme;
themeSelect.value=document.documentElement.dataset.omniTheme||'noble-green';
themeSelect.addEventListener('change',()=>{document.documentElement.dataset.omniTheme=themeSelect.value;localStorage.setItem('krona-staff-theme',themeSelect.value)});
let professionsCache=[];
async function loadProfessions(){
const result=await get('/professions');
professionsCache=result.professions||[];
const select=byId('employee-profession');
const current=select.value;
select.innerHTML='<option value="">Не указана</option>'+professionsCache.filter(x=>x.status==='active').map(x=>option(x.id,x.name)).join('');
if([...select.options].some(x=>x.value===current))select.value=current;
byId('profession-list').innerHTML=professionsCache.length?professionsCache.map(x=>'<div class="person"><span class="avatar">'+esc(x.name).slice(0,1).toUpperCase()+'</span><div><strong>'+esc(x.name)+'</strong><small>'+(x.status==='active'?'Активна':'В архиве')+'</small></div></div>').join(''):'<div class="empty">Профессии ещё не добавлены</div>';renderRuleWorkspace();
}
byId('employee-profession').addEventListener('change',()=>{const selected=byId('employee-profession').selectedOptions[0];byId('employee-position').value=selected&&selected.value?selected.textContent:''});
document.addEventListener('click',event=>{const target=event.target.closest?.('.edit-employee,#add-employee');if(!target)return;setTimeout(async()=>{await loadProfessions();const position=byId('employee-position').value;const match=professionsCache.find(x=>x.name===position);byId('employee-profession').value=match?.id||''},0)},true);
byId('manage-professions').addEventListener('click',async()=>{byId('profession-error').hidden=true;await loadProfessions();byId('profession-dialog').showModal()});
byId('profession-close').addEventListener('click',()=>byId('profession-dialog').close());
byId('profession-form').addEventListener('submit',async event=>{event.preventDefault();const name=byId('profession-name').value.trim();if(!name)return;try{const created=await send('/professions','POST',{name});byId('profession-name').value='';await loadProfessions();byId('employee-profession').value=created.id;byId('employee-position').value=created.name}catch(error){byId('profession-error').textContent=error.message||'Не удалось сохранить профессию.';byId('profession-error').hidden=false}});
if(byId('payroll-approve-btn')){
byId('payroll-approve-btn').addEventListener('click',async()=>{
if(!currentRunId)return;
const btn=byId('payroll-approve-btn');
btn.disabled=true;
try{
const res=await send('/payroll/runs/'+currentRunId+'/approve','POST',{});
byId('payroll-preview-card').hidden=true;
byId('payroll-calc-status').textContent='Расчёт успешно утверждён! Начислено: '+rub(res.total_accrued_minor)+' ('+res.entries_posted+' начислений проведено в реестр).';
byId('payroll-calc-status').hidden=false;
await loadPayroll();
}catch(err){alert(err.message||'Ошибка утверждения расчёта')}finally{btn.disabled=false}
});
}
const notifyBtn=byId('notify');
async function updatePushStatus(){
if(!('serviceWorker' in navigator)||!('PushManager' in window)||!notifyBtn) return;
try{
const reg=await navigator.serviceWorker.ready;
const sub=await reg.pushManager.getSubscription();
notifyBtn.textContent=sub?'Уведомления включены':'Включить push';
notifyBtn.classList.toggle('active',!!sub);
}catch{}
}
if(notifyBtn){
notifyBtn.addEventListener('click',async()=>{
if(!('serviceWorker' in navigator)||!('PushManager' in window)){
alert('Этот браузер не поддерживает WebPush-уведомления.');
return;
}
const reg=await navigator.serviceWorker.ready;
const existing=await reg.pushManager.getSubscription();
if(existing){
try{await send('/push/subscriptions','DELETE',{endpoint:existing.endpoint});}catch{}
await existing.unsubscribe();
await updatePushStatus();
return;
}
const perm=await Notification.requestPermission();
if(perm!=='granted'){
alert('Разрешение на push-уведомления не предоставлено.');
return;
}
try{
notifyBtn.disabled=true;
const res=await get('/push/public-key');
const pub=res.public_key;
const padding='='.repeat((4-(pub.length%4))%4);
const b64=(pub+padding).replace(/-/g,'+').replace(/_/g,'/');
const raw=atob(b64);
const arr=new Uint8Array(raw.length);
for(let i=0;i<raw.length;++i) arr[i]=raw.charCodeAt(i);
const sub=await reg.pushManager.subscribe({userVisibleOnly:true,applicationServerKey:arr});
const json=sub.toJSON();
await send('/push/subscriptions','POST',{
endpoint:sub.endpoint,
keys:{p256dh:json.keys.p256dh,auth:json.keys.auth},
user_agent:navigator.userAgent
});
await updatePushStatus();
const msg=byId('message');
if(msg){
msg.textContent='Push-уведомления о фиксации отпечатка успешно включены!';
msg.hidden=false;
setTimeout(()=>{msg.hidden=true;},4000);
}
}catch(err){
alert('Ошибка подключения push: '+(err.message||err));
}finally{
notifyBtn.disabled=false;
}
});
setTimeout(updatePushStatus,500);
}
window.addEventListener('online',()=>{
const conn=byId('connection');if(conn){conn.textContent='Синхронизировано';conn.className='status ok';}
loadActive();
});
window.addEventListener('offline',()=>{
const conn=byId('connection');if(conn){conn.textContent='Нет связи. Показаны сохранённые данные';conn.className='status offline';}
});
const observer=new MutationObserver(()=>{if(role==='manager')document.querySelectorAll('#employees .edit-employee').forEach(edit=>{if(edit.parentElement.querySelector('.person-access'))return;const button=document.createElement('button');button.type='button';button.className='quiet compact person-access';button.dataset.id=edit.dataset.id;button.textContent='Личный вход';edit.insertAdjacentElement('afterend',button)});fillSelects();syncEmployeeDirectory()});observer.observe(byId('employees'),{childList:true,subtree:true});const shellObserver=new MutationObserver(()=>{if(!byId('app-shell').hidden){initialized=false;initialize()}else initialized=false});shellObserver.observe(byId('app-shell'),{attributes:true,attributeFilter:['hidden']});setTimeout(initialize,0);
})();
`;
export const portalIcon192 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192"><rect width="192" height="192" rx="44" fill="#315F46"/><circle cx="96" cy="96" r="64" fill="#657255" opacity="0.35"/><path d="M 60 48 L 60 144 M 60 96 L 132 48 M 90 76 L 132 144" stroke="#B78335" stroke-width="16" stroke-linecap="round" stroke-linejoin="round" fill="none"/><circle cx="60" cy="48" r="6" fill="#EFE0C2"/><circle cx="132" cy="48" r="6" fill="#EFE0C2"/><circle cx="132" cy="144" r="6" fill="#EFE0C2"/></svg>`;
export const portalIcon512 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"><rect width="512" height="512" rx="120" fill="#315F46"/><circle cx="256" cy="256" r="170" fill="#657255" opacity="0.35"/><path d="M 160 128 L 160 384 M 160 256 L 352 128 M 240 200 L 352 384" stroke="#B78335" stroke-width="44" stroke-linecap="round" stroke-linejoin="round" fill="none"/><circle cx="160" cy="128" r="16" fill="#EFE0C2"/><circle cx="352" cy="128" r="16" fill="#EFE0C2"/><circle cx="352" cy="384" r="16" fill="#EFE0C2"/></svg>`;
export const portalManifest = JSON.stringify({
name: 'Krona Staff',
short_name: 'Staff',
start_url: '/staff/dashboard',
scope: '/staff',
display: 'standalone',
background_color: '#F3EAD7',
theme_color: '#657255',
lang: 'ru',
icons: [
{
src: '/staff/icon-192.svg',
sizes: '192x192',
type: 'image/svg+xml',
purpose: 'any maskable',
},
{
src: '/staff/icon-512.svg',
sizes: '512x512',
type: 'image/svg+xml',
purpose: 'any maskable',
},
],
});
export const portalServiceWorker = `const CACHE='krona-staff-v3';const ASSETS=['/staff','/staff/styles.css','/staff/app.js','/staff/manifest.webmanifest'];self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(ASSETS))));self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k))))));self.addEventListener('fetch',e=>{if(e.request.method==='GET'&&new URL(e.request.url).pathname.startsWith('/staff'))e.respondWith(fetch(e.request).then(r=>{const copy=r.clone();caches.open(CACHE).then(c=>c.put(e.request,copy));return r;}).catch(()=>caches.match(e.request)));});`;
export const portalServiceWorkerEnhanced = `const CACHE='krona-staff-v6';
const ASSETS=['/staff/dashboard','/staff/styles.css','/staff/app.js','/staff/manifest.webmanifest','/staff/icon-192.svg','/staff/icon-512.svg'];
self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(ASSETS)).then(()=>self.skipWaiting())));
self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k)))).then(()=>self.clients.claim())));
self.addEventListener('fetch',e=>{
if(e.request.method==='GET'){
const url=new URL(e.request.url);
// Authenticated API responses can contain attendance and payroll data. They must never
// enter the shared service-worker cache; offline personal data needs a user-bound,
// encrypted store with an explicit retention policy.
if(url.pathname.startsWith('/api/v1/staff/'))return;
if(url.pathname.startsWith('/staff')){
e.respondWith(fetch(e.request).then(r=>{const copy=r.clone();caches.open(CACHE).then(c=>c.put(e.request,copy));return r;}).catch(()=>caches.match(e.request)));
}
}
});
self.addEventListener('push',e=>{
let data={title:'Krona Staff',body:'Отметка рабочего времени зарегистрирована',icon:'/staff/icon-192.svg',badge:'/staff/icon-192.svg',tag:'attendance',data:{url:'/staff'}};
if(e.data){
try{data=Object.assign(data,e.data.json());}catch{data.body=e.data.text()||data.body;}
}
e.waitUntil(self.registration.showNotification(data.title,{body:data.body,icon:data.icon||'/staff/icon-192.svg',badge:data.badge||'/staff/icon-192.svg',tag:data.tag||'attendance',renotify:true,data:data.data||{url:'/staff'}}));
});
self.addEventListener('notificationclick',e=>{
e.notification.close();
const url=(e.notification.data&&e.notification.data.url)||'/staff';
e.waitUntil(clients.matchAll({type:'window',includeUncontrolled:true}).then(wList=>{for(const c of wList){if(c.url.includes('/staff')&&'focus' in c)return c.focus();}if(clients.openWindow)return clients.openWindow(url);}));
});`;