*/varUr=Symbol.for("react.element"),zp=Symbol.for("react.portal"),Op=Symbol.for("react.fragment"),Dp=Symbol.for("react.strict_mode"),Lp=Symbol.for("react.profiler"),Fp=Symbol.for("react.provider"),Ip=Symbol.for("react.context"),Bp=Symbol.for("react.forward_ref"),Mp=Symbol.for("react.suspense"),Up=Symbol.for("react.memo"),$p=Symbol.for("react.lazy"),ha=Symbol.iterator;functionWp(e){returne===null||typeofe!="object"?null:(e=ha&&e[ha]||e["@@iterator"],typeofe=="function"?e:null)}varKu={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Yu=Object.assign,Xu={};functionqn(e,t,n){this.props=e,this.context=t,this.refs=Xu,this.updater=n||Ku}qn.prototype.isReactComponent={};qn.prototype.setState=function(e,t){if(typeofe!="object"&&typeofe!="function"&&e!=null)throwError("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};qn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};functionGu(){}Gu.prototype=qn.prototype;functionps(e,t,n){this.props=e,this.context=t,this.refs=Xu,this.updater=n||Ku}varfs=ps.prototype=newGu;fs.constructor=ps;Yu(fs,qn.prototype);fs.isPureReactComponent=!0;varma=Array.isArray,Ju=Object.prototype.hasOwnProperty,hs={current:null},Zu={key:!0,ref:!0,__self:!0,__source:!0};functionec(e,t,n){varr,o={},i=null,l=null;if(t!=null)for(rint.ref!==void0&&(l=t.ref),t.key!==void0&&(i=""+t.key),t)Ju.call(t,r)&&!Zu.hasOwnProperty(r)&&(o[r]=t[r]);vara=arguments.length-2;if(a===1)o.children=n;elseif(1<a){for(varu=Array(a),c=0;c<a;c++)u[c]=arguments[c+2];o.children=u}if(e&&e.defaultProps)for(rina=e.defaultProps,a)o[r]===void0&&(o[r]=a[r]);return{$$typeof:Ur,type:e,key:i,ref:l,props:o,_owner:hs.current}}functionHp(e,t){return{$$typeof:Ur,type:e.type,key:t,ref:e.ref,props:e.props,_owner:e._owner}}functionms(e){returntypeofe=="object"&&e!==null&&e.$$typeof===Ur}functionVp(e){vart={"=":"=0",":":"=2"};return"$"+e.replace(/[=:]/g,function(n){returnt[n]})}varga=/\/+/g;functionNi(e,t){returntypeofe=="object"&&e!==null&&e.key!=null?Vp(""+e.key):t.toString(36)}functionwo(e,t,n,r,o){vari=typeofe;(i==="undefined"||i==="boolean")&&(e=null);varl=!1;if(e===null)l=!0;elseswitch(i){case"string":case"number":l=!0;break;case"object":switch(e.$$typeof){caseUr:casezp:l=!0}}if(l)returnl=e,o=o(l),e=r===""?"."+Ni(l,0):r,ma(o)?(n="",e!=null&&(n=e.replace(ga,"$&/")+"/"),wo(o,t,n,"",function(c){returnc})):o!=null&&(ms(o)&&(o=Hp(o,n+(!o.key||l&&l.key===o.key?"":(""+o.key).replace(ga,"$&/")+"/")+e)),t.push(o)),1;if(l=0,r=r===""?".":r+":",ma(e))for(vara=0;a<e.length;a++){i=e[a];varu=r+Ni(i,a);l+=wo(i,t,n,u,o)}elseif(u=Wp(e),typeofu=="function")for(e=u.call(e),a=0;!(i=e.next()).done;)i=i.value,u=r+Ni(i,a++),l+=wo(i,t,n,u,o);elseif(i==="object")throwt=String(e),Error("Objects are not valid as a React child (found: "+(t==="[object Object]"?"object with keys {"+Object.keys(e).join(", ")+"}":t)+"). If you meant to render a collection of children, use an array instead.");returnl}functionZr(e,t,n){if(e==null)returne;varr=[],o=0;returnwo(e,r,"","",function(i){returnt.call(n,i,o++)}),r}functionQp(e){if(e._status===-1){vart=e._result;t=t(),t.then(function(n){(e._status===0||e._status===-1)&&(e._status=1,e._result=n)},function(n){(e._status===0||e._status===-1)&&(e._status=2,e._result=n)}),e._status===-1&&(e._status=0,e._result=t)}if(e._status===1)returne._result.default;throwe._result}varDe={current:null},So={transition:null},qp={ReactCurrentDispatcher:De,ReactCurrentBatchConfig:So,ReactCurrentOwner:hs};functiontc(){throwError("act(...) is not supported in production builds of React.")}q.Children={map:Zr,forEach:function(e,t,n){Zr(e,function(){t.apply(this,arguments)},n)},count:function(e){vart=0;returnZr(e,function(){t++}),t},toArray:function(e){returnZr(e,function(t){returnt})||[]},only:function(e){if(!ms(e))throwError("React.Children.only expected to receive a sing
`):" "+Ou(l[0]):"as no adapter specified";thrownewO("There is no suitable adapter to dispatch the request "+a,"ERR_NOT_SUPPORT")}returno}constkp={getAdapter:Vg,adapters:da};functionll(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)thrownewXr(null,e)}functionDu(e){returnll(e),e.headers=Oe.from(e.headers),e.data=il.call(e,e.transformRequest),["post","put","patch"].indexOf(e.method)!==-1&&e.headers.setContentType("application/x-www-form-urlencoded",!1),kp.getAdapter(e.adapter||Yr.adapter,e)(e).then(function(r){ll(e),e.response=r;try{r.data=il.call(e,e.transformResponse,r)}finally{deletee.response}returnr.headers=Oe.from(r.headers),r},function(r){if(!xp(r)&&(ll(e),r&&r.response)){e.response=r.response;try{r.response.data=il.call(e,e.transformResponse,r.response)}finally{deletee.response}r.response.headers=Oe.from(r.response.headers)}returnPromise.reject(r)})}constRi={};["object","boolean","number","function","string","symbol"].forEach((e,t)=>{Ri[e]=function(r){returntypeofr===e||"a"+(t<1?"n ":" ")+e}});constLu={};Ri.transitional=function(t,n,r){functiono(i,l){return"[Axios v"+ca+"] Transitional option '"+i+"'"+l+(r?". "+r:"")}return(i,l,a)=>{if(t===!1)thrownewO(o(l," has been removed"+(n?" in "+n:"")),O.ERR_DEPRECATED);returnn&&!Lu[l]&&(Lu[l]=!0,console.warn(o(l," has been deprecated since v"+n+" and will be removed in the near future"))),t?t(i,l,a):!0}};Ri.spelling=function(t){return(n,r)=>(console.warn(`${r} is likely a misspelling of ${t}`),!0)};functionQg(e,t,n){if(typeofe!="object")thrownewO("options must be an object",O.ERR_BAD_OPTION_VALUE);constr=Object.keys(e);leto=r.length;for(;o-->0;){consti=r[o],l=Object.prototype.hasOwnProperty.call(t,i)?t[i]:void0;if(l){consta=e[i],u=a===void0||l(a,i,e);if(u!==!0)thrownewO("option "+i+" must be "+u,O.ERR_BAD_OPTION_VALUE);continue}if(n!==!0)thrownewO("Unknown option "+i,O.ERR_BAD_OPTION)}}constOo={assertOptions:Qg,validators:Ri},Ze=Oo.validators;letun=class{constructor(t){this.defaults=t||{},this.interceptors={request:new_u,response:new_u}}asyncrequest(t,n){try{returnawaitthis._request(t,n)}catch(r){if(rinstanceofError){leto={};Error.captureStackTrace?Error.captureStackTrace(o):o=newError;consti=(()=>{if(!o.stack)return"";constl=o.stack.indexOf(`
`+i)}}catch{}}throwr}}_request(t,n){typeoft=="string"?(n=n||{},n.url=t):n=t||{},n=mn(this.defaults,n);const{transitional:r,paramsSerializer:o,headers:i}=n;r!==void0&&Oo.assertOptions(r,{silentJSONParsing:Ze.transitional(Ze.boolean),forcedJSONParsing:Ze.transitional(Ze.boolean),clarifyTimeoutError:Ze.transitional(Ze.boolean),legacyInterceptorReqResOrdering:Ze.transitional(Ze.boolean)},!1),o!=null&&(w.isFunction(o)?n.paramsSerializer={serialize:o}:Oo.assertOptions(o,{encode:Ze.function,serialize:Ze.function},!0)),n.allowAbsoluteUrls!==void0||(this.defaults.allowAbsoluteUrls!==void0?n.allowAbsoluteUrls=this.defaults.allowAbsoluteUrls:n.allowAbsoluteUrls=!0),Oo.assertOptions(n,{baseUrl:Ze.spelling("baseURL"),withXsrfToken:Ze.spelling("withXSRFToken")},!0),n.method=(n.method||this.defaults.method||"get").toLowerCase();letl=i&&w.merge(i.common,i[n.method]);i&&w.forEach(["delete","get","head","post","put","patch","query","common"],x=>{deletei[x]}),n.headers=Oe.concat(l,i);consta=[];letu=!0;this.interceptors.request.forEach(function(v){if(typeofv.runWhen=="function"&&v.runWhen(n)===!1)return;u=u&&v.synchronous;constg=n.transitional||aa;g&&g.legacyInterceptorReqResOrdering?a.unshift(v.fulfilled,v.rejected):a.push(v.fulfilled,v.rejected)});constc=[];this.interceptors.response.forEach(function(v){c.push(v.fulfilled,v.rejected)});letf,m=0,y;if(!u){constx=[Du.bind(this),void0];for(x.unshift(...a),x.push(...c),y=x.length,f=Promise.resolve(n);m<y;)f=f.then(x[m++],x[m++]);returnf}y=a.length;letS=n;for(;m<y;){constx=a[m++],v=a[m++];try{S=x(S)}catch(g){v.call(this,g);break}}try{f=Du.call(this,S)}catch(x){returnPromise.reject(x)}for(m=0,y=c.length;m<y;)f=f.then(c[m++],c[m++]);returnf}getUri(t){t=mn(this.defaults,t);constn=wp(t.baseURL,t.url,t.allowAbsoluteUrls);returngp(n,t.params,t.paramsSerializer)}};w.forEach(["delete","get","head","options"],function(t){un.prototype[t]=function(n,r){returnthis.request(mn(r||{},{method:t,url:n,data:(r||{}).data}))}});w.forEach(["post","put","patch","query"],function(t){functionn(r){returnfunction(i,l,a){returnthis.request(mn(a||{},{method:t,headers:r?{"Content-Type":"multipart/form-data"}:{},url:i,data:l}))}}un.prototype[t]=n(),t!=="query"&&(un.prototype[t+"Form"]=n(!0))});letqg=classjp{constructor(t){if(typeoft!="function")thrownewTypeError("executor must be a function.");letn;this.promise=newPromise(function(i){n=i});constr=this;this.promise.then(o=>{if(!r._listeners)return;leti=r._listeners.length;for(;i-->0;)r._listeners[i](o);r._listeners=null}),this.promise.then=o=>{leti;constl=newPromise(a=>{r.subscribe(a),i=a}).then(o);returnl.cancel=function(){r.unsubscribe(i)},l},t(function(i,l,a){r.reason||(r.reason=newXr(i,l,a),n(r.reason))})}throwIfRequested(){if(this.reason)throwthis.reason}subscribe(t){if(this.reason){t(this.reason);return}this._listeners?this._listeners.push(t):this._listeners=[t]}unsubscribe(t){if(!this._listeners)return;constn=this._listeners.indexOf(t);n!==-1&&this._listeners.splice(n,1)}toAbortSignal(){constt=newAbortController,n=r=>{t.abort(r)};returnthis.subscribe(n),t.signal.unsubscribe=()=>this.unsubscribe(n),t.signal}staticsource(){lett;return{token:newjp(function(o){t=o}),cancel:t}}};functionKg(e){returnfunction(n){returne.apply(null,n)}}functionYg(e){returnw.isObject(e)&&e.isAxiosError===!0}constas={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,Misdirec
Usethisafterback-datingaviolationifolderPDFsnolongerreflectthecorrectprior-pointstotal.ExistingPDFswillregeneratewithup-to-datenumbers.`))try{const R=await F.post(`/api/employees/${e}/recompute-snapshots`),{scanned:P,updated:$}=R.data;$===0?d.success(`Snapshotsalreadyuptodate(${P}checked).`):d.success(`Updated${$}of${P}snapshot${$===1?"":"s"}.`),h()}catch(R){d.error("Backfill failed: "+(((L=(A=R.response)==null?void 0:A.data)==null?void 0:L.error)||R.message))}},_=async({resolution_type:A,details:L,resolved_by:R})=>{var P,$;try{await F.patch(`/api/violations/${f.id}/negate`,{resolution_type:A,details:L,resolved_by:R}),d.success("Violation negated."),m(null),S(null),h()}catch(N){d.error("Negate failed: "+((($=(P=N.response)==null?void 0:P.data)==null?void 0:$.error)||N.message))}},Q=o?Kt(o.active_points):null,U=l.filter(A=>!A.negated),X=l.filter(A=>A.negated),H=A=>{A.target===A.currentTarget&&t()};return s.jsxs("div",{style:M.overlay,onClick:H,children:[s.jsxs("div",{style:M.panel,onClick:A=>A.stopPropagation(),children:[s.jsx("div",{style:M.header,children:s.jsxs("div",{style:M.headerRow,children:[s.jsxs("div",{children:[s.jsx("div",{style:{fontSize:"18px",fontWeight:700},children:n?n.name:"Employee"}),n&&s.jsxs("div",{style:{fontSize:"12px",color:"#b5b5c0",marginTop:"4px"},children:[n.department," ",n.supervisor&&`·Supervisor:${n.supervisor}`]}),n&&s.jsx("button",{style:M.editEmpBtn,onClick:()=>v(!0),children:"✎ Edit Employee"})]}),s.jsx("button",{style:M.closeBtn,onClick:t,children:"✕"})]})}),s.jsx("div",{style:M.body,children:u?s.jsx("div",{style:{padding:"40px",textAlign:"center",color:"#b5b5c0"},children:"Loading…"}):s.jsxs(s.Fragment,{children:[o&&s.jsxs("div",{style:M.scoreRow,children:[s.jsxs("div",{style:M.scoreCard,children:[s.jsx("div",{style:{...M.scoreNum,color:(Q==null?void 0:Q.color)||"#f8f9fa"},children:o.active_points}),s.jsx("div",{style:M.scoreLbl,children:"Active Points"})]}),s.jsxs("div",{style:M.scoreCard,children:[s.jsx("div",{style:M.scoreNum,children:o.total_violations}),s.jsx("div",{style:M.scoreLbl,children:"Total Violations"})]}),s.jsxs("div",{style:M.scoreCard,children:[s.jsx("div",{style:M.scoreNum,children:o.negated_count}),s.jsx("div",{style:M.scoreLbl,children:"Negated"})]}),s.jsxs("div",{style:{...M.scoreCard,minWidth:"140px"},children:[s.jsx("div",{style:{fontSize:"13px",fontWeight:700,color:(Q==null?void 0:Q.color)||"#f8f9fa"},children:Q?Q.label:"—"}),s.jsx("div",{style:M.scoreLbl,children:"Current Tier"})]})]}),o&&s.jsx(Ti,{points:o.active_points,style:{marginBottom:"20px"}}),n&&s.jsx(xy,{employeeId:e,initialNotes:n.notes,onSaved:A=>r(L=>({...L,notes:A}))}),o&&o.active_points>0&&s.jsx(gy,{employeeId:e,currentPoints:o.active_points}),s.jsxs("div",{style:{display:"flex",alignItems:"center",justifyContent:"space-between",marginTop:"24px",marginBottom:"10px"},children:[s.jsx("div",{style:{...M.sectionHd,marginTop:0,marginBottom:0},children:"Active Violations"}),l.length>0&&s.jsx("button",{style:M.backfillBtn,onClick:T,title:"Rebuild prior-points snapshot on each violation. Use after a back-dated insert if older PDFs show the wrong Prior Active Points.",children:"↻ Backfill Snapshots"})]}),U.length===0?s.jsx("div",{style:{color:"#777990",fontStyle:"italic",fontSize:"12px"},children:"No active violations on record."}):s.jsxs("table",{style:M.table,children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{style:M.th,children:"Date"}),s.jsx("th",{style:M.th,children:"Violation"}),s.jsx("th",{style:M.th,children:"Pts"}),s.jsx("th",{style:M.th,children:"Actions"})]})}),s.jsx("tbody",{children:U.map(A=>s.jsxs("tr",{children:[s.jsx("td",{style:M.td,children:A.incident_date}),s.jsxs("td",{style:M.td,children:[s.jsxs("div",{style:{fontWeight:600},children:[A.violation_name,A.amendment_count>0&&s.jsxs("span",{style:M.amendBadge,children:[A.amendment_count," edit",A.amendment_count!==1?"s":""]})]}),s.jsx("div",{style:{fontSize:"10px",color:"#9ca0b8"},children:A.category}),A.details&&s.jsx("div",{style:{fontSize:"10px",color:"#b5b5c0",marginTop:
`;functionPy(){const[e,t]=k.useState([]),[n,r]=k.useState([]),[o,i]=k.useState(""),[l,a]=k.useState(null),[u,c]=k.useState(!1),[f,m]=k.useState(!0),[y,S]=k.useState(vo),x=Ry("(max-width: 768px)"),v=k.useCallback(()=>{m(!0),F.get("/api/dashboard").then(j=>{t(j.data),r(j.data)}).finally(()=>m(!1))},[]);k.useEffect(()=>{v()},[v]),k.useEffect(()=>{constj=o.toLowerCase();letT=e;y===sr?T=T.filter(_=>_.active_points>=0&&_.active_points<=4):y===ar?T=T.filter(_=>_.active_points>0):y===ur&&(T=T.filter(_=>ul(_.active_points))),j&&(T=T.filter(_=>_.name.toLowerCase().includes(j)||(_.department||"").toLowerCase().includes(j)||(_.supervisor||"").toLowerCase().includes(j))),r(T)},[o,e,y]);constg=e.filter(j=>ul(j.active_points)).length,p=e.filter(j=>j.active_points>0).length,d=e.filter(j=>j.active_points>=0&&j.active_points<=4).length,h=e.reduce((j,T)=>Math.max(j,T.active_points),0);functionb(j){S(T=>T===j?vo:j)}functionC(j,T={}){const_=y===j;return{...V.statCard,..._?V.statCardActive:{},...T}}returns.jsxs(s.Fragment,{children:[s.jsx("style",{children:Ty}),s.jsxs("div",{style:V.wrap,className:"dashboard-wrap",children:[s.jsxs("div",{style:V.header,className:"dashboard-header",children:[s.jsxs("div",{children:[s.jsx("div",{style:V.title,className:"dashboard-title",children:"Company Dashboard"}),s.jsxs("div",{style:V.subtitle,className:"dashboard-subtitle",children:["Click any employee name to view their full profile",y&&y!==vo&&s.jsxs("span",{style:{marginLeft:"10px",color:"#d4af37",fontWeight:600},children:["· Filtered: ",y===sr?"Elite Standing (0–4 pts)":y===ar?"With Active Points":y===ur?"At Risk":"All",s.jsx("button",{onClick:()=>S(vo),style:{marginLeft:"6px",background:"none",border:"none",color:"#9ca0b8",cursor:"pointer",fontSize:"12px"},title:"Clear filter",children:"✕"})]})]})]}),s.jsxs("div",{style:V.toolbarRight,className:"toolbar-right",children:[s.jsx("input",{style:V.search,className:"search-input",placeholder:"Search name, dept, supervisor…",value:o,onChange:j=>i(j.target.value)}),s.jsx("button",{style:V.auditBtn,className:"toolbar-btn",onClick:()=>c(!0),children:"📋 Audit Log"}),s.jsx("button",{style:V.refreshBtn,className:"toolbar-btn",onClick:v,children:"↻ Refresh"})]})]}),s.jsxs("div",{style:V.statsRow,className:"dashboard-stats",children:[s.jsxs("div",{style:C(cl),className:"dashboard-stat-card",onClick:()=>b(cl),title:"Click to show all employees",children:[s.jsx("div",{style:V.statNum,className:"stat-num",children:e.length}),s.jsx("div",{style:V.statLbl,className:"stat-lbl",children:"Total Employees"}),y===cl&&s.jsx("div",{style:V.filterBadge,children:"▼ Showing All"})]}),s.jsxs("div",{style:C(sr,{borderTop:"3px solid #28a745"}),className:"dashboard-stat-card",onClick:()=>b(sr),title:"Click to filter: Elite Standing (0–4 pts)",children:[s.jsx("div",{style:{...V.statNum,color:"#6ee7b7"},className:"stat-num",children:d}),s.jsx("div",{style:V.statLbl,className:"stat-lbl",children:"Elite Standing (0–4 pts)"}),y===sr&&s.jsx("div",{style:V.filterBadge,children:"▼ Filtered"})]}),s.jsxs("div",{style:C(ar,{borderTop:"3px solid #d4af37"}),className:"dashboard-stat-card",onClick:()=>b(ar),title:"Click to filter: employees with active points",children:[s.jsx("div",{style:{...V.statNum,color:"#ffd666"},className:"stat-num",children:p}),s.jsx("div",{style:V.statLbl,className:"stat-lbl",children:"With Active Points"}),y===ar&&s.jsx("div",{style:V.filterBadge,children:"▼ Filtered"})]}),s.jsxs("div",{style:C(ur,{borderTop:"3px solid #ffb020"}),className:"dashboard-stat-card",onClick:()=>b(ur),title:`Click to filter: at risk (≤${us} pts to next tier)`,children:[s.jsx("div",{style:{...V.statNum,color:"#ffdf8a"},className:"stat-num",children:g}),s.jsxs("div",{style:V.statLbl,className:"stat-lbl",children:["At Risk (≤",us," pts to next tier)"]}),y===ur&&s.jsx("div",{style:V.filterBadge,children:"▼ Filtered"})]}),s.jsxs("div",{style:{...V.statCard,borderTop:"3px solid #c0392b",cursor:"default"},className:"dashboard-stat-card",children:[s.jsx("div",{style:{...V.statNum,color:"#ff8a80"},className:"stat-num
Opentheappandenteryourusernameandpasswordintheloginpopup.Asessionlasts**7days**,afterwhichyou'll be asked to sign in again. Use the **Logout** button in the top-right of the nav bar to end your session immediately.
### The bootstrap admin
A single admin account is created automatically from the container'senvironmentvariables(\`ADMIN_USERNAME\` / \`ADMIN_PASSWORD\`). Notes:
- Its password is **owned by the Docker environment** — it re-syncs on every container start. To rotate it, change \`ADMIN_PASSWORD\` in the container settings and restart. It cannot be changed from the UI (a UI change would be overwritten on the next restart).
- Set a strong \`ADMIN_PASSWORD\` at deploy time. The image ships with a placeholder default that must be overridden.
### Managing users (admin only)
Admins see a **Users** button in the nav bar that opens the user-management panel. From there you can:
- **Add a user** — username, password (min 6 characters), and a role of **User** or **Admin**.
- **Reset a password** for any account.
- **Delete a user** — they lose access immediately. You cannot delete your own account.
Roles: **Admin** can manage users plus everything a User can do; **User** can view and edit CPAS data but cannot manage accounts. Login attempts and all account changes are written to the audit log.
Every violation carries a **point value** set at the time of submission. Points count toward an employee's score only within a **rolling 90-day window** — once a violation is older than 90 days it automatically drops off and the score recalculates.
Negated (voided) violations are excluded from scoring immediately. Hard-deleted violations are removed from the record entirely.
## Tier Reference
| Points | Tier | Label |
|--------|------|-------|
| 0–4 | 0–1 | Elite Standing |
| 5–9 | 1 | Realignment |
| 10–14 | 2 | Administrative Lockdown |
| 15–19 | 3 | Verification |
| 20–24 | 4 | Risk Mitigation |
| 25–29 | 5 | Final Decision |
| 30+ | 6 | Separation |
The **at-risk badge** on the dashboard flags anyone within 2 points of the next tier threshold so supervisors can act before escalation occurs.
---
## Feature Map
### Dashboard
The main view. Employees are sorted by active CPAS points, highest first.
- **Stat cards** — live counts: total employees, zero-point (elite), with active points, at-risk, highest score
- **Search / filter** — by name, department, or supervisor; narrows the table in real time
- **At-risk badge** — gold flag on rows where the employee is within 2 pts of the next tier
- **Audit Log button** — opens the filterable, paginated write-action log (top right of the dashboard toolbar)
- **Click any name** — opens that employee's full profile modal
---
### Logging a Violation
Use the **+ New Violation** tab.
1. Select an existing employee from the dropdown, or type a new name to create a record on-the-fly.
2. The **employee intelligence panel** loads their current tier badge and 90-day violation count before you commit.
3. Choose a violation type. The dropdown is grouped by category and shows prior 90-day counts inline for each type.
4. If the employee has a prior violation of the same type, the **recidivist auto-escalation** rule triggers — the points slider jumps to the maximum allowed for that violation type.
5. The **tier crossing warning** previews what tier the submission would land the employee in. Review before submitting.
6. Adjust points using the slider if discretionary reduction is warranted (within the violation's allowed min/max range).
7. **Employee Acknowledgment** (optional): if the employee is present and acknowledges receipt, enter their printed name and the acknowledgment date. This replaces the blank signature line on the PDF with a recorded acknowledgment and an "Acknowledged" badge. Leave blank if the employee is not present or declines.
8. Submit. A **PDF download link** appears immediately — download it for the employee's file.
9. **Toast notifications** confirm success or surface errors at the top right of the screen. Toasts auto-dismiss after a few seconds.
---
### Employee Profile Modal
Click any name on the dashboard to open their profile.
#### Overview section
Shows current tier badge, active points, and 90-day violation count.
#### Notes & Flags
Free-text field for HR context (e.g. "On PIP", "Union member", "Pending investigation", "FMLA"). Quick-add tag buttons pre-fill common statuses. Notes are visible to anyone who opens the profile but **do not affect CPAS scoring**. Edit inline; saves on blur.
#### Point Expiration Timeline
Visible when the employee has active points. Shows each active violation as a progress bar indicating how far through its 90-day window it is, days remaining until roll-off, and a **tier-drop indicator** for violations whose expiration would move the employee down a tier.
#### Violation History
Full record of all submissions — active, negated, and resolved.
- **Amend** — edit non-scoring fields (location, details, witness, submitted-by, incident time, acknowledged-by, acknowledged-date) on any active violation. Every change is logged as a field-level diff (old → new) with timestamp. Points, type, and incident date are immutable.
- **Negate** — soft-delete a violation with a resolution type and notes. The record is preserved in history; the points are immediately removed from the score. Fully reversible via **Restore**.
- **Hard delete** — permanent removal. Use only for genuine data entry errors.
- **PDF** — download the formal violation document for any historical record. If the violation has an employee acknowledgment on record, the PDF shows the filled-in name and date instead of blank signature lines.
All actions trigger **toast notifications** confirming success or surfacing errors.
#### Edit Employee
Update name, department, or supervisor. Changes are logged to the audit trail.
#### Merge Duplicate
If the same employee exists under two names, use Merge to reassign all violations from the duplicate to the canonical record. The duplicate is then deleted. This cannot be undone.
#### Backfill Snapshots (repair tool)
Each violation stores a **prior-points snapshot** at submission time so its PDF always shows the score *as it was on the incident date*. Normally you never touch this — it's set on insert, refreshed automatically when a back-dated violation lands inside another violation's 90-day window, and otherwise locked. PDFs stay stable through negate/restore by design.
The **↻ Backfill Snapshots** button sits next to the **Active Violations** header in the profile modal. It rebuilds the snapshot on every violation for that employee using current data.
**When to use it:**
- After back-dating a violation, an older PDF still shows "Prior Active Points: 0" even though an earlier violation now clearly exists in the timeline.
- More generally: any time a regenerated PDF disagrees with what you see in the Point Expiration Timeline (the timeline is computed live; PDFs use the snapshot).
**When *not* to use it:**
- After a negate, restore, amend, or hard delete in normal workflow. The system intentionally keeps existing PDFs stable through those operations.
- As a routine maintenance step. If you keep needing it after ordinary back-dated inserts, that's a bug worth reporting — the auto-refresh should already be covering you.
**What clicking it does:**
1. Iterates every violation belonging to the employee (active and negated).
2. Recomputes each row's prior-points snapshot from the current set of non-negated violations in the 90 days before its incident date.
3. Writes only the rows that actually changed.
4. Records one entry in the audit log (action: \`violation_snapshots_recomputed\`, reason: \`manual_backfill\`) with the per-row before/after values.
A toast confirms the outcome — either *"Updated X of Y snapshots"* or *"Snapshots already up to date"*. Re-download any affected PDFs after running it; the new totals will appear immediately.
---
### Audit Log
Accessible from the dashboard toolbar (🔍 button). Append-only log of every write action in the system.
- Filter by entity type: **employee** or **violation**
- Paginated with load-more; most recent entries first
The audit log is the authoritative record for compliance review. Nothing in it can be edited or deleted through the UI.
---
### Violation Amendment
Amendments allow corrections to a violation's non-scoring fields without deleting and re-submitting, which would disrupt the audit trail and the prior-points snapshot.
- **Info** (blue) — general informational messages
Toasts auto-dismiss after a few seconds (errors persist longer). Each toast has a progress bar countdown and a manual dismiss button. Up to 5 toasts can stack simultaneously.
---
## Immutability Rules — Quick Reference
| Action | Allowed? | Notes |
|--------|----------|-------|
| Edit violation type | No | Immutable after submission |
| Edit incident date | No | Immutable after submission |
| Edit point value | No | Immutable after submission |
| Add / edit employee notes | Yes | Does not affect score |
| Recompute prior-points snapshot | Yes | Two paths only: auto (back-dated insert) or **↻ Backfill Snapshots** button. Never touched by negate, restore, amend, or hard delete |
Larger features that require more design work or infrastructure.
- **Violation trends chart** — line/bar chart of violations over time, filterable by department or supervisor. Useful for identifying systemic patterns vs. isolated incidents. Recharts is already available in the frontend bundle.
- **Department heat map** — grid showing violation density and average CPAS score per department. Helps identify team-level risk early.
- **Draft / pending violations** — save a violation as a draft before it's officially logged. Useful when incidents need supervisor review or HR sign-off before they count toward the score.
- **At-risk threshold configuration** — make the 2-point at-risk warning threshold configurable per deployment rather than hardcoded.
---
### Future Considerations
These require meaningful infrastructure additions and should be evaluated against actual operational need before committing.