import { portalDesignCss } from './portal-design.js';
import { portalWorkspaceHtml } from './portal-workspace.js';
const legacyPortalHtml = `
Krona Staff
КKrona Staff
Защищённый кабинет
Вход в Krona Staff
Введите ключ руководителя или личный ключ сотрудника. После входа будут доступны только разрешённые вам данные.
Сессия действует 8 часов и хранится только в защищённой cookie.
Личный кабинет
Рабочий день — под контролем
Отметки терминала, сотрудники и табель в одном защищённом пространстве.
Сегодня—Данные загружаются
Сотрудников—
Отметок сегодня—
Часов в табеле—
Последние события
Отметки терминала
● в реальном времени
Сверка терминала
Рабочие смены
| Дата | Сотрудник | Подразделение | Интервал | Часы | Состояние | Причина правки | |
|---|
Учёт времени
Табель за 40 дней
Изменения сохраняет руководитель| Дата | Сотрудник | Часы | Статус | Примечание | |
|---|
Команда
Сотрудники
Кадровые данные, профессии, статусы и зарплатные профили.
Всего сотрудников—Показано—Требуют внимания—
Управленческий учёт
Доходы и расходы
Выручка по каналам, расходы по категориям и прибыль за выбранный месяц.
Динамика по дням
Выручка и расходы
₽
| Дата | Точка | Категория | Тип | Сумма |
|---|
План и факт
Календарь смен
Плановые часы рядом с фактическими приходами и уходами со сканера.
Расчётный лист
Зарплата
Начисления, выплаты, остаток и фактически подтверждённые часы.
Предварительный результат
Расчёт зарплаты
| Сотрудник | Должность | Дней | Факт, часов | Начислено (расчёт) |
|---|
Начислено—Выплачено—Остаток—
Сотрудники
Расчёт за месяц
По подтверждённым сменам и начислениям| Сотрудник | Должность | Факт, часов | Начислено | Выплачено | Остаток |
|---|
История
Начисления и выплаты
| Дата | Сотрудник | Операция | Сумма | Примечание |
|---|
`;
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=>''+(x.event_type==='check_in'?'↘':'↗')+''+esc(x.display_name)+''+(x.event_type==='check_in'?'Приход':'Уход')+' · '+esc(x.source)+'
').join(''):'Отметок пока нет
';
el('employees').innerHTML=data.employees.length?data.employees.map(x=>''+esc(x.display_name).slice(0,1).toUpperCase()+''+esc(x.display_name)+''+esc(x.position||'Должность не указана')+' · № '+esc(x.personnel_number)+'
'+esc(x.status==='dismissed'?'Уволен':x.status==='blocked'?'Заблокирован':'Работает')+''+(viewerRole==='manager'?'
':'')+'
').join(''):'Сотрудники не найдены
';
el('add-employee').hidden=viewerRole!=='manager'; el('timesheet-create').hidden=viewerRole!=='manager'; el('new-time-employee').innerHTML=data.employees.map(x=>'').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(''):'| Табель пока пуст — добавьте первую строку выше |
'; bindSaves(); bindEmployeeEdits(); }
function timesheetRow(row){const editable=viewerRole==='manager'; const statuses={draft:'Черновик',submitted:'На проверке',approved:'Утверждён',rejected:'Возвращён'}; return '| '+localDate(row.work_date)+' | '+esc(row.display_name)+' | '+(editable?'':(Number(row.minutes_worked)/60).toLocaleString('ru-RU'))+' | '+(editable?'':''+esc(statuses[row.status]||row.status)+'')+' | '+(editable?'':esc(row.note||'—'))+' | '+(editable?'':'')+' |
';}
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 '| '+localDate(row.business_date)+' | '+esc(row.display_name)+' | '+esc(row.org_unit_name||'—')+' | '+interval+' | '+(review?'':(Number(row.worked_minutes)/60).toLocaleString('ru-RU'))+' | '+esc(states[row.status]||row.status)+''+(issue?''+esc(issue)+'':'')+' | '+(review?'':esc(row.correction_note||'—'))+' | '+(review?'':'')+' |
';}).join(''):'| Смен пока нет — выполните сверку отметок |
';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)=>'';
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=''+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=>'| '+esc(x.business_date)+' | '+esc(x.org_unit_name)+' | '+esc(x.label)+' | '+(x.metric_key.startsWith('income_')?'Доход':'Расход')+' | '+rub(x.value_minor)+' |
').join(''):'| За месяц операций нет |
';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='Добавьте выручку или расходы — здесь появится график
';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='ДоходРасход
'}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+=''+d.getUTCDate()+''+((grouped[key]||[]).map(x=>''+esc(x.display_name)+''+esc(x.org_unit_name)+' · план '+hours(x.planned_minutes)+' ч'+(x.actual_minutes!==null?'факт '+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'}):'')+'':'факт: нет отметки')+'
').join(''))+''}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=>'| '+esc(x.display_name)+' | '+esc(x.position||'—')+' | '+hours(x.worked_minutes)+' | '+rub(x.accrued_minor)+' | '+rub(x.paid_minor)+' | '+rub(x.balance_minor)+' |
').join(''):'| Нет данных для расчёта |
';byId('payroll-entries').innerHTML=data.entries.length?data.entries.map(x=>'| '+esc(x.business_date)+' | '+esc(x.display_name)+' | '+({accrual:'Начисление',payout:'Выплата',adjustment:'Корректировка'}[x.kind]||esc(x.kind))+' | '+rub(Math.abs(Number(x.amount_minor)))+' | '+esc(x.note||'—')+' |
').join(''):'| Операций пока нет |
'}
function renderRuleWorkspace(){
if(!byId('rules-list'))return;
byId('rules-list').innerHTML=payRulesCache.length?payRulesCache.map(rule=>'◎'+esc(rule.name)+''+esc(rule.code||'Схема начисления')+'
Активно').join(''):'Схемы начислений не настроены
';
byId('dashboard-rules').innerHTML=payRulesCache.slice(0,6).map(rule=>''+esc(rule.name)+'').join('');
if(byId('rules-professions'))byId('rules-professions').innerHTML=professionsCache.length?professionsCache.map(x=>''+esc(x.name).slice(0,1).toUpperCase()+''+esc(x.name)+''+(x.status==='active'?'Активная профессия':'В архиве')+'
'+(x.status==='active'?'Активна':'Архив')+' ').join(''):'Профессии ещё не добавлены
';
}
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=>''+esc(x.display_name).slice(0,1).toUpperCase()+''+esc(x.display_name)+''+esc(x.position||'Должность не указана')+'
'+(x.status==='active'?'Работает':x.status==='blocked'?'Заблокирован':'Уволен')+' ').join(''):'Сотрудников пока нет
';
byId('dashboard-attendance').innerHTML=todayEvents.length?todayEvents.slice(0,6).map(x=>''+(x.event_type==='check_in'?'↘':'↗')+''+esc(x.display_name)+''+(x.event_type==='check_in'?'Приход':'Уход')+' · '+esc(x.source)+'
').join(''):'Сегодня отметок нет
';
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=>''+(x.event_type==='check_in'?'↘':'↗')+''+esc(x.display_name)+''+esc(x.source)+' · '+new Date(x.occurred_at).toLocaleString('ru-RU')+'
').join(''):'Отметок пока нет
';
const alerts=issues.map(x=>'!'+esc(x.display_name||'Смена требует проверки')+''+esc(x.anomaly||'Нужна проверка рабочего времени')+'
');
const alertsHtml=alerts.length?alerts.join(''):'Нет событий, требующих внимания
';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='- Приходов
- '+ins+'
- Уходов
- '+outs+'
- Всего отметок
- '+todayEvents.length+'
- Смен требуют проверки
- '+issues.length+'
';
}
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=''+esc(employee.display_name).slice(0,1).toUpperCase()+'Профиль сотрудника
'+esc(employee.display_name)+'
'+label+' - Табельный номер
- '+esc(employee.personnel_number)+'
- Должность
- '+esc(employee.position||'Не указана')+'
- Пол
- '+esc(employee.gender==='female'?'Женский':employee.gender==='male'?'Мужской':'Не указан')+'
'}
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='Предупреждения при расчёте:'+res.warnings.map(w=>'- '+esc(w.message)+'
').join('')+'
';
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=>'| '+esc(x.display_name)+' | '+esc(x.position||'—')+' | '+x.days+' | '+hours(x.worked_minutes)+' | '+rub(x.accrued_minor)+' |
').join(''):'| Нет начислений |
';
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=''+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=>''+esc(x.name).slice(0,1).toUpperCase()+''+esc(x.name)+''+(x.status==='active'?'Активна':'В архиве')+'
').join(''):'Профессии ещё не добавлены
';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{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 = ``;
export const portalIcon512 = ``;
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);}));
});`;