542 lines
87 KiB
TypeScript
542 lines
87 KiB
TypeScript
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);}));
|
||
});`;
|