From 331e4909c9305fd2ea90b82c7992765bc5c8aabe Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Sat, 26 Sep 2026 14:13:21 +0700 Subject: [PATCH] feat(portal): rebuild staff operational workspace --- src/portal-design.ts | 10 +++ src/portal-ui.ts | 69 ++++++++++++++----- src/portal-workspace.ts | 129 +++++++++++++++++++++++++++++++++++ tests/unit/portal-ui.test.ts | 21 ++++-- 4 files changed, 205 insertions(+), 24 deletions(-) create mode 100644 src/portal-workspace.ts diff --git a/src/portal-design.ts b/src/portal-design.ts index c5f69c1..1ba1036 100644 --- a/src/portal-design.ts +++ b/src/portal-design.ts @@ -17,5 +17,15 @@ main{width:min(100%,1480px);margin:0 auto;padding:var(--omni-space-5)}.app-view{ .auth-screen{min-height:100vh;display:grid;place-items:center;padding:var(--omni-space-6);background:var(--omni-bg-workspace)}.auth-card{width:min(100%,460px);padding:var(--omni-space-8);border:1px solid var(--omni-border);border-radius:var(--omni-radius-xl);background:var(--omni-surface-raised);box-shadow:var(--omni-shadow-strong)}.auth-brand{display:flex;align-items:center;gap:var(--omni-space-3);margin-bottom:var(--omni-space-8);font-size:1.2rem}.auth-card h1{margin:var(--omni-space-1) 0 var(--omni-space-3);font-size:1.875rem}.auth-copy{margin:0 0 var(--omni-space-5);color:var(--omni-text-secondary)}.auth-card form{display:grid;gap:var(--omni-space-2)}.auth-card label{font-size:var(--omni-text-label);font-weight:700}.auth-card input,.auth-card button{width:100%;min-height:48px}.auth-card small{display:block;margin-top:var(--omni-space-4);color:var(--omni-text-secondary)}@keyframes view-enter{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}} @media(max-width:900px){:root{--omni-control-height:44px;--omni-control-height-sm:40px;--omni-row-height:48px}.topbar{align-items:flex-start;flex-wrap:wrap;padding:var(--omni-space-3) var(--omni-space-4)}.actions{width:100%;justify-content:flex-start;overflow-x:auto}.section-nav{top:108px;padding-inline:var(--omni-space-4)}.period-control{margin-left:var(--omni-space-2)}main{padding:var(--omni-space-4)}.layout,.finance-layout{grid-template-columns:1fr}.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.inline-form{grid-template-columns:repeat(2,minmax(0,1fr))}.inline-form button{grid-column:1/-1}.timesheet-create{grid-template-columns:repeat(2,minmax(0,1fr))}.timesheet-create .grow,.timesheet-create button{grid-column:1/-1}} @media(max-width:600px){.status,.theme-control{display:none}.section-nav{top:112px}.nav-item{min-height:var(--omni-touch-target)}main{padding:var(--omni-space-3)}.hero,.view-head{display:block}.hero{padding:var(--omni-space-4)}.today{margin-top:var(--omni-space-4);padding:var(--omni-space-3) 0 0;border-left:0;border-top:1px solid var(--omni-border)}.view-head button,.view-head .actions{margin-top:var(--omni-space-3)}.stats{grid-template-columns:1fr}.panel{padding:var(--omni-space-3)}.event{grid-template-columns:36px minmax(0,1fr)}.event time{grid-column:2}.person{grid-template-columns:36px minmax(0,1fr)}.person button{grid-column:2;width:100%}.directory-toolbar{align-items:stretch;flex-direction:column}.timesheet-create,.inline-form,.form-row-2{grid-template-columns:1fr}.calendar-weekdays,.calendar-grid{min-width:840px;grid-template-columns:repeat(7,120px)}.employee-dialog form{padding:var(--omni-space-4)}} +/* Workspace composition follows the supplied Krona Staff layouts while every visual value stays on Krona Library tokens. */ +.platform-bar{min-height:44px;display:grid;grid-template-columns:auto minmax(280px,560px) auto;align-items:center;gap:var(--omni-space-4);padding:var(--omni-space-1) var(--omni-space-5);background:var(--omni-surface-raised);border-bottom:1px solid var(--omni-border-subtle)}.platform-context,.platform-actions{display:flex;align-items:center;gap:var(--omni-space-2);white-space:nowrap}.platform-context{color:var(--omni-text-secondary)}.platform-context strong{color:var(--omni-primary)}.app-grid{min-width:34px;min-height:34px;padding:0;background:transparent;border-color:transparent;color:var(--omni-primary);font-size:1.25rem}.global-search{min-height:34px;display:flex;align-items:center;gap:var(--omni-space-2);padding:0 var(--omni-space-3);border:1px solid var(--omni-border-subtle);border-radius:var(--omni-radius-sm);background:var(--omni-surface-muted);color:var(--omni-text-muted)}.global-search input{width:100%;min-height:30px;padding:0;border:0;background:transparent}.platform-actions{justify-content:flex-end}.module-bar{position:sticky;top:0;z-index:var(--omni-z-sticky);min-height:62px;display:flex;align-items:center;gap:var(--omni-space-5);padding:0 var(--omni-space-5);background:color-mix(in srgb,var(--omni-surface-raised) 96%,transparent);border-bottom:1px solid var(--omni-border-subtle);backdrop-filter:blur(14px)}.module-bar .brand{flex:0 0 auto}.module-bar .brand>span:last-child{display:grid}.module-bar .brand small{color:var(--omni-text-secondary);font-size:.67rem;font-weight:500}.module-bar .section-nav{position:static;flex:1;padding:0;background:transparent;border:0}.module-bar .nav-item{min-height:38px;padding-inline:var(--omni-space-2);font-size:var(--omni-text-label)}.module-bar .nav-item>span{font-size:1rem} +main{width:min(100%,1720px);padding:var(--omni-space-4) var(--omni-space-5) var(--omni-space-8)}.page-title-row{min-height:62px;display:flex;align-items:center;justify-content:space-between;gap:var(--omni-space-4);margin-bottom:var(--omni-space-3)}.page-title-row h1{margin:0;font-size:clamp(1.55rem,2vw,2rem);letter-spacing:-.025em}.page-title-row p{margin:var(--omni-space-1) 0 0;color:var(--omni-text-secondary)}.page-context{display:grid;justify-items:end;color:var(--omni-text-secondary)}.page-context small{color:var(--omni-text-muted)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.text-action{min-height:32px;padding:0;border:0;background:transparent;color:var(--omni-primary)}.text-action:hover{background:transparent;color:var(--omni-primary-hover)} +.metric-strip{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:var(--omni-space-3);margin-bottom:var(--omni-space-3)}.metric-strip article{min-height:106px;display:flex;align-items:center;gap:var(--omni-space-3);padding:var(--omni-space-4);border:1px solid var(--omni-border-subtle);border-radius:var(--omni-radius-lg);background:var(--omni-surface-raised);box-shadow:var(--omni-shadow-soft)}.metric-strip small,.metric-strip span{display:block;color:var(--omni-text-secondary)}.metric-strip strong{display:block;margin:var(--omni-space-1) 0;font-size:1.55rem;font-variant-numeric:tabular-nums}.metric-icon{width:46px;height:46px;flex:0 0 auto;display:grid!important;place-items:center;border-radius:var(--omni-radius-pill);background:var(--omni-primary-soft);color:var(--omni-primary)!important;font-size:1.25rem;font-weight:750}.metric-icon.danger{background:var(--omni-danger-soft);color:var(--omni-danger)!important}.compact-metrics{grid-template-columns:repeat(3,minmax(0,1fr))}.compact-metrics article{min-height:84px;padding-block:var(--omni-space-3)} +.dashboard-grid{display:grid;grid-template-columns:minmax(420px,1.45fr) minmax(380px,1.15fr) minmax(290px,.8fr);grid-template-areas:"people attendance terminal" "payroll analytics alerts";gap:var(--omni-space-3)}.dashboard-grid>.panel{min-width:0}.dashboard-people{grid-area:people}.dashboard-attendance{grid-area:attendance}.dashboard-terminal{grid-area:terminal}.dashboard-payroll{grid-area:payroll}.dashboard-analytics{grid-area:analytics}.dashboard-alerts{grid-area:alerts}.dense-list,.alert-list{display:grid;gap:var(--omni-space-1)}.dense-row,.alert-row{min-height:46px;display:grid;grid-template-columns:34px minmax(0,1fr) auto;align-items:center;gap:var(--omni-space-2);padding:var(--omni-space-1) var(--omni-space-2);border-bottom:1px solid var(--omni-border-subtle)}.dense-row:last-child,.alert-row:last-child{border-bottom:0}.dense-row strong,.dense-row small,.alert-row strong,.alert-row small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dense-row small,.alert-row small,.dense-row time{color:var(--omni-text-secondary);font-size:var(--omni-text-label)}.alert-mark{width:28px;height:28px;display:grid;place-items:center;border-radius:var(--omni-radius-pill);background:var(--omni-warning-soft);color:var(--omni-warning);font-weight:800}.terminal-device{display:flex;align-items:center;gap:var(--omni-space-4);padding:var(--omni-space-4);margin-bottom:var(--omni-space-3);background:var(--omni-surface-muted);border-radius:var(--omni-radius-md)}.terminal-device strong,.terminal-device small{display:block}.terminal-device small{color:var(--omni-text-secondary)}.terminal-glyph{width:58px;height:72px;display:grid;place-items:center;border-radius:var(--omni-radius-sm);background:var(--omni-text-primary);color:var(--omni-success);font-size:1.8rem}.terminal-glyph.large{width:88px;height:112px;font-size:2.5rem}.dashboard-terminal dl,.terminal-card dl,.system-list,.inspector-list,.summary-dl{display:grid;gap:var(--omni-space-2);margin:0 0 var(--omni-space-3)}.dashboard-terminal dl div,.terminal-card dl div,.system-list div,.inspector-list div,.summary-dl div{display:flex;justify-content:space-between;gap:var(--omni-space-3);padding-bottom:var(--omni-space-2);border-bottom:1px solid var(--omni-border-subtle)}dl dt{color:var(--omni-text-secondary)}dl dd{margin:0;text-align:right;font-weight:650}.dashboard-totals{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--omni-space-2)}.dashboard-totals>div{padding:var(--omni-space-3);background:var(--omni-surface-muted);border-radius:var(--omni-radius-md)}.dashboard-totals small,.dashboard-totals strong{display:block}.rule-chips{display:flex;flex-wrap:wrap;gap:var(--omni-space-1);margin-top:var(--omni-space-3)}.mini-chart svg{width:100%;height:150px}.mini-chart .chart-legend{margin-top:var(--omni-space-2)}.quick-bar{position:sticky;bottom:var(--omni-space-2);z-index:5;display:flex;align-items:center;gap:var(--omni-space-2);margin-top:var(--omni-space-3);padding:var(--omni-space-2) var(--omni-space-3);border:1px solid var(--omni-border);border-radius:var(--omni-radius-lg);background:color-mix(in srgb,var(--omni-surface-raised) 96%,transparent);box-shadow:var(--omni-shadow-medium);backdrop-filter:blur(12px)} +.employee-workspace{display:grid;grid-template-columns:minmax(620px,1fr) minmax(300px,360px);gap:var(--omni-space-3)}.employee-list-panel{min-width:0}.directory-list{display:grid;gap:0}.directory-list .person{min-height:58px;grid-template-columns:38px minmax(180px,1fr) auto auto;border-width:0 0 1px;border-radius:0;background:transparent;cursor:pointer}.directory-list .person.selected{background:var(--omni-surface-selected);box-shadow:inset 3px 0 var(--omni-primary)}.inspector{position:sticky;top:78px;align-self:start}.inspector-head{display:flex;align-items:center;gap:var(--omni-space-3);padding-bottom:var(--omni-space-4);border-bottom:1px solid var(--omni-border-subtle)}.inspector-head h2{margin:0 0 var(--omni-space-2)}.avatar.large{width:64px;height:64px;font-size:1.35rem}.inspector-list{margin-top:var(--omni-space-4)}.inspector-actions{display:grid;grid-template-columns:1fr 1fr;gap:var(--omni-space-2)} +.segmented{display:inline-flex;align-items:center;padding:2px;border-radius:var(--omni-radius-sm);background:var(--omni-surface-muted)}.segmented button{min-height:34px;background:transparent;border-color:transparent;color:var(--omni-text-secondary)}.segmented button.selected{background:var(--omni-primary);border-color:var(--omni-primary);color:var(--omni-text-inverse)}.attendance-workspace{display:grid;grid-template-columns:minmax(600px,1.5fr) minmax(280px,.55fr);gap:var(--omni-space-3)}.attendance-workspace .event{border-width:0 0 1px;border-radius:0;background:transparent}.summary-dl dd{font-size:1.15rem}.finance-workspace{display:grid;grid-template-columns:minmax(680px,1.5fr) minmax(340px,.65fr);gap:var(--omni-space-3)}.finance-side{display:grid;align-content:start;gap:var(--omni-space-3)}.payroll-workspace{display:grid;grid-template-columns:minmax(720px,1.4fr) minmax(420px,.8fr);gap:var(--omni-space-3)}.payroll-workspace>.panel{min-width:0}.payroll-detail table{min-width:620px}.payroll-row{cursor:pointer}.rules-workspace,.terminal-workspace,.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--omni-space-3)}.rule-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--omni-space-2)}.rule-card{display:grid;grid-template-columns:46px minmax(0,1fr) auto;align-items:center;gap:var(--omni-space-3);padding:var(--omni-space-3);border:1px solid var(--omni-border-subtle);border-radius:var(--omni-radius-md);background:var(--omni-surface)}.rule-card strong,.rule-card small{display:block}.rule-card small{color:var(--omni-text-secondary)}.terminal-card{display:flex;align-items:flex-start;gap:var(--omni-space-5)}.terminal-card h2{margin-top:0}.settings-field{display:grid;gap:var(--omni-space-2);color:var(--omni-text-secondary)}.calendar-legend{display:flex;justify-content:flex-end;gap:var(--omni-space-4);margin-bottom:var(--omni-space-2);color:var(--omni-text-secondary);font-size:var(--omni-text-label)}.calendar-legend i{width:9px;height:9px;display:inline-block;margin-right:var(--omni-space-1);border-radius:var(--omni-radius-pill);background:var(--omni-primary)}.calendar-legend .actual{background:var(--omni-info)}.calendar-legend .warning{background:var(--omni-warning)}.schedule-tabs{margin-bottom:var(--omni-space-3)} +@media(max-width:1380px){.module-bar{align-items:flex-start;flex-direction:column;gap:0;padding-top:var(--omni-space-2)}.module-bar .section-nav{width:100%}.dashboard-grid{grid-template-columns:1fr 1fr;grid-template-areas:"people attendance" "payroll analytics" "terminal alerts"}.metric-strip{grid-template-columns:repeat(3,minmax(0,1fr))}.employee-workspace{grid-template-columns:minmax(560px,1fr) 320px}.finance-workspace,.payroll-workspace{grid-template-columns:1fr}.payroll-detail{max-height:440px}} +@media(max-width:900px){.platform-bar{grid-template-columns:1fr auto}.global-search{grid-column:1/-1;grid-row:2}.module-bar{position:static}.module-bar .section-nav{top:auto}.dashboard-grid{grid-template-columns:1fr;grid-template-areas:"people" "attendance" "terminal" "payroll" "analytics" "alerts"}.metric-strip,.compact-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.employee-workspace,.attendance-workspace,.rules-workspace,.terminal-workspace,.settings-grid{grid-template-columns:1fr}.inspector{position:static}.quick-bar{position:static;flex-wrap:wrap}.finance-workspace{grid-template-columns:1fr}.rule-grid{grid-template-columns:1fr}} +@media(max-width:600px){.platform-context span,.platform-context b,.platform-actions .status{display:none}.platform-bar{padding-inline:var(--omni-space-3)}.module-bar{padding-inline:var(--omni-space-3)}.module-bar .brand{display:none}.page-title-row{align-items:flex-start;flex-direction:column}.page-title-row .actions{width:100%;overflow-x:auto}.metric-strip,.compact-metrics{grid-template-columns:1fr}.metric-strip article{min-height:82px}.dashboard-totals{grid-template-columns:1fr}.employee-workspace{display:block}.inspector{margin-top:var(--omni-space-3)}.directory-toolbar select{width:100%}.directory-list .person{grid-template-columns:36px minmax(0,1fr)}.directory-list .person .tag,.directory-list .person button{grid-column:2}.inspector-actions,.rule-grid{grid-template-columns:1fr}.terminal-card{display:grid}.quick-bar>*{width:100%}} @media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;transition-duration:.01ms!important}} `; diff --git a/src/portal-ui.ts b/src/portal-ui.ts index ed48987..3d778f7 100644 --- a/src/portal-ui.ts +++ b/src/portal-ui.ts @@ -1,6 +1,7 @@ import { portalDesignCss } from './portal-design.js'; +import { portalWorkspaceHtml } from './portal-workspace.js'; -export const portalHtml = ` +const legacyPortalHtml = ` @@ -99,6 +100,9 @@ export const portalHtml = ` `; +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 = `(() => { @@ -113,8 +117,8 @@ export const portalJs = `(() => { 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)+'
'+(viewerRole==='manager'?'':'')+'
').join(''):'
Сотрудники не найдены
'; + 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?'':'')+'';} @@ -172,7 +176,7 @@ export const portalJsEnhanced = ` (() => { 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; + 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()} @@ -188,21 +192,23 @@ export const portalJsEnhanced = if(byId('app-shell').hidden||initialized)return; initialized=true; try{ - const state=await get('/portal'); + const state=await get('/portal');staffState=state; role=state.viewer&&state.viewer.role||'manager'; - if(role!=='manager'&&active==='employees-view')show('overview-view',false); + 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/finance':'finance-view','/staff/schedules':'schedule-view','/staff/payroll':'payroll-view'}; + 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(target==='employees-view'&&role!=='manager')target='overview-view'; + 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)); @@ -210,11 +216,30 @@ export const portalJsEnhanced = if(updateHistory&&button?.dataset.route&&location.pathname!==button.dataset.route)history.pushState({view:target},'',button.dataset.route); 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 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('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(''):'Операций пока нет'} + 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); @@ -222,18 +247,26 @@ export const portalJsEnhanced = 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'); - people.forEach(person=>person.hidden=query&&!person.textContent.toLocaleLowerCase('ru-RU').includes(query)); + 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?'Найдено: '+visible:'Все сотрудники'; + byId('employee-search-result').textContent=query||status?'Найдено: '+visible:'Все сотрудники'; byId('directory-total').textContent=people.length; byId('directory-active').textContent=visible; - byId('directory-attention').textContent='—'; + 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 edit=event.target.closest('.edit-employee');if(edit)byId('employees').querySelector('.edit-employee[data-id="'+edit.dataset.id+'"]').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='Скопировано'}); @@ -355,7 +388,7 @@ export const portalJsEnhanced = 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(''):'
Профессии ещё не добавлены
'; + 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); diff --git a/src/portal-workspace.ts b/src/portal-workspace.ts new file mode 100644 index 0000000..1c71519 --- /dev/null +++ b/src/portal-workspace.ts @@ -0,0 +1,129 @@ +export const portalWorkspaceHtml = ` + + + + + + Krona Staff + + + + + + +
+
+
КKrona Staff
+

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

+

Вход в Krona Staff

+

Введите ключ руководителя или личный ключ сотрудника.

+
+ + Сессия действует 8 часов и хранится только в защищённой cookie. +
+
+ + + + +`; diff --git a/tests/unit/portal-ui.test.ts b/tests/unit/portal-ui.test.ts index c9dfdd8..e7849bc 100644 --- a/tests/unit/portal-ui.test.ts +++ b/tests/unit/portal-ui.test.ts @@ -3,12 +3,19 @@ import { describe, expect, it } from 'vitest'; import { portalCssEnhanced, portalHtml, portalJsEnhanced, portalManifest } from '../../src/portal-ui.js'; describe('Krona Staff workspace shell', () => { - it('exposes the five approved top-level sections with deep routes', () => { - expect(portalHtml).toContain('data-route="/staff/dashboard">Дашборд'); - expect(portalHtml).toContain('data-route="/staff/employees">Сотрудники'); - expect(portalHtml).toContain('data-route="/staff/finance">Финансы'); - expect(portalHtml).toContain('data-route="/staff/schedules">Графики'); - expect(portalHtml).toContain('data-route="/staff/payroll">Зарплата'); + it('exposes the operational workspace sections with deep routes', () => { + expect(portalHtml).toContain('data-route="/staff/dashboard"'); + expect(portalHtml).toContain('data-route="/staff/employees"'); + expect(portalHtml).toContain('data-route="/staff/finance"'); + expect(portalHtml).toContain('data-route="/staff/schedules"'); + expect(portalHtml).toContain('data-route="/staff/payroll"'); + expect(portalHtml).toContain('data-route="/staff/attendance">'); + expect(portalHtml).toContain('data-route="/staff/shifts">'); + expect(portalHtml).toContain('data-route="/staff/rules">'); + expect(portalHtml).toContain('data-route="/staff/terminal">'); + expect(portalHtml).toContain('id="employee-inspector"'); + expect(portalHtml).toContain('id="finance-entries"'); + expect(portalHtml).toContain('id="calendar-grid"'); expect(portalHtml).not.toContain('