();
+ for (const entry of body.financial_entries ?? []) {
+ const orgUnitId = unitIds.get(entry.org_unit_code)!;
+ await client.query(
+ `INSERT INTO staff_daily_metrics
+ (id, tenant_id, org_unit_id, business_date, metric_key, value,
+ currency_code, breakdown, source, recorded_by)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'import', $9)
+ ON CONFLICT (tenant_id, org_unit_id, business_date, metric_key)
+ DO UPDATE SET value = EXCLUDED.value, currency_code = EXCLUDED.currency_code,
+ breakdown = EXCLUDED.breakdown, source = 'import',
+ recorded_by = EXCLUDED.recorded_by, updated_at = now()`,
+ [newUlid(), context.tenantId, orgUnitId, entry.business_date, entry.metric_key,
+ entry.value_minor, entry.currency_code ?? 'RUB',
+ { source_name: body.source_name }, context.actorId],
+ );
+ if (entry.metric_key.startsWith('income_')) {
+ affectedRevenueDays.add(`${orgUnitId}|${entry.business_date}`);
+ }
+ }
+ for (const key of affectedRevenueDays) {
+ const [orgUnitId, businessDate] = key.split('|') as [string, string];
+ await refreshRevenueMetric(client, context.tenantId, context.actorId,
+ orgUnitId, businessDate);
+ }
+
+ for (const shift of body.schedule_items ?? []) {
+ const attributes = {
+ planned_minutes: shift.planned_minutes,
+ location_code: shift.location_code ?? '',
+ imported_from: body.source_name,
+ };
+ await client.query(
+ `INSERT INTO staff_work_items
+ (id, tenant_id, employee_id, org_unit_id, business_date, item_kind,
+ attributes, source, external_key, created_by)
+ VALUES ($1, $2, $3, $4, $5, 'planned_shift', $6, 'import', $7, $8)
+ ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL
+ DO UPDATE SET employee_id = EXCLUDED.employee_id,
+ org_unit_id = EXCLUDED.org_unit_id,
+ business_date = EXCLUDED.business_date,
+ attributes = EXCLUDED.attributes, source = 'import'`,
+ [newUlid(), context.tenantId, employeeIds.get(shift.employee_external_ref),
+ unitIds.get(shift.org_unit_code), shift.business_date, attributes,
+ shift.external_key, context.actorId],
+ );
+ }
+
+ for (const entry of body.payroll_entries ?? []) {
+ const amount = entry.kind === 'payout'
+ ? -Math.abs(entry.amount_minor)
+ : entry.amount_minor;
+ await client.query(
+ `INSERT INTO staff_payroll_ledger
+ (id, tenant_id, employee_id, business_date, kind, amount_minor,
+ currency_code, ref_type, correlation_id, note, created_by, external_key)
+ VALUES ($1, $2, $3, $4, $5, $6, 'RUB', 'manual', $7, $8, $9, $10)
+ ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING`,
+ [newUlid(), context.tenantId, employeeIds.get(entry.employee_external_ref),
+ entry.business_date, entry.kind, amount, context.correlationId,
+ entry.note?.trim() ?? `Импорт: ${body.source_name}`,
+ context.actorId, entry.external_key],
+ );
+ }
+ const imported = {
+ units: body.units?.length ?? 0,
+ employees: body.employees?.length ?? 0,
+ financial_entries: body.financial_entries?.length ?? 0,
+ schedule_items: body.schedule_items?.length ?? 0,
+ payroll_entries: body.payroll_entries?.length ?? 0,
+ };
+ await writeAudit(client, context, 'owner_workbook.imported', 'ImportBatch',
+ newUlid(), { source_name: body.source_name, ...imported });
+ return imported;
+ });
+ return reply.code(202).send(counts);
+ });
+}
+
+const periodSchema = {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
+ to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
+ },
+} as const;
+
+function parsePeriod(query: PeriodQuery) {
+ const now = new Date();
+ const first = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
+ const last = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0));
+ const from = query.from ?? first.toISOString().slice(0, 10);
+ const to = query.to ?? last.toISOString().slice(0, 10);
+ if (!parseDate(from) || !parseDate(to) || from > to) throw new Error('Некорректный период.');
+ return { from, to };
+}
+
+function parseDate(value: string): Date | null {
+ if (!datePattern.test(value)) return null;
+ const parsed = new Date(`${value}T00:00:00.000Z`);
+ return Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== value
+ ? null
+ : parsed;
+}
+
+async function ensureMetricDefinitions(client: PoolClient, tenantId: string) {
+ for (const [key, label] of financialMetricDefinitions) {
+ await client.query(
+ `INSERT INTO staff_metric_definitions
+ (id, tenant_id, key, label, value_kind, source)
+ VALUES ($1, $2, $3, $4, 'money', 'manual')
+ ON CONFLICT (tenant_id, key) DO UPDATE SET label = EXCLUDED.label, is_active = TRUE`,
+ [newUlid(), tenantId, key, label],
+ );
+ }
+ await client.query(
+ `INSERT INTO staff_metric_definitions
+ (id, tenant_id, key, label, value_kind, source)
+ VALUES ($1, $2, 'revenue_minor', 'Общая выручка', 'money', 'module')
+ ON CONFLICT (tenant_id, key) DO UPDATE SET label = EXCLUDED.label, is_active = TRUE`,
+ [newUlid(), tenantId],
+ );
+}
+
+async function refreshRevenueMetric(
+ client: PoolClient,
+ tenantId: string,
+ actorId: string,
+ orgUnitId: string,
+ businessDate: string,
+) {
+ await client.query(
+ `INSERT INTO staff_daily_metrics
+ (id, tenant_id, org_unit_id, business_date, metric_key, value,
+ currency_code, source, recorded_by)
+ SELECT $1, $2, $3, $4, 'revenue_minor',
+ COALESCE(SUM(value) FILTER (WHERE metric_key LIKE 'income\\_%' ESCAPE '\\'), 0),
+ 'RUB', 'module', $5
+ FROM staff_daily_metrics
+ WHERE tenant_id = $2 AND org_unit_id = $3 AND business_date = $4
+ ON CONFLICT (tenant_id, org_unit_id, business_date, metric_key)
+ DO UPDATE SET value = EXCLUDED.value, source = 'module',
+ recorded_by = EXCLUDED.recorded_by, updated_at = now()`,
+ [newUlid(), tenantId, orgUnitId, businessDate, actorId],
+ );
+}
+
+async function writeAudit(
+ client: PoolClient,
+ context: RequestContext,
+ action: string,
+ entityType: string,
+ entityId: string,
+ values: unknown,
+) {
+ await client.query(
+ `INSERT INTO staff_audit_log
+ (id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
+ [newUlid(), context.tenantId, context.actorId, action, entityType,
+ entityId, values, context.correlationId],
+ );
+}
+
+function validateImport(body: OwnerWorkbookImportBody): boolean {
+ if (!body.source_name.trim()) return false;
+ for (const unit of body.units ?? []) {
+ if (!/^[a-z0-9_-]{1,40}$/.test(unit.code) || !unit.name.trim()) return false;
+ }
+ for (const entry of body.financial_entries ?? []) {
+ if (!financialMetricKeys.has(entry.metric_key)
+ || !parseDate(entry.business_date)
+ || !Number.isSafeInteger(entry.value_minor)
+ || entry.value_minor < 0) return false;
+ }
+ for (const employee of body.employees ?? []) {
+ if (!employee.external_ref || !employee.personnel_number.trim() || !employee.display_name.trim()) {
+ return false;
+ }
+ }
+ for (const shift of body.schedule_items ?? []) {
+ if (!shift.external_key || !parseDate(shift.business_date)
+ || !Number.isInteger(shift.planned_minutes)
+ || shift.planned_minutes < 0 || shift.planned_minutes > 1440) return false;
+ }
+ for (const entry of body.payroll_entries ?? []) {
+ if (!entry.external_key || !parseDate(entry.business_date)
+ || !Number.isSafeInteger(entry.amount_minor)) return false;
+ }
+ return true;
+}
diff --git a/src/portal-ui.ts b/src/portal-ui.ts
index 9de2d06..3aac41c 100644
--- a/src/portal-ui.ts
+++ b/src/portal-ui.ts
@@ -14,10 +14,10 @@ export const portalHtml = `
КKrona Staff
Защищённый кабинет
-
Вход администратора
-
Введите ключ администратора. Сотрудники, отметки и табель скрыты до успешного входа.
+
Вход в Krona Staff
+
Введите ключ руководителя или личный ключ сотрудника. После входа будут доступны только разрешённые вам данные.
@@ -30,7 +30,15 @@ export const portalHtml = `
КKrona Staff
Подключение…
+
+
Личный кабинет
Рабочий день — под контролем
Отметки терминала, сотрудники и табель в одном защищённом пространстве.
Сегодня—Данные загружаются
@@ -47,8 +55,27 @@ export const portalHtml = `
Сверка терминала
Рабочие смены
| Дата | Сотрудник | Подразделение | Интервал | Часы | Состояние | Причина правки | |
|---|
Учёт времени
Табель за 40 дней
Изменения сохраняет руководитель| Дата | Сотрудник | Часы | Статус | Примечание | |
|---|
+
+
+
Управленческий учёт
Доходы и расходы
Выручка по каналам, расходы по категориям и прибыль за выбранный месяц.
+
+
Динамика по дням
Выручка и расходы
₽
+
| Дата | Точка | Категория | Тип | Сумма |
|---|
+
+
+
План и факт
Календарь смен
Плановые часы рядом с фактическими приходами и уходами со сканера.
+
+
+
+
+
Расчётный лист
Зарплата
Начисления, выплаты, остаток и фактически подтверждённые часы.
+
Начислено—Выплачено—Остаток—
+
Сотрудники
Расчёт за месяц
По подтверждённым сменам и начислениям| Сотрудник | Должность | Факт, часов | Начислено | Выплачено | Остаток |
|---|
+
История
Начисления и выплаты
| Дата | Сотрудник | Операция | Сумма | Примечание |
|---|
+
+
@@ -92,5 +119,34 @@ export const portalJs = `(() => {
if('serviceWorker' in navigator) navigator.serviceWorker.register('/staff/sw.js'); load(false); setInterval(()=>{if(!el('app-shell').hidden)load(true);},15000);
})();`;
+export const portalCssEnhanced = portalCss + `
+.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}}
+`;
+
+export const portalJsEnhanced = portalJs.replace('Неверный ключ администратора.', 'Неверный ключ доступа.') + `
+(() => {
+ const api='/api/v1',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';
+ 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});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');role=state.viewer&&state.viewer.role||'manager';document.querySelectorAll('.manager-only').forEach(node=>node.hidden=role!=='manager');byId('viewer').textContent=role==='employee'?'Личный кабинет сотрудника':'Кабинет руководителя';units=(await get('/org-units')).org_units||[];fillSelects();await loadActive()}catch{initialized=false}}
+ function show(target){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));loadActive()}
+ async function loadActive(){try{if(active==='finance-view')await loadFinance();if(active==='schedule-view')await loadSchedule();if(active==='payroll-view')await loadPayroll()}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('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]));if(!points.length){byId('finance-chart').innerHTML='Добавьте выручку или расходы — здесь появится график
';return}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(' ');byId('finance-chart').innerHTML='ДоходРасход
'}
+ 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('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(''):'| Операций пока нет |
'}
+ document.querySelectorAll('.nav-item').forEach(button=>button.addEventListener('click',()=>show(button.dataset.target)));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);
+ 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='Скопировано'});
+ const observer=new MutationObserver(()=>{if(role!=='manager')return;document.querySelectorAll('.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()});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 portalManifest = JSON.stringify({ name: 'Krona Staff', short_name: 'Staff', start_url: '/staff', display: 'standalone', background_color: '#F3EAD7', theme_color: '#657255', lang: 'ru' });
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 = portalServiceWorker.replace('krona-staff-v3', 'krona-staff-v4');
diff --git a/src/portal.ts b/src/portal.ts
index 9fb8f63..52d66aa 100644
--- a/src/portal.ts
+++ b/src/portal.ts
@@ -1,3 +1,4 @@
+import { createHash, randomBytes } from 'node:crypto';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import type { AppConfig } from './config.js';
import type { RequestContext } from './core/context.js';
@@ -6,6 +7,7 @@ import type { IdentityPort } from './core/ports.js';
import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
import type { Database } from './infrastructure/database.js';
import { reconcileWorkSession } from './domain/attendance/work-session.js';
+import { registerOperationsRoutes } from './operations.js';
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
@@ -68,9 +70,10 @@ export async function registerPortalRoutes(
config: AppConfig,
database: Database,
) {
- const identity: IdentityPort = config.STAFF_RUNTIME_MODE === 'platform'
+ const baseIdentity: IdentityPort = config.STAFF_RUNTIME_MODE === 'platform'
? new PlatformIdentityAdapter()
: new StandaloneIdentityAdapter(config);
+ const identity = withStandaloneEmployeeAccess(baseIdentity, config, database);
api.post<{ Body: SessionBody }>('/session', {
schema: {
@@ -86,13 +89,16 @@ export async function registerPortalRoutes(
return reply.serviceUnavailable('Вход администратора ещё не настроен.');
}
const context = await resolveToken(identity, request.body.access_token, request);
- if (!context.permissions.has('staff:attendance:manage')) {
- return reply.forbidden('Для входа требуется ключ администратора.');
+ const role = context.permissions.has('staff:attendance:manage') ? 'manager'
+ : context.permissions.has('staff:attendance:read_own') ? 'employee'
+ : null;
+ if (!role) {
+ return reply.forbidden('Ключ не подходит для входа в кабинет.');
}
reply
.header('cache-control', 'no-store')
.header('set-cookie', serializeSessionCookie(request.body.access_token, sessionMaxAgeSeconds));
- return { role: 'manager', expires_in: sessionMaxAgeSeconds };
+ return { role, expires_in: sessionMaxAgeSeconds };
});
api.delete('/session', async (request, reply) => {
@@ -276,6 +282,63 @@ export async function registerPortalRoutes(
return reply.send(outcome.employee);
});
+ api.post<{ Params: { employeeId: string } }>('/employees/:employeeId/access-token', {
+ schema: {
+ params: { type: 'object', additionalProperties: false, required: ['employeeId'], properties: {
+ employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
+ } },
+ },
+ }, async (request, reply) => {
+ assertCookieMutationOrigin(request);
+ if (config.STAFF_RUNTIME_MODE !== 'standalone') {
+ return reply.conflict('В платформенном режиме доступом сотрудников управляет Core SSO.');
+ }
+ const context = await requirePermission(identity, request, 'staff:employee:write');
+ const employeeId = parseUlid(request.params.employeeId);
+ const accessToken = randomBytes(32).toString('base64url');
+ const tokenHash = hashAccessToken(accessToken);
+ const tokenHint = accessToken.slice(-6);
+ const saved = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
+ const employee = await client.query(
+ `SELECT id, core_user_id, display_name FROM staff_employees
+ WHERE tenant_id = $1 AND id = $2 AND status = 'active' FOR UPDATE`,
+ [context.tenantId, employeeId],
+ );
+ if (employee.rowCount === 0) return null;
+ const employeeRow = employee.rows[0] as {
+ core_user_id?: string | null;
+ display_name?: string;
+ } | undefined;
+ const subjectId = String(employeeRow?.core_user_id ?? newUlid());
+ await client.query(
+ `UPDATE staff_employees SET core_user_id = $3, updated_at = now()
+ WHERE tenant_id = $1 AND id = $2`,
+ [context.tenantId, employeeId, subjectId],
+ );
+ await client.query(
+ `INSERT INTO staff_standalone_employee_access
+ (id, tenant_id, employee_id, subject_id, token_hash, token_hint, created_by)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ ON CONFLICT (tenant_id, employee_id)
+ DO UPDATE SET subject_id = EXCLUDED.subject_id, token_hash = EXCLUDED.token_hash,
+ token_hint = EXCLUDED.token_hint, created_by = EXCLUDED.created_by,
+ rotated_at = now()`,
+ [newUlid(), context.tenantId, employeeId, subjectId, tokenHash, tokenHint, context.actorId],
+ );
+ await client.query(
+ `INSERT INTO staff_audit_log
+ (id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id)
+ VALUES ($1, $2, $3, 'employee.access.rotated', 'Employee', $4, $5, $6)`,
+ [newUlid(), context.tenantId, context.actorId, employeeId,
+ { subject_id: subjectId, token_hint: tokenHint }, context.correlationId],
+ );
+ return { employee_id: employeeId, display_name: employeeRow?.display_name,
+ subject_id: subjectId, token_hint: tokenHint };
+ });
+ if (!saved) return reply.notFound('Активный сотрудник не найден.');
+ return reply.send({ ...saved, access_token: accessToken });
+ });
+
api.post<{ Body: AttendanceBody }>('/attendance/events', {
schema: {
body: {
@@ -637,6 +700,59 @@ export async function registerPortalRoutes(
return reply.send(row);
},
);
+
+ await registerOperationsRoutes(api, database, {
+ resolve: (request) => resolveContext(identity, request),
+ require: (request, permission) => requirePermission(identity, request, permission),
+ assertMutationOrigin: assertCookieMutationOrigin,
+ });
+}
+
+function withStandaloneEmployeeAccess(
+ baseIdentity: IdentityPort,
+ config: AppConfig,
+ database: Database,
+): IdentityPort {
+ if (config.STAFF_RUNTIME_MODE !== 'standalone' || !config.STANDALONE_MANAGER_TOKEN) {
+ return baseIdentity;
+ }
+ return {
+ async resolve(token, correlationId) {
+ try {
+ return await baseIdentity.resolve(token, correlationId);
+ } catch (baseError) {
+ if (!token || token.length < 32) throw baseError;
+ const tenantId = parseUlid(config.STANDALONE_TENANT_ID);
+ const serviceActorId = parseUlid(config.STANDALONE_ACTOR_ID);
+ const tokenHash = hashAccessToken(token);
+ const row = await database.tenantTransaction(tenantId, serviceActorId, async (client) => {
+ const result = await client.query(
+ `SELECT subject_id
+ FROM staff_standalone_employee_access
+ WHERE tenant_id = $1 AND token_hash = $2`,
+ [tenantId, tokenHash],
+ );
+ return result.rows[0] as { subject_id?: string } | undefined;
+ });
+ if (!row?.subject_id) throw baseError;
+ return {
+ tenantId,
+ actorId: parseUlid(row.subject_id),
+ permissions: new Set([
+ 'staff:health:read',
+ 'staff:attendance:read_own',
+ 'staff:shift:read',
+ 'staff:payslip:read_own',
+ ]),
+ correlationId,
+ };
+ }
+ },
+ };
+}
+
+function hashAccessToken(token: string): string {
+ return createHash('sha256').update(token, 'utf8').digest('hex');
}
async function resolveContext(identity: IdentityPort, request: FastifyRequest): Promise {
diff --git a/src/version.ts b/src/version.ts
index 1bffbe6..6359178 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
export const SERVICE_NAME = 'krona-staff';
-export const SERVICE_VERSION = '0.2.5';
+export const SERVICE_VERSION = '0.3.0';
diff --git a/tests/integration/health.test.ts b/tests/integration/health.test.ts
index aad256c..7012dcd 100644
--- a/tests/integration/health.test.ts
+++ b/tests/integration/health.test.ts
@@ -24,7 +24,7 @@ describe('operational endpoints', () => {
mode: 'standalone',
});
expect((await app.inject('/live')).statusCode).toBe(404);
- expect((await app.inject('/api/v1/staff/payroll')).statusCode).toBe(404);
+ expect((await app.inject('/api/v1/staff/not-a-route')).statusCode).toBe(404);
await app.close();
});