| '+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 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;}}));}
+ 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);
+})();`;
+
+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-v1';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)));});`;
diff --git a/src/portal.ts b/src/portal.ts
new file mode 100644
index 0000000..151ffdb
--- /dev/null
+++ b/src/portal.ts
@@ -0,0 +1,295 @@
+import type { FastifyInstance, FastifyRequest } from 'fastify';
+import type { AppConfig } from './config.js';
+import type { RequestContext } from './core/context.js';
+import { newUlid, parseUlid } from './core/ids.js';
+import type { IdentityPort } from './core/ports.js';
+import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
+import type { Database } from './infrastructure/database.js';
+
+const datePattern = /^\d{4}-\d{2}-\d{2}$/;
+
+interface AttendanceBody {
+ event_id?: string;
+ employee_id: string;
+ event_type: 'check_in' | 'check_out';
+ occurred_at: string;
+ source_event_id: string;
+ device_id?: string;
+}
+
+interface TimesheetBody {
+ minutes_worked: number;
+ status: 'draft' | 'submitted' | 'approved' | 'rejected';
+ note?: string;
+}
+
+interface SessionBody {
+ access_token: string;
+}
+
+const sessionCookieName = '__Host-krona_staff_session';
+const sessionMaxAgeSeconds = 8 * 60 * 60;
+
+export async function registerPortalRoutes(
+ api: FastifyInstance,
+ config: AppConfig,
+ database: Database,
+) {
+ const identity: IdentityPort = config.STAFF_RUNTIME_MODE === 'platform'
+ ? new PlatformIdentityAdapter()
+ : new StandaloneIdentityAdapter(config);
+
+ api.post<{ Body: SessionBody }>('/session', {
+ schema: {
+ body: {
+ type: 'object', additionalProperties: false,
+ required: ['access_token'],
+ properties: { access_token: { type: 'string', minLength: 32, maxLength: 4096 } },
+ },
+ },
+ }, async (request, reply) => {
+ assertSameOrigin(request);
+ if (config.STAFF_RUNTIME_MODE === 'standalone' && !config.STANDALONE_MANAGER_TOKEN) {
+ return reply.serviceUnavailable('Вход администратора ещё не настроен.');
+ }
+ const context = await resolveToken(identity, request.body.access_token, request);
+ if (!context.permissions.has('staff:attendance:manage')) {
+ return reply.forbidden('Для входа требуется ключ администратора.');
+ }
+ reply
+ .header('cache-control', 'no-store')
+ .header('set-cookie', serializeSessionCookie(request.body.access_token, sessionMaxAgeSeconds));
+ return { role: 'manager', expires_in: sessionMaxAgeSeconds };
+ });
+
+ api.delete('/session', async (request, reply) => {
+ assertSameOrigin(request);
+ reply
+ .header('cache-control', 'no-store')
+ .header('set-cookie', serializeSessionCookie('', 0));
+ return reply.code(204).send();
+ });
+
+ api.get('/portal', async (request, reply) => {
+ reply.header('cache-control', 'no-store');
+ const context = await resolveContext(identity, request);
+ const canManage = context.permissions.has('staff:attendance:manage');
+ if (!canManage && !context.permissions.has('staff:attendance:read_own')) {
+ return reply.forbidden('Недостаточно прав для просмотра посещаемости.');
+ }
+
+ return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
+ const employees = await client.query(
+ `SELECT e.id, e.core_user_id, e.personnel_number, e.display_name, e.position, e.gender, e.status
+ FROM staff_employees e
+ WHERE e.status <> 'dismissed' AND ($1::boolean OR e.core_user_id = $2)
+ ORDER BY e.display_name`,
+ [canManage, context.actorId],
+ );
+ const attendance = await client.query(
+ `SELECT a.id, a.employee_id, e.display_name, a.event_type, a.source,
+ a.occurred_at, a.received_at, a.status
+ FROM staff_attendance_events a
+ JOIN staff_employees e ON e.tenant_id = a.tenant_id AND e.id = a.employee_id
+ WHERE ($1::boolean OR e.core_user_id = $2)
+ ORDER BY a.occurred_at DESC LIMIT 100`,
+ [canManage, context.actorId],
+ );
+ const timesheet = await client.query(
+ `SELECT t.id, t.employee_id, e.display_name, t.work_date, t.minutes_worked,
+ t.status, t.note, t.revision, t.updated_at
+ FROM staff_timesheet_entries t
+ JOIN staff_employees e ON e.tenant_id = t.tenant_id AND e.id = t.employee_id
+ WHERE t.work_date >= (CURRENT_DATE - INTERVAL '40 days')
+ AND ($1::boolean OR e.core_user_id = $2)
+ ORDER BY t.work_date DESC, e.display_name`,
+ [canManage, context.actorId],
+ );
+ return {
+ viewer: { actor_id: context.actorId, role: canManage ? 'manager' : 'employee' },
+ employees: employees.rows,
+ attendance: attendance.rows,
+ timesheet: timesheet.rows,
+ refreshed_at: new Date().toISOString(),
+ };
+ });
+ });
+
+ api.post<{ Body: AttendanceBody }>('/attendance/events', {
+ schema: {
+ body: {
+ type: 'object', additionalProperties: false,
+ required: ['employee_id', 'event_type', 'occurred_at', 'source_event_id'],
+ properties: {
+ event_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
+ employee_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
+ event_type: { enum: ['check_in', 'check_out'] },
+ occurred_at: { type: 'string', format: 'date-time' },
+ source_event_id: { type: 'string', minLength: 1, maxLength: 200 },
+ device_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
+ },
+ },
+ },
+ }, async (request, reply) => {
+ const context = await requirePermission(identity, request, 'staff:attendance:clock');
+ const eventId = request.body.event_id ? parseUlid(request.body.event_id) : newUlid();
+ const employeeId = parseUlid(request.body.employee_id);
+ const deviceId = request.body.device_id ? parseUlid(request.body.device_id) : null;
+ const occurredAt = new Date(request.body.occurred_at);
+ if (Number.isNaN(occurredAt.valueOf())) return reply.badRequest('Некорректное время отметки.');
+ const now = Date.now();
+ if (occurredAt.valueOf() > now + 5 * 60_000 || occurredAt.valueOf() < now - 180 * 86_400_000) {
+ return reply.badRequest('Время отметки выходит за допустимый период пилота.');
+ }
+
+ const result = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
+ await client.query(
+ `INSERT INTO staff_employees
+ (id, tenant_id, personnel_number, display_name, position, gender, status)
+ VALUES ($1, $2, $3, $4, '', '', 'active')
+ ON CONFLICT (id) DO NOTHING`,
+ [employeeId, context.tenantId, `pilot-${employeeId}`, `Сотрудник ${employeeId.slice(-6)}`],
+ );
+ const inserted = await client.query(
+ `INSERT INTO staff_attendance_events
+ (id, tenant_id, employee_id, event_type, source, source_event_id,
+ occurred_at, device_id, actor_id, correlation_id)
+ VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9)
+ ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING RETURNING id`,
+ [eventId, context.tenantId, employeeId, request.body.event_type,
+ request.body.source_event_id, occurredAt.toISOString(), deviceId,
+ context.actorId, context.correlationId],
+ );
+ if (inserted.rowCount === 0) return { id: eventId, duplicate: true };
+
+ const eventType = request.body.event_type === 'check_in'
+ ? 'attendance.clock.in'
+ : 'attendance.clock.out';
+ await client.query(
+ `INSERT INTO staff_outbox
+ (id, tenant_id, event_type, event_version, subject_type, subject_id,
+ actor_type, actor_id, payload, correlation_id, occurred_at)
+ VALUES ($1, $2, $3, 1, 'Employee', $4, 'service', $5, $6, $7, $8)`,
+ [newUlid(), context.tenantId, eventType, employeeId, context.actorId,
+ { attendance_event_id: eventId, channel: 'platform_notification' },
+ context.correlationId, occurredAt.toISOString()],
+ );
+ return { id: eventId, duplicate: false };
+ });
+ return reply.code(result.duplicate ? 200 : 201).send(result);
+ });
+
+ api.put<{ Params: { employeeId: string; workDate: string }; Body: TimesheetBody }>(
+ '/timesheets/:employeeId/:workDate',
+ {
+ schema: {
+ params: { type: 'object', required: ['employeeId', 'workDate'], properties: {
+ employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
+ workDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
+ } },
+ body: { type: 'object', additionalProperties: false,
+ required: ['minutes_worked', 'status'], properties: {
+ minutes_worked: { type: 'integer', minimum: 0, maximum: 1440 },
+ status: { enum: ['draft', 'submitted', 'approved', 'rejected'] },
+ note: { type: 'string', maxLength: 1000 },
+ } },
+ },
+ },
+ async (request, reply) => {
+ assertCookieMutationOrigin(request);
+ const context = await requirePermission(identity, request, 'staff:attendance:manage');
+ if (!datePattern.test(request.params.workDate)) return reply.badRequest('Некорректная дата.');
+ const employeeId = parseUlid(request.params.employeeId);
+ if (request.body.status === 'approved' && !context.permissions.has('staff:attendance:approve')) {
+ return reply.forbidden('Для утверждения табеля требуется отдельное право.');
+ }
+
+ const row = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
+ const saved = await client.query(
+ `INSERT INTO staff_timesheet_entries
+ (id, tenant_id, employee_id, work_date, minutes_worked, status, note, updated_by)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ ON CONFLICT (tenant_id, employee_id, work_date) DO UPDATE SET
+ minutes_worked = EXCLUDED.minutes_worked, status = EXCLUDED.status,
+ note = EXCLUDED.note, revision = staff_timesheet_entries.revision + 1,
+ updated_by = EXCLUDED.updated_by, updated_at = now()
+ RETURNING id, employee_id, work_date, minutes_worked, status, note, revision, updated_at`,
+ [newUlid(), context.tenantId, employeeId, request.params.workDate,
+ request.body.minutes_worked, request.body.status, request.body.note ?? '', context.actorId],
+ );
+ const result = saved.rows[0] as Record