import { portalDesignCss } from './portal-design.js'; import { portalWorkspaceHtml } from './portal-workspace.js'; const legacyPortalHtml = ` Krona Staff
КKrona Staff

Защищённый кабинет

Вход в Krona Staff

Введите ключ руководителя или личный ключ сотрудника. После входа будут доступны только разрешённые вам данные.

Сессия действует 8 часов и хранится только в защищённой cookie.
`; 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='Предупреждения при расчёте:'; 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);})); });`;