NESAPP

North East Security · Secure Portal

Checking your session…

Please wait.

'; popup.document.open();popup.document.write(html);popup.document.close(); const d=popup.document; const put=(id,value)=>{d.getElementById(id).textContent=String(value??'—');}; const field=(label,value)=>{const box=d.createElement('div');box.className='field';const l=d.createElement('span');l.className='label';l.textContent=label;const v=d.createElement('span');v.className='value';v.textContent=String(value??'—');box.append(l,v);d.getElementById('meta').append(box);}; put('audience',audience==='management'?'Management copy — confidential investigation activity included':'Client copy — shared updates only'); field('Complaint reference',record.complaint_reference);field('Organisation',organisation||'—');field('Venue',venue||'—');field('Category',record.category);field('Subject',record.subject);field('Status',String(record.status||'submitted').replaceAll('_',' ').toUpperCase());field('Incident date',record.incident_date||'Not provided');field('Submitted',record.created_at?new Date(record.created_at).toLocaleString('en-GB'):'—'); put('description',record.description);put('resolution',record.resolution||'No resolution recorded yet.'); if(audience==='management' && privateDetails){ d.getElementById('privateSection').hidden=false; put('privateAssignee',privateDetails.assigned_to||'Not assigned'); put('privateNotes',privateDetails.management_notes||'No confidential notes recorded'); put('privateClosed',privateDetails.closed_at?new Date(privateDetails.closed_at).toLocaleString('en-GB'):'Not recorded'); put('privateRetention',privateDetails.retention_until?new Date(privateDetails.retention_until).toLocaleString('en-GB'):'Not scheduled'); put('privateHold',privateDetails.retention_hold?'Yes'+(privateDetails.retention_hold_reason?' — '+privateDetails.retention_hold_reason:''):'No'); } const activityBox=d.getElementById('activities'); if(!activities.length){activityBox.textContent='No recorded investigation activity available.';} for(const entry of activities){ const section=d.createElement('section');section.className='field'; const heading=d.createElement('strong');heading.textContent=String(entry.activity_type||'Investigation update')+' — '+(entry.created_at?new Date(entry.created_at).toLocaleString('en-GB'):'Date unavailable'); const body=d.createElement('div');body.className='value';body.textContent=entry.activity_details||''; section.append(heading,body); if(audience==='management'){const flag=d.createElement('small');flag.textContent=entry.visible_to_client?'Shared with client':'Confidential — management only';section.append(flag);} activityBox.append(section); } d.title='NESAPP Complaint '+String(record.complaint_reference||'Report').replace(/[^A-Za-z0-9-]/g,''); d.getElementById('printNow').addEventListener('click',()=>popup.print()); popup.focus(); } // Client complaints management: safe shared SELECT columns; protected changes via management-only RPC. async function loadManagementClientComplaints(){ const root=document.getElementById('managementClientComplaints');if(!root)return; const node=(tag,value,cls)=>{const e=document.createElement(tag);if(value!==undefined)e.textContent=value;if(cls)e.className=cls;return e;}; root.replaceChildren(node('p','Loading complaints…')); try{ const sb=window.nesappSupabase; const {data:auth,error:authError}=await sb.auth.getUser();if(authError||!auth?.user)throw new Error('Please sign in again.'); const {data:rows,error}=await sb.from('nesapp_client_complaints').select('id,client_id,venue_id,complaint_reference,category,subject,description,incident_date,status,resolution,created_at').neq('status','closed').order('created_at',{ascending:false}).limit(100); if(error)throw error; if(!rows?.length){root.replaceChildren(node('p','No active client complaints. Closed complaints are listed in the Closed Complaints Register.'));return;} const clientIds=[...new Set(rows.map(x=>x.client_id))],venueIds=[...new Set(rows.map(x=>x.venue_id))]; const [clientsRes,venuesRes]=await Promise.all([sb.from('nesapp_clients').select('id,company_name').in('id',clientIds),sb.from('nesapp_client_venues').select('id,venue_name').in('id',venueIds)]); const clients=new Map((clientsRes.data||[]).map(x=>[x.id,x.company_name])); const venues=new Map((venuesRes.data||[]).map(x=>[x.id,x.venue_name])); root.replaceChildren(); if(clientsRes.error||venuesRes.error)root.append(node('p','Some venue or organisation names could not be loaded.','muted')); const {data:activityRows,error:activityError}=await sb.from('nesapp_complaint_activity').select('id,complaint_id,created_by,activity_type,activity_details,visible_to_client,created_at').in('complaint_id',rows.map(x=>x.id)).order('created_at',{ascending:true}); const activitiesByComplaint=new Map();for(const a of activityRows||[]){if(!activitiesByComplaint.has(a.complaint_id))activitiesByComplaint.set(a.complaint_id,[]);activitiesByComplaint.get(a.complaint_id).push(a);} if(activityError)root.append(node('p','Investigation history unavailable: '+activityError.message,'muted')); const statuses=['submitted','under_review','investigating','resolved','closed']; for(const c of rows){ const card=node('section',undefined,'verify-card'); card.append(node('h4',(c.complaint_reference||'Complaint')+' · '+c.subject)); card.append(node('p',(clients.get(c.client_id)||'Client')+' · '+(venues.get(c.venue_id)||'Venue')+' · '+c.category,'muted')); card.append(node('p','Status: '+c.status.replaceAll('_',' '))); if(c.incident_date)card.append(node('p','Incident date: '+c.incident_date.split('-').reverse().join('/'),'muted')); card.append(node('p',c.description)); if(c.resolution)card.append(node('p','Current client-visible resolution: '+c.resolution)); let privateDetails=null; const privateBox=node('section',undefined,'verify-card');privateBox.append(node('p','Loading confidential management details…','muted'));card.append(privateBox); const privateResult=await sb.rpc('nesapp_get_management_complaint_details',{p_complaint_id:c.id}); if(privateResult.error){privateBox.replaceChildren(node('p','Confidential details unavailable: '+privateResult.error.message,'muted'));} else { privateDetails=Array.isArray(privateResult.data)?privateResult.data[0]:privateResult.data; privateBox.replaceChildren(node('h4','Confidential management information')); for(const [label,value] of [['Assigned investigator (user ID)',privateDetails?.assigned_to||'Not assigned'],['Existing management notes',privateDetails?.management_notes||'None recorded']])privateBox.append(node('p',label+': '+value)); } const pdf=node('button','Save full management PDF');pdf.type='button';pdf.className='secondary'; pdf.disabled=!privateDetails;pdf.title=privateDetails?'':'Confidential details must load before generating a complete management PDF'; pdf.addEventListener('click',()=>nesappPrintComplaintPdf(c,clients.get(c.client_id)||'Client',venues.get(c.venue_id)||'Venue','management',activitiesByComplaint.get(c.id)||[],privateDetails));card.append(pdf); const activitySection=node('section',undefined,'wide');activitySection.append(node('h4','Investigation activity history')); const entries=activitiesByComplaint.get(c.id)||[]; if(!entries.length)activitySection.append(node('p','No investigation actions recorded yet.','muted')); for(const a of entries){const item=node('div',undefined,'verify-card');item.append(node('strong',a.activity_type+' · '+new Date(a.created_at).toLocaleString('en-GB')));item.append(node('p',a.activity_details));item.append(node('p',a.visible_to_client?'Shared with client':'Confidential — management only','muted'));activitySection.append(item);} const activityForm=node('form',undefined,'staff-form'); const activityType=node('select');activityType.className='field';for(const label of ['Investigation started','Action taken','Evidence reviewed','Witness contacted','Investigation finding','Client update','Follow-up','Other']){const option=node('option',label);option.value=label;activityType.append(option);} const activityDetails=node('textarea');activityDetails.className='field';activityDetails.rows=3;activityDetails.maxLength=5000;activityDetails.placeholder='Describe the action, evidence, findings or update';activityDetails.required=true; const share=node('input');share.type='checkbox';share.style.width='auto'; const activitySubmit=node('button','Add investigation activity');activitySubmit.type='submit'; const activityFeedback=node('p','');activityFeedback.setAttribute('role','status'); const activityLabel=node('label','Action type');activityLabel.append(activityType); const detailsLabel=node('label','Investigation details');detailsLabel.append(activityDetails); const shareLabel=node('label','Share this update with the client');shareLabel.append(share); activityForm.append(activityLabel,detailsLabel,shareLabel,activitySubmit,activityFeedback);activitySection.append(activityForm);card.append(activitySection); activityForm.addEventListener('submit',async event=>{event.preventDefault();if(!activityDetails.value.trim())return;if(!window.confirm(share.checked?'This entry will be visible to the client. Continue?':'Save this confidential management entry?'))return;activitySubmit.disabled=true;activityFeedback.textContent='Saving investigation entry…';const {error:activitySaveError}=await sb.rpc('nesapp_add_complaint_activity',{p_complaint_id:c.id,p_activity_type:activityType.value,p_activity_details:activityDetails.value.trim(),p_visible_to_client:share.checked});if(activitySaveError){activityFeedback.textContent='Unable to save: '+activitySaveError.message;activitySubmit.disabled=false;return;}await loadManagementClientComplaints();}); const form=node('form',undefined,'staff-form'); const wrap=(title,control)=>{const label=node('label',title);label.append(control);return label;}; const status=document.createElement('select');status.className='field'; for(const v of statuses){const opt=node('option',v.replaceAll('_',' '));opt.value=v;status.append(opt);}status.value=c.status; const assign=document.createElement('input');assign.type='checkbox';assign.style.width='auto'; const notes=document.createElement('textarea');notes.className='field';notes.rows=3;notes.maxLength=5000;notes.placeholder='New confidential notes (not displayed to clients)'; const resolution=document.createElement('textarea');resolution.className='field';resolution.rows=3;resolution.maxLength=5000;resolution.value=c.resolution||'';resolution.placeholder='Client-visible resolution'; const warning=node('p','Blank notes, investigator and resolution fields preserve existing values. Nonblank notes replace the previous note; record a separate investigation activity for a permanent history.','muted'); const submit=node('button','Save complaint update');submit.type='submit'; const feedback=node('p','');feedback.setAttribute('role','status');feedback.setAttribute('aria-live','polite'); const field1=wrap('Investigation status',status),field2=wrap('Assign to my management account',assign),field3=wrap('Confidential management notes',notes),field4=wrap('Client-visible resolution',resolution); field3.className='wide';field4.className='wide';warning.className='wide muted';feedback.className='wide'; form.append(field1,field2,field3,warning,field4,submit,feedback);card.append(form);root.append(card); form.addEventListener('submit',async event=>{ event.preventDefault(); if(!window.confirm('Save this complaint update? Nonblank confidential notes will replace previous notes. Record an investigation activity for a permanent history.'))return; submit.disabled=true;feedback.textContent='Saving complaint…'; try{ const {error:rpcError}=await sb.rpc('nesapp_manage_complaint',{p_complaint_id:c.id,p_status:status.value,p_assigned_to:assign.checked?auth.user.id:null,p_management_notes:notes.value.trim()||null,p_resolution:resolution.value.trim()||null}); if(rpcError)throw rpcError; feedback.textContent='Complaint updated successfully.';await loadManagementClientComplaints();if(status.value==='closed')await loadManagementClosedComplaints(); }catch(e){feedback.textContent='Unable to update complaint: '+(e?.message||'Unknown error');submit.disabled=false;} }); } }catch(e){root.replaceChildren(node('p','Unable to load complaints: '+(e?.message||'Unknown error')));} } // Management-only closed complaints register. Data and changes are secured by Supabase RPCs. async function loadManagementClosedComplaints(){ const root=document.getElementById('managementClosedComplaints');if(!root)return; const el=(tag,text,cls)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;if(cls)n.className=cls;return n;}; root.replaceChildren(el('p','Loading closed complaints…')); try{ const sb=window.nesappSupabase;if(!sb)throw new Error('Supabase connection unavailable.'); const {data,error}=await sb.rpc('nesapp_get_closed_complaints');if(error)throw error; root.replaceChildren(); if(!data?.length){root.append(el('p','No closed complaints found.'));return;} const date=v=>v?new Date(v).toLocaleString('en-GB'):'Not recorded'; for(const c of data){ const card=el('section',undefined,'verify-card'); card.append(el('h4',c.complaint_reference||'Closed complaint')); card.append(el('p','Closed: '+date(c.closed_at)+' · Deletion due: '+(c.retention_until?date(c.retention_until):'Not scheduled'),'muted')); card.append(el('p','Retention hold: '+(c.retention_hold?'ACTIVE':'No'))); if(c.retention_hold_reason)card.append(el('p','Reason: '+c.retention_hold_reason,'muted')); // Closed complaint detail is loaded on demand using the existing management RLS. const view=el('button','View Complaint');view.type='button';view.className='secondary'; const details=el('section',undefined,'verify-card');details.hidden=true; card.append(view,details); view.addEventListener('click',async()=>{ if(!details.hidden){details.hidden=true;view.textContent='View Complaint';return;} view.disabled=true;details.replaceChildren(el('p','Loading full complaint record…'));details.hidden=false; try{ const {data:record,error:recordError}=await sb.from('nesapp_client_complaints') .select('id,client_id,venue_id,complaint_reference,category,subject,description,incident_date,status,resolution,created_at') .eq('id',c.complaint_id).eq('status','closed').single(); if(recordError)throw recordError; const [activityResult,clientResult,venueResult,privateResult]=await Promise.all([ sb.from('nesapp_complaint_activity').select('id,activity_type,activity_details,visible_to_client,created_at').eq('complaint_id',c.complaint_id).order('created_at',{ascending:true}), sb.from('nesapp_clients').select('company_name').eq('id',record.client_id).maybeSingle(), sb.from('nesapp_client_venues').select('venue_name').eq('id',record.venue_id).maybeSingle(), sb.rpc('nesapp_get_management_complaint_details',{p_complaint_id:c.complaint_id}) ]); if(activityResult.error)throw activityResult.error; if(privateResult.error)throw privateResult.error; const privateDetails=Array.isArray(privateResult.data)?privateResult.data[0]:privateResult.data; if(!privateDetails)throw new Error('Confidential complaint details not found.'); const activities=activityResult.data||[]; const organisation=clientResult.data?.company_name||'Client'; const venue=venueResult.data?.venue_name||'Venue'; details.replaceChildren(); details.append(el('h4','Complaint details')); for(const [label,value] of [ ['Reference',record.complaint_reference],['Organisation',organisation],['Venue',venue], ['Category',record.category],['Subject',record.subject],['Incident date',record.incident_date||'Not provided'], ['Submitted',record.created_at?new Date(record.created_at).toLocaleString('en-GB'):'Not recorded'], ['Status','Closed'],['Original complaint',record.description],['Client-visible resolution',record.resolution||'Not recorded'] ])details.append(el('p',label+': '+(value||'—'))); details.append(el('h4','Confidential management information')); for(const [label,value] of [ ['Assigned investigator (user ID)',privateDetails.assigned_to||'Not assigned'], ['Management notes',privateDetails.management_notes||'Not recorded'], ['Closure date',privateDetails.closed_at?new Date(privateDetails.closed_at).toLocaleString('en-GB'):'Not recorded'], ['Retention deadline',privateDetails.retention_until?new Date(privateDetails.retention_until).toLocaleString('en-GB'):'Not scheduled'], ['Retention hold',privateDetails.retention_hold?'Active'+(privateDetails.retention_hold_reason?' — '+privateDetails.retention_hold_reason:''):'No'] ])details.append(el('p',label+': '+value)); details.append(el('h4','Investigation activity history')); if(!activities.length)details.append(el('p','No investigation activity has been recorded.','muted')); for(const a of activities){ const item=el('div',undefined,'verify-card'); item.append(el('strong',(a.activity_type||'Update')+' · '+(a.created_at?new Date(a.created_at).toLocaleString('en-GB'):'Date unavailable'))); item.append(el('p',a.activity_details||'')); item.append(el('p',a.visible_to_client?'Shared with client':'Confidential — management only','muted')); details.append(item); } const pdf=el('button','Download Full PDF');pdf.type='button';pdf.className='secondary'; pdf.addEventListener('click',()=>nesappPrintComplaintPdf(record,organisation,venue,'management',activities,privateDetails)); details.append(pdf); details.append(el('p','The investigator is shown by user ID. Investigation activity recorded before the activity log was introduced may be unavailable.','muted')); view.textContent='Hide Complaint'; }catch(err){details.replaceChildren(el('p','Unable to open complaint: '+(err?.message||'Unknown error')));view.textContent='Retry View Complaint';} finally{view.disabled=false;} }); const form=el('form',undefined,'staff-form'); const checkLabel=el('label','Retention hold (prevent deletion)');const check=document.createElement('input');check.type='checkbox';check.checked=!!c.retention_hold;check.style.width='auto';checkLabel.append(check); const reasonLabel=el('label','Reason for hold');const reason=document.createElement('textarea');reason.className='field';reason.rows=2;reason.maxLength=2000;reason.value=c.retention_hold_reason||'';reason.placeholder='Required when applying a hold';reasonLabel.append(reason); const save=el('button','Save retention hold');save.type='submit';const feedback=el('p','');feedback.setAttribute('role','status');feedback.className='muted'; form.append(checkLabel,reasonLabel,save,feedback);card.append(form); form.addEventListener('submit',async e=>{ e.preventDefault();const why=reason.value.trim();if(check.checked&&!why){feedback.textContent='Please enter a reason for the hold.';return;} if(!window.confirm(check.checked?'Apply or update this retention hold?':'Remove the retention hold? Confirm there is no ongoing legal, safeguarding or contractual reason to retain it.'))return; save.disabled=true;feedback.textContent='Saving…'; try{const {error}=await sb.rpc('nesapp_set_complaint_retention_hold',{p_complaint_id:c.complaint_id,p_hold:check.checked,p_reason:check.checked?why:null});if(error)throw error;await loadManagementClosedComplaints();} catch(err){feedback.textContent='Unable to save hold: '+(err?.message||'Unknown error');save.disabled=false;} }); root.append(card); } }catch(err){root.replaceChildren(el('p','Unable to load closed complaints: '+(err?.message||'Unknown error')));} } window.nesappLoadManagementClosedComplaints=loadManagementClosedComplaints; document.getElementById('refreshClosedComplaints')?.addEventListener('click',loadManagementClosedComplaints); window.nesappLoadManagementClientComplaints=loadManagementClientComplaints; document.getElementById('refreshClientComplaintManagement')?.addEventListener('click',loadManagementClientComplaints); // Client booking management: authenticated management role and server-side RPC enforce decisions. async function loadManagementClientBookings(){ const root=document.getElementById('managementClientBookings');if(!root)return; root.replaceChildren();const info=document.createElement('p');info.textContent='Loading booking requests…';root.append(info); const text=(tag,value,cls)=>{const e=document.createElement(tag);e.textContent=value??'—';if(cls)e.className=cls;return e;}; try{ const {data:bookings,error}=await supabase.from('nesapp_client_bookings').select('id,client_id,venue_id,booking_date,start_time,end_time,number_of_officers,security_role,special_requirements,status,created_at').order('created_at',{ascending:false}).limit(100); if(error)throw error; const rows=bookings||[];root.replaceChildren(); if(!rows.length){root.append(text('p','No client booking requests yet.'));return;} const clientIds=[...new Set(rows.map(x=>x.client_id))],venueIds=[...new Set(rows.map(x=>x.venue_id))]; const [clientsResult,venuesResult]=await Promise.all([ supabase.from('nesapp_clients').select('id,company_name').in('id',clientIds), supabase.from('nesapp_client_venues').select('id,venue_name').in('id',venueIds) ]); // Booking visibility and decisions are always governed by Supabase RLS and the management RPC. const clients=new Map((clientsResult.data||[]).map(x=>[x.id,x.company_name])); const venues=new Map((venuesResult.data||[]).map(x=>[x.id,x.venue_name])); if(clientsResult.error||venuesResult.error)root.append(text('p','Some organisation or venue names could not be loaded.','muted')); const fmtDate=v=>v?String(v).split('-').reverse().join('/'):'—'; for(const b of rows){ const card=text('div',undefined,'verify-card'); card.append(text('h4',(clients.get(b.client_id)||'Client')+' · '+(venues.get(b.venue_id)||'Venue'))); card.append(text('p',fmtDate(b.booking_date)+' · '+String(b.start_time).slice(0,5)+'–'+String(b.end_time).slice(0,5)+' · '+b.number_of_officers+' officer(s) · '+b.security_role)); card.append(text('p','Status: '+b.status)); if(b.special_requirements)card.append(text('p','Requirements: '+b.special_requirements)); if(b.status==='pending'){ const actions=text('div',undefined,'bar'); for(const [label,nextStatus] of [['Approve','approved'],['Decline','declined']]){ const btn=text('button',label,nextStatus==='declined'?'secondary':'');btn.type='button'; btn.addEventListener('click',async()=>{ if(!window.confirm((nextStatus==='approved'?'Approve':'Decline')+' this booking request?'))return; [...actions.querySelectorAll('button')].forEach(x=>x.disabled=true); const message=text('p','Saving decision…','muted');card.append(message); try{ const {error:rpcError}=await supabase.rpc('nesapp_manage_booking_status',{p_booking_id:b.id,p_new_status:nextStatus}); if(rpcError)throw rpcError; message.textContent='Decision saved. Refreshing bookings…';await loadManagementClientBookings(); }catch(e){message.textContent='Unable to update booking: '+(e?.message||'Unknown error');[...actions.querySelectorAll('button')].forEach(x=>x.disabled=false);} });actions.append(btn); }card.append(actions); } root.append(card); } }catch(e){root.replaceChildren(text('p','Unable to load client bookings: '+(e?.message||'Unknown error')));} } document.getElementById('refreshClientBookingManagement')?.addEventListener('click',loadManagementClientBookings); // Expiry alerts are calculated in the browser when the portal is opened/refreshed. // No background emails or notifications are sent by this feature. function siaExpiryState(dateString){ if(!dateString||!/^\d{4}-\d{2}-\d{2}$/.test(dateString))return {kind:'missing',days:null,label:'Expiry date missing',level:'urgent'}; const [y,m,d]=dateString.split('-').map(Number); const expiry=Date.UTC(y,m-1,d);const check=new Date(expiry); if(check.getUTCFullYear()!==y||check.getUTCMonth()!==m-1||check.getUTCDate()!==d)return {kind:'missing',days:null,label:'Invalid expiry date',level:'urgent'}; const now=new Date();const today=Date.UTC(now.getFullYear(),now.getMonth(),now.getDate()); const days=Math.round((expiry-today)/86400000); if(days<0)return {kind:'expired',days,label:'Expired '+Math.abs(days)+' day(s) ago',level:'urgent'}; if(days===0)return {kind:'expired',days,label:'Expires today',level:'urgent'}; if(days<=7)return {kind:'due',days,label:'Urgent: '+days+' day(s) remaining',level:'urgent'}; if(days<=14)return {kind:'due',days,label:days+' days remaining (14-day alert)',level:'warning'}; if(days<=30)return {kind:'due',days,label:days+' days remaining (30-day alert)',level:'warning'}; if(days<=60)return {kind:'due',days,label:days+' days remaining (60-day alert)',level:'warning'}; const threshold=new Date(today);threshold.setUTCMonth(threshold.getUTCMonth()+5); if(expiry<=threshold.getTime())return {kind:'due',days,label:days+' days remaining (within 5 months)',level:'warning'}; return {kind:'valid-date',days,label:days+' days until expiry',level:'normal'}; } function renderEmployeeSiaAlert(profile){ const state=siaExpiryState(profile?.sia_expiry_date); for(const id of ['employeeSiaAlerts','employeeSiaPersonalAlert']){ const target=$(id);if(!target)continue;target.replaceChildren(); const box=document.createElement('div');box.className='sia-alert '+state.level; const title=document.createElement('strong');title.textContent='SIA licence: '+state.label; const desc=document.createElement('p');desc.textContent=profile?.sia_expiry_date?'Recorded expiry: '+profile.sia_expiry_date+'. ':'No SIA licence expiry date has been entered. '; const note=document.createElement('small');note.textContent='This is an expiry reminder only. Your licence status must be checked separately with the SIA.'; box.append(title,desc,note);target.append(box); } } async function loadManagementSiaAlerts(){ const target=$('managementSiaAlerts');if(!target)return;target.textContent='Loading SIA expiry dates…'; try{ const [{data:roles,error:re},{data:profiles,error:pe}]=await Promise.all([ supabase.from('staff_roles').select('user_id,role').eq('role','employee').limit(1000), supabase.from('staff_profiles').select('user_id,full_name,sia_licence_number,sia_expiry_date').limit(1000) ]); if(re||pe)throw re||pe; const ids=new Set((roles||[]).map(x=>x.user_id)); const rows=(profiles||[]).filter(x=>ids.has(x.user_id)).map(p=>({...p,expiry:siaExpiryState(p.sia_expiry_date)})); rows.sort((a,b)=>{const rank={expired:0,missing:1,due:2,'valid-date':3};return rank[a.expiry.kind]-rank[b.expiry.kind]||(a.expiry.days??-99999)-(b.expiry.days??-99999)||String(a.full_name).localeCompare(String(b.full_name))}); target.replaceChildren();const summary=document.createElement('div');summary.className='sia-summary'; for(const [label,num] of [['Expired / today',rows.filter(x=>x.expiry.kind==='expired').length],['Within 5 months',rows.filter(x=>x.expiry.kind==='due').length],['Missing date',rows.filter(x=>x.expiry.kind==='missing').length],['Beyond 5 months',rows.filter(x=>x.expiry.kind==='valid-date').length]]){const span=document.createElement('span');span.textContent=label+': '+num;summary.append(span)}target.append(summary); const controls=document.createElement('div');controls.className='onboard-filter';const search=document.createElement('input');search.className='field';search.type='search';search.placeholder='Search employee';const filter=document.createElement('select');filter.className='field';for(const [value,label] of [['all','All employees'],['attention','Needs attention (within 5 months)'],['expired','Expired / today'],['missing','Missing expiry date'],['valid-date','More than 5 months']]){const o=document.createElement('option');o.value=value;o.textContent=label;filter.append(o)}const exportBtn=document.createElement('button');exportBtn.type='button';exportBtn.className='secondary';exportBtn.textContent='Export expiry list (CSV)'; exportBtn.addEventListener('click',()=>{ const q=search.value.toLowerCase().trim(); const selected=rows.filter(r=>String(r.full_name||'').toLowerCase().includes(q)&&(filter.value==='all'||filter.value==='attention'&&['expired','due','missing'].includes(r.expiry.kind)||r.expiry.kind===filter.value)); const csvSafe=v=>{const str=String(v??'');const safe=/^[\s]*[=+\-@]/.test(str)?"'"+str:str;return '"'+safe.replace(/"/g,'""')+'"'}; const data=[['Employee','SIA licence number','Recorded expiry','Days remaining','Alert'],...selected.map(r=>[r.full_name||'Unnamed employee',r.sia_licence_number||'',r.sia_expiry_date||'',r.expiry.days===null?'':r.expiry.days,r.expiry.label])]; const csv='\uFEFF'+data.map(row=>row.map(csvSafe).join(',')).join('\r\n'); const blob=new Blob([csv],{type:'text/csv;charset=utf-8'});const url=globalThis.URL.createObjectURL(blob); const link=document.createElement('a');link.href=url;link.download='north-east-security-sia-expiry-'+new Date().toISOString().slice(0,10)+'.csv';document.body.append(link);link.click();link.remove();globalThis.URL.revokeObjectURL(url); }); controls.append(search,filter,exportBtn);target.append(controls); const wrap=document.createElement('div');wrap.className='compliance-table-wrap';const table=document.createElement('table');table.className='compliance-table';const head=document.createElement('thead');const hr=document.createElement('tr');for(const label of ['Employee','Recorded expiry','Days remaining','Alert']){const th=document.createElement('th');th.textContent=label;hr.append(th)}head.append(hr);table.append(head);const body=document.createElement('tbody');table.append(body);wrap.append(table);target.append(wrap); function render(){body.replaceChildren();const q=search.value.toLowerCase().trim();const selected=rows.filter(r=>String(r.full_name||'').toLowerCase().includes(q)&&(filter.value==='all'||filter.value==='attention'&&['expired','due','missing'].includes(r.expiry.kind)||r.expiry.kind===filter.value));for(const r of selected){const tr=document.createElement('tr');for(const val of [r.full_name||'Unnamed employee',r.sia_expiry_date||'Not entered',r.expiry.days===null?'—':String(r.expiry.days),r.expiry.label]){const td=document.createElement('td');td.textContent=val;tr.append(td)}body.append(tr)}if(!selected.length){const tr=document.createElement('tr');const td=document.createElement('td');td.colSpan=4;td.textContent='No matching employee records.';tr.append(td);body.append(tr)}} search.addEventListener('input',render);filter.addEventListener('change',render);render(); const note=document.createElement('p');note.className='muted';note.textContent='In-app alerts start 5 calendar months before expiry. Export downloads the currently filtered list. Alerts are calculated on opening or refreshing the dashboard. These dates are self-reported and do not confirm an active SIA licence. Maximum 1,000 records per query.';target.append(note); }catch(err){target.textContent='Unable to load SIA alerts: '+err.message} } $('refreshSiaAlerts').addEventListener('click',loadManagementSiaAlerts); async function loadPersonal(){const {data:{user}}=await supabase.auth.getUser();if(!user)return;const {data,error}=await supabase.from('staff_profiles').select('sia_licence_number,sia_expiry_date,id_document_type').eq('user_id',user.id).maybeSingle();if(error){$('personalStatus').textContent='Could not load your details: '+error.message;return;}if(data){$('mySiaNumber').value=data.sia_licence_number||'';$('mySiaExpiry').value=data.sia_expiry_date||'';$('myIdType').value=data.id_document_type||'';}renderEmployeeSiaAlert(data);} $('personalForm').addEventListener('submit',async e=>{e.preventDefault();const b=$('savePersonalBtn'),status=$('personalStatus');b.disabled=true;status.textContent='Saving…';try{const {data:{user},error:userError}=await supabase.auth.getUser();if(userError||!user)throw new Error('Please sign in again');const {error}=await supabase.from('staff_profiles').update({sia_licence_number:$('mySiaNumber').value||null,sia_expiry_date:$('mySiaExpiry').value||null,id_document_type:$('myIdType').value||null}).eq('user_id',user.id);if(error)throw error;status.className='status ok';status.textContent='Your personal details have been saved.';await loadProgress();await loadPersonal();}catch(err){status.className='status error';status.textContent='Unable to save: '+err.message;}finally{b.disabled=false;}}); const DOCUMENT_TYPES={ 'id-front':{input:'idFrontFile',status:'idFrontStatus',prefix:'id-front',needsId:true}, 'id-back':{input:'idBackFile',status:'idBackStatus',prefix:'id-back',needsId:true}, address:{input:'addressFile',status:'addressStatus',prefix:'address'}, profile:{input:'profileFile',status:'profileStatus',prefix:'profile',imageOnly:true}, 'sia-badge':{input:'siaBadgeFile',status:'siaBadgeStatus',prefix:'sia-badge',imageOnly:true} }; const VERIFY_LABELS={'id-front':'Photo ID — front','id-back':'Photo ID — back','address':'Proof of address','profile':'Profile photograph','sia-badge':'SIA badge photograph'}; function verifyBadge(status){const span=document.createElement('span');span.className='verify-status '+(status==='verified'?'verified':status==='rejected'?'rejected':'pending');span.textContent=status==='verified'?'✓ Verified':status==='rejected'?'✕ Rejected — action required':'Pending review';return span} async function listLatestDocuments(userId){ const result=[]; for(const folder of Object.keys(VERIFY_LABELS)){ const {data,error}=await supabase.storage.from('employee-documents').list(userId+'/'+folder,{limit:100,sortBy:{column:'created_at',order:'desc'}}); if(error)throw error; const file=(data||[]).find(f=>f.id&&f.name); if(file)result.push({folder,path:userId+'/'+folder+'/'+file.name,name:file.name,created:file.created_at||''}); } return result; } async function loadEmployeeDocumentReviews(){ const target=$('employeeDocumentReviews');if(!target)return;target.textContent='Loading review status…'; try{ const {data:{user},error:ue}=await supabase.auth.getUser();if(ue||!user)throw new Error('Please sign in again'); const [docs,{data:reviews,error:re}]=await Promise.all([listLatestDocuments(user.id),supabase.from('employee_document_reviews').select('file_path,status,rejection_reason,reviewed_at').eq('employee_id',user.id)]); if(re)throw re; const map=new Map((reviews||[]).map(r=>[r.file_path,r]));target.replaceChildren(); for(const folder of Object.keys(VERIFY_LABELS)){ const doc=docs.find(d=>d.folder===folder),review=doc?map.get(doc.path):null; const line=document.createElement('div');line.className='verify-row';const name=document.createElement('strong');name.textContent=VERIFY_LABELS[folder]+':';line.append(name); if(!doc){const empty=document.createElement('span');empty.textContent='Not uploaded';line.append(empty)}else{ line.append(verifyBadge(review?.status)); if(review?.status==='rejected'&&review.rejection_reason){const why=document.createElement('span');why.textContent='Reason: '+review.rejection_reason;line.append(why)} }target.append(line); } }catch(e){target.textContent='Unable to load verification status: '+e.message} } async function loadManagementDocumentReviews(){ const target=$('managementDocumentReviews');if(!target)return;target.textContent='Loading employee documents…'; try{ const [{data:roles,error:roleError},{data:profiles,error:profileError},{data:reviews,error:reviewError}]=await Promise.all([ supabase.from('staff_roles').select('user_id').eq('role','employee').limit(250), supabase.from('staff_profiles').select('user_id,full_name').limit(250), supabase.from('employee_document_reviews').select('file_path,status,rejection_reason,reviewed_at,reviewed_by') ]); for(const error of [roleError,profileError,reviewError])if(error)throw error; const people=new Set((roles||[]).map(x=>x.user_id)),names=new Map((profiles||[]).map(x=>[x.user_id,x.full_name||'Unnamed employee'])); const docs=[];const errors=[]; for(const userId of people){try{const uploaded=await listLatestDocuments(userId);for(const file of uploaded)docs.push({...file,employeeId:userId,employeeName:names.get(userId)||'Employee (profile missing)'})}catch(e){errors.push((names.get(userId)||'Employee')+': '+e.message)}} const reviewMap=new Map((reviews||[]).map(r=>[r.file_path,r])); target.replaceChildren();const summary=document.createElement('p');summary.className='muted';summary.textContent=docs.length+' current uploaded document(s) · '+docs.filter(d=>reviewMap.get(d.path)?.status==='verified').length+' verified · '+docs.filter(d=>reviewMap.get(d.path)?.status==='rejected').length+' rejected · '+docs.filter(d=>!reviewMap.has(d.path)).length+' awaiting review';target.append(summary); if(errors.length){const warn=document.createElement('p');warn.className='status error';warn.textContent='Some files could not be listed: '+errors.join(' | ');target.append(warn)} const search=document.createElement('input');search.className='field';search.type='search';search.placeholder='Search employee';search.setAttribute('aria-label','Search document reviews by employee');target.append(search); const filter=document.createElement('select');filter.className='field';for(const [v,t] of [['all','All statuses'],['pending','Pending review'],['verified','Verified'],['rejected','Rejected']]){const o=document.createElement('option');o.value=v;o.textContent=t;filter.append(o)}target.append(filter); const items=document.createElement('div');target.append(items); function render(){items.replaceChildren();const q=search.value.trim().toLowerCase();let visible=0; for(const doc of docs){const review=reviewMap.get(doc.path),status=review?.status||'pending';if(!doc.employeeName.toLowerCase().includes(q)||(filter.value!=='all'&&filter.value!==status))continue;visible++; const card=document.createElement('div');card.className='verify-card';const title=document.createElement('h4');title.textContent=doc.employeeName+' — '+VERIFY_LABELS[doc.folder];card.append(title,verifyBadge(status)); const date=document.createElement('p');date.className='muted';date.textContent='Uploaded: '+(doc.created?new Date(doc.created).toLocaleString('en-GB'):'Date unavailable')+(review?.reviewed_at?' · Reviewed: '+new Date(review.reviewed_at).toLocaleString('en-GB'):'');card.append(date); if(review?.rejection_reason){const reason=document.createElement('p');reason.textContent='Previous rejection reason: '+review.rejection_reason;card.append(reason)} const controls=document.createElement('div');controls.className='verify-row';const open=document.createElement('button');open.type='button';open.className='secondary';open.textContent='View private document';open.addEventListener('click',async()=>{open.disabled=true;try{const {data,error}=await supabase.storage.from('employee-documents').createSignedUrl(doc.path,90);if(error||!data?.signedUrl)throw error||new Error('Could not create link');window.open(data.signedUrl,'_blank','noopener,noreferrer')}catch(e){alert('Unable to view document: '+e.message)}finally{open.disabled=false}});controls.append(open); const approve=document.createElement('button');approve.type='button';approve.textContent='Verify document';controls.append(approve); const reject=document.createElement('button');reject.type='button';reject.className='secondary';reject.textContent='Reject document';controls.append(reject);card.append(controls); const reason=document.createElement('textarea');reason.className='field verify-reason';reason.placeholder='Reason required when rejecting (visible to employee)';reason.maxLength=1000;card.append(reason); const message=document.createElement('p');message.className='status';card.append(message); async function saveReview(next){if(next==='rejected'&&!reason.value.trim()){message.className='status error';message.textContent='Enter a rejection reason first.';return} approve.disabled=true;reject.disabled=true;message.textContent='Saving review…';try{const {data:{user},error:ue}=await supabase.auth.getUser();if(ue||!user)throw new Error('Please sign in again'); const payload={employee_id:doc.employeeId,file_path:doc.path,document_type:doc.folder,status:next,rejection_reason:next==='rejected'?reason.value.trim():null,reviewed_by:user.id,reviewed_at:new Date().toISOString()}; const {error}=await supabase.from('employee_document_reviews').upsert(payload,{onConflict:'file_path'});if(error)throw error; reviewMap.set(doc.path,payload);render();summary.textContent=docs.length+' current uploaded document(s) · '+docs.filter(d=>reviewMap.get(d.path)?.status==='verified').length+' verified · '+docs.filter(d=>reviewMap.get(d.path)?.status==='rejected').length+' rejected · '+docs.filter(d=>!reviewMap.has(d.path)).length+' awaiting review'; }catch(e){message.className='status error';message.textContent='Unable to save: '+e.message;approve.disabled=false;reject.disabled=false}} approve.addEventListener('click',()=>saveReview('verified'));reject.addEventListener('click',()=>saveReview('rejected'));items.append(card); }if(!visible){const p=document.createElement('p');p.className='muted';p.textContent='No matching uploaded documents.';items.append(p)} }search.addEventListener('input',render);filter.addEventListener('change',render);render(); }catch(e){target.textContent='Unable to load document verification: '+e.message+' — check that the included Supabase SQL has been run.'} } $('refreshDocumentReviews').addEventListener('click',loadManagementDocumentReviews); const ALLOWED_MIME=new Set(['image/jpeg','image/png','image/webp','application/pdf']); const EXTENSIONS={'image/jpeg':'jpg','image/png':'png','image/webp':'webp','application/pdf':'pdf'}; async function loadUploads(){ const area=$('uploadsList');area.textContent='Checking uploaded documents…'; try{ if(!supabase)throw new Error('Supabase connection is not ready. Please refresh and try again.'); const {data:{user},error:authError}=await supabase.auth.getUser();if(authError||!user)throw new Error('Sign in again'); const {data,error}=await supabase.storage.from('employee-documents').list(user.id,{limit:100}); if(error)throw error; const folders=(data||[]).filter(f=>Object.values(DOCUMENT_TYPES).some(t=>t.prefix===f.name)); const lines=[]; for(const folder of folders){const {data:files,error:listError}=await supabase.storage.from('employee-documents').list(user.id+'/'+folder.name,{limit:100,sortBy:{column:'created_at',order:'desc'}});if(listError)throw listError; if(files?.length)lines.push(folder.name+': '+files.length+' uploaded file(s)');} area.textContent=lines.length?lines.join(' | '):'No uploaded documents yet.'; await Promise.all([showPrivateImage(user.id,'profile','employeePhotoPreview','photoPreviewStatus'),showPrivateImage(user.id,'sia-badge','siaBadgePreview','siaBadgePreviewStatus')]);await loadProgress();await loadEmployeeDocumentReviews(); }catch(e){area.textContent='Could not list uploads: '+e.message;} } async function showPrivateImage(userId,folder,imgId,statusId){ const img=$(imgId),status=$(statusId); const {data:files,error}=await supabase.storage.from('employee-documents').list(userId+'/'+folder,{limit:100,sortBy:{column:'created_at',order:'desc'}}); if(error){status.textContent='Unable to load image: '+error.message;img.classList.add('hidden');return;} const file=(files||[]).find(f=>/\.(jpg|jpeg|png|webp)$/i.test(f.name)); if(!file){status.textContent='No photograph uploaded yet.';img.classList.add('hidden');return;} const {data,error:linkError}=await supabase.storage.from('employee-documents').createSignedUrl(userId+'/'+folder+'/'+file.name,120); if(linkError||!data?.signedUrl){status.textContent='Unable to display private image.';img.classList.add('hidden');return;} img.src=data.signedUrl;img.classList.remove('hidden');status.textContent='Most recent uploaded photograph'; } async function uploadDocument(kind,button){ const config=DOCUMENT_TYPES[kind],input=$(config.input),status=$(config.status),file=input.files?.[0]; status.className='status'; if(!file){status.textContent='Choose a file first.';return;} if(!ALLOWED_MIME.has(file.type)||(config.imageOnly&&file.type==='application/pdf')){status.textContent='Unsupported file type.';return;} if(file.size>10*1024*1024||file.size===0){status.textContent='File must be between 1 byte and 10 MB.';return;} if(config.needsId&&!$('myIdType').value){status.textContent='Choose passport or driving licence first.';return;} if(kind==='id-back'&&$('myIdType').value!=='driving_licence'){status.textContent='The back image is for driving licences only.';return;} button.disabled=true;status.textContent='Uploading securely…'; try{ const {data:{user},error:authError}=await supabase.auth.getUser();if(authError||!user)throw new Error('Please sign in again'); const path=user.id+'/'+config.prefix+'/'+crypto.randomUUID()+'.'+EXTENSIONS[file.type]; const {error}=await supabase.storage.from('employee-documents').upload(path,file,{contentType:file.type,upsert:false,cacheControl:'0'}); if(error)throw error; status.className='status ok';status.textContent='Uploaded. Management verification is still required.';input.value='';await loadUploads(); }catch(e){status.className='status error';status.textContent='Upload failed: '+e.message;}finally{button.disabled=false;} } for(const button of document.querySelectorAll('[data-upload]'))button.addEventListener('click',()=>uploadDocument(button.dataset.upload,button)); $('refreshUploads').addEventListener('click',loadUploads); // Toolbox talks: only published talks are visible to employees. No client-side admin key. let employeeTalkCache=[]; const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); async function loadEmployeeTalks(){ const area=$('employeeTalks');area.textContent='Loading toolbox talks…'; try{ const {data:{user},error:ue}=await supabase.auth.getUser();if(ue||!user)throw new Error('Please sign in again'); const [{data:talks,error:te},{data:acks,error:ae}]=await Promise.all([ supabase.from('toolbox_talks').select('id,title,content,version,created_at').eq('is_published',true).order('created_at',{ascending:false}), supabase.from('toolbox_acknowledgements').select('talk_id,acknowledged_at').eq('user_id',user.id) ]); if(te)throw te;if(ae)throw ae; const acknowledged=new Map((acks||[]).map(a=>[a.talk_id,a.acknowledged_at])); employeeTalkCache=(talks||[]).map(t=>({...t,acknowledged_at:acknowledged.get(t.id)||null})); area.replaceChildren(); if(!employeeTalkCache.length){area.textContent='No toolbox talks have been published yet.';} for(const t of employeeTalkCache){ const card=document.createElement('div');card.className='talk-card'; const heading=document.createElement('h4');heading.textContent=t.title+' · v'+t.version;card.append(heading); const state=document.createElement('p');state.className='subtle';state.textContent=t.acknowledged_at?'✓ Acknowledged '+new Date(t.acknowledged_at).toLocaleString('en-GB'):'Outstanding — please read and acknowledge';card.append(state); const details=document.createElement('details');const summary=document.createElement('summary');summary.textContent='Read full toolbox talk';details.append(summary); const body=document.createElement('div');body.className='talk-body';body.textContent=t.content;details.append(body);card.append(details); if(!t.acknowledged_at){ const label=document.createElement('label');label.style.display='block';label.style.marginTop='12px'; const check=document.createElement('input');check.type='checkbox';check.style.marginRight='8px';label.append(check,document.createTextNode('I confirm I have read and understood this toolbox talk'));card.append(label); const button=document.createElement('button');button.type='button';button.textContent='Submit acknowledgement';button.disabled=true;check.addEventListener('change',()=>button.disabled=!check.checked); const msg=document.createElement('p');msg.className='status';button.addEventListener('click',async()=>{ button.disabled=true;msg.textContent='Saving acknowledgement…'; const {error}=await supabase.from('toolbox_acknowledgements').insert({talk_id:t.id,user_id:user.id}); if(error){msg.textContent='Could not save: '+error.message;button.disabled=false;return;} await loadEmployeeTalks(); });card.append(button,msg); } area.append(card); } await loadProgress(); }catch(e){area.textContent='Unable to load toolbox talks: '+e.message;} } function onboardingRow(container,title,done,detail){ const row=document.createElement('div');row.className='onboard-item'; const mark=document.createElement('span');mark.className='mark'+(done?'':' pending');mark.textContent=done?'✓':'○'; const content=document.createElement('div');const heading=document.createElement('strong');heading.textContent=title;const note=document.createElement('small');note.textContent=detail;content.append(heading,note);row.append(mark,content);container.append(row); } function onboardingStatus(done,total){return total>0&&done===total?'Submitted':done?'In progress':'Action required'} function renderEmployeeOnboarding(profile,docs,doneTalks,talkCount,policyComplete,policyTotal){ const target=$('employeeOnboarding');if(!target)return;target.replaceChildren(); const today=new Date();today.setHours(0,0,0,0); const validSia=!!(profile?.sia_licence_number&&profile?.sia_expiry_date&&new Date(profile.sia_expiry_date+'T00:00:00')>=today); const idDone=!!docs['id-front']&&(profile?.id_document_type!=='driving_licence'||!!docs['id-back']); const steps=[['SIA licence details',validSia,'A licence number and unexpired date are required; validity must be independently checked'],['SIA badge photo',!!docs['sia-badge'],'Upload a photograph of your SIA badge'],['Photo ID',idDone,'Upload the required front and back images'],['Proof of address',!!docs.address,'Upload your proof of address'],['Profile photograph',!!docs.profile,'Upload a profile photograph'],['Company policies',policyTotal>0&&policyComplete===policyTotal,policyComplete+' of '+policyTotal+' acknowledged'],['Toolbox talks',talkCount>0&&doneTalks===talkCount,doneTalks+' of '+talkCount+' acknowledged']]; const completed=steps.filter(x=>x[1]).length; const heading=document.createElement('div');heading.className='onboard-head';const h=document.createElement('h3');h.textContent='My onboarding checklist';const badge=document.createElement('span');badge.className='onboard-badge'+(completed===steps.length?' done':'');badge.textContent=onboardingStatus(completed,steps.length);heading.append(h,badge);target.append(heading); const summary=document.createElement('p');summary.className='muted';summary.textContent=completed+' of '+steps.length+' submission tasks complete. Completion does not mean management has verified documents or authorised you to work.';target.append(summary); const bar=document.createElement('div');bar.className='progress-track';const fill=document.createElement('div');fill.className='progress-fill';fill.style.width=Math.round(100*completed/steps.length)+'%';bar.append(fill);target.append(bar); for(const [name,done,detail] of steps)onboardingRow(target,name,done,detail); const note=document.createElement('p');note.className='muted';note.textContent='Management must separately verify identity, right to work, SIA licence, required training and assignment readiness. These checks are not automatically marked complete.';target.append(note); } async function loadManagementOnboarding(){ const target=$('managementOnboarding');if(!target)return;target.textContent='Loading employee onboarding records…'; try{ const [{data:roles,error:re},{data:profiles,error:pe},{data:policies,error:ce},{data:pa,error:ae},{data:talks,error:te},{data:ta,error:tae}]=await Promise.all([ supabase.from('staff_roles').select('user_id,role').eq('role','employee').limit(250), supabase.from('staff_profiles').select('user_id,full_name,sia_licence_number,sia_expiry_date,id_document_type').limit(250), supabase.from('company_policies').select('id').eq('is_published',true), supabase.from('policy_acknowledgements').select('employee_id,policy_id'), supabase.from('toolbox_talks').select('id').eq('is_published',true), supabase.from('toolbox_acknowledgements').select('user_id,talk_id') ]); for(const e of [re,pe,ce,ae,te,tae])if(e)throw e; const employees=new Set((roles||[]).map(r=>r.user_id));const publishedPolicies=new Set((policies||[]).map(p=>p.id));const publishedTalks=new Set((talks||[]).map(t=>t.id)); const policyMap=new Map(),talkMap=new Map(); for(const a of pa||[])if(publishedPolicies.has(a.policy_id)){if(!policyMap.has(a.employee_id))policyMap.set(a.employee_id,new Set());policyMap.get(a.employee_id).add(a.policy_id)} for(const a of ta||[])if(publishedTalks.has(a.talk_id)){if(!talkMap.has(a.user_id))talkMap.set(a.user_id,new Set());talkMap.get(a.user_id).add(a.talk_id)} const today=new Date();today.setHours(0,0,0,0); const rows=(profiles||[]).filter(p=>employees.has(p.user_id)).map(p=>{ const sia=!!(p.sia_licence_number&&p.sia_expiry_date&&new Date(p.sia_expiry_date+'T00:00:00')>=today); const policyCount=policyMap.get(p.user_id)?.size||0,talkCount=talkMap.get(p.user_id)?.size||0; const policiesDone=publishedPolicies.size>0&&policyCount===publishedPolicies.size; const talksDone=publishedTalks.size>0&&talkCount===publishedTalks.size; const completed=[sia,!!p.id_document_type,policiesDone,talksDone].filter(Boolean).length; return {name:p.full_name||'Unnamed employee',sia,identity:!!p.id_document_type,policyCount,talkCount,completed,status:onboardingStatus(completed,4)}; }); target.replaceChildren();const stats=document.createElement('div');stats.className='compliance-grid'; for(const [label,value] of [['Employees with profiles',rows.length],['Submission checklist complete',rows.filter(r=>r.completed===4).length],['In progress',rows.filter(r=>r.status==='In progress').length],['Action required',rows.filter(r=>r.status==='Action required').length]]){const b=document.createElement('div');b.className='compliance-stat';const n=document.createElement('strong');n.textContent=value;const l=document.createElement('small');l.textContent=label;b.append(n,l);stats.append(b)}target.append(stats); const note=document.createElement('p');note.className='muted';note.textContent='Preliminary tracker: 4 available data checks only (SIA fields, ID type, policy and toolbox acknowledgements). Document uploads, licence validity, right to work, handbook, uniform and training evidence are NOT verified here. No employee is automatically cleared for work.';target.append(note); const controls=document.createElement('div');controls.className='onboard-filter';const search=document.createElement('input');search.className='field';search.type='search';search.placeholder='Search employee';const filter=document.createElement('select');filter.className='field';for(const v of ['All statuses','Submitted','In progress','Action required']){const o=document.createElement('option');o.textContent=v;o.value=v;filter.append(o)}controls.append(search,filter);target.append(controls); const wrap=document.createElement('div');wrap.className='compliance-table-wrap';const table=document.createElement('table');table.className='compliance-table';const head=document.createElement('thead');const hr=document.createElement('tr');for(const name of ['Employee','SIA fields','ID type','Policies','Toolbox talks','Submission status']){const th=document.createElement('th');th.textContent=name;hr.append(th)}head.append(hr);table.append(head);const body=document.createElement('tbody');table.append(body);wrap.append(table);target.append(wrap); function render(){body.replaceChildren();const q=search.value.trim().toLowerCase();for(const r of rows.filter(r=>r.name.toLowerCase().includes(q)&&(filter.value==='All statuses'||filter.value===r.status))){const tr=document.createElement('tr');for(const value of [r.name,r.sia?'Entered':'Missing',r.identity?'Selected':'Missing',r.policyCount+'/'+publishedPolicies.size,r.talkCount+'/'+publishedTalks.size,r.status]){const td=document.createElement('td');td.textContent=value;tr.append(td)}body.append(tr)}}search.addEventListener('input',render);filter.addEventListener('change',render);render(); }catch(e){target.textContent='Unable to load onboarding tracker: '+e.message} } $('refreshOnboardingManagement').addEventListener('click',loadManagementOnboarding); async function loadProgress(){ const list=$('progressItems');if(!list)return; try{ const {data:{user},error:ue}=await supabase.auth.getUser();if(ue||!user)throw new Error('Please sign in again'); const {data:profile,error:pe}=await supabase.from('staff_profiles').select('sia_licence_number,sia_expiry_date,id_document_type').eq('user_id',user.id).maybeSingle();if(pe)throw pe;renderEmployeeSiaAlert(profile); const folders=['sia-badge','id-front','id-back','address','profile']; const checks=await Promise.all(folders.map(async f=>{const {data,error}=await supabase.storage.from('employee-documents').list(user.id+'/'+f,{limit:1});if(error)throw error;return [f,(data||[]).some(x=>x.id)];})); const docs=Object.fromEntries(checks); const idDone=!!docs['id-front'] && (profile?.id_document_type!=='driving_licence'||!!docs['id-back']); const {data:talks,error:te}=await supabase.from('toolbox_talks').select('id').eq('is_published',true);if(te)throw te; const {data:acks,error:ae}=await supabase.from('toolbox_acknowledgements').select('talk_id').eq('user_id',user.id);if(ae)throw ae; const doneIds=new Set((acks||[]).map(a=>a.talk_id)); const talkCount=(talks||[]).length;const doneTalks=(talks||[]).filter(t=>doneIds.has(t.id)).length; const {data:policies,error:polErr}=await supabase.from('company_policies').select('id').eq('is_published',true);if(polErr)throw polErr; const {data:policyAcks,error:paErr}=await supabase.from('policy_acknowledgements').select('policy_id').eq('employee_id',user.id);if(paErr)throw paErr; const policyDone=new Set((policyAcks||[]).map(a=>a.policy_id)); const policyTotal=(policies||[]).length;const policyComplete=(policies||[]).filter(p=>policyDone.has(p.id)).length; // Detailed employee policy completion dashboard, sourced from published policies only. const progressPanel=$('policyProgressDashboard');progressPanel.replaceChildren(); const metrics=document.createElement('div');metrics.className='compliance-grid'; for(const [label,value] of [['Policies acknowledged',policyComplete+' / '+policyTotal],['Policies outstanding',String(policyTotal-policyComplete)],['Policy completion',policyTotal?Math.round(policyComplete/policyTotal*100)+'%':'—']]){ const box=document.createElement('div');box.className='compliance-stat';const number=document.createElement('strong');number.textContent=value;const caption=document.createElement('small');caption.textContent=label;box.append(number,caption);metrics.append(box); } progressPanel.append(metrics); const policyTrack=document.createElement('div');policyTrack.className='progress-track';policyTrack.setAttribute('aria-label','Policy acknowledgement progress'); const policyFill=document.createElement('div');policyFill.className='progress-fill';policyFill.style.width=(policyTotal?policyComplete/policyTotal*100:0)+'%';policyTrack.append(policyFill);progressPanel.append(policyTrack); const {data:policyDetails,error:pdErr}=await supabase.from('company_policies').select('id,title,version').eq('is_published',true).order('title');if(pdErr)throw pdErr; const subtitle=document.createElement('h4');subtitle.textContent='Policy acknowledgement checklist';subtitle.className='compliance-subtitle';progressPanel.append(subtitle); if(!policyDetails?.length){const note=document.createElement('p');note.className='muted';note.textContent='No published policies yet.';progressPanel.append(note)} for(const policy of policyDetails||[]){const row=document.createElement('div');row.className='compliance-row';const name=document.createElement('span');name.textContent=policy.title+' · v'+policy.version;const state=document.createElement('span');const completed=policyDone.has(policy.id);state.className=completed?'compliance-done':'compliance-wait';state.textContent=completed?'✓ Acknowledged':'○ Outstanding';row.append(name,state);progressPanel.append(row)} renderEmployeeOnboarding(profile,docs,doneTalks,talkCount,policyComplete,policyTotal); const items=[ ['SIA licence details',!!(profile?.sia_licence_number&&profile?.sia_expiry_date)], ['SIA badge photograph',!!docs['sia-badge']], ['Passport / driving licence photograph'+(profile?.id_document_type==='driving_licence'?' (front and back)':''),idDone], ['Proof of address',!!docs.address], ['Profile photograph',!!docs.profile], ['Toolbox talks ('+doneTalks+'/'+talkCount+' acknowledged)',talkCount>0&&doneTalks===talkCount], ['Company policies ('+policyComplete+'/'+policyTotal+' acknowledged)',policyTotal>0&&policyComplete===policyTotal] ]; const count=items.filter(x=>x[1]).length; $('progressCount').textContent=count+' of '+items.length+' actions completed ('+Math.round(100*count/items.length)+'%). Uploaded documents are not yet management-verified.'; $('progressFill').style.width=(100*count/items.length)+'%';list.replaceChildren(); for(const [name,complete] of items){const row=document.createElement('div');row.className='progress-item';const icon=document.createElement('span');icon.className='progress-icon';icon.textContent=complete?'✓':'○';icon.style.color=complete?'#168354':'#64748b';const label=document.createElement('div');label.textContent=name;const detail=document.createElement('div');detail.className='subtle';detail.textContent=complete?'Complete / submitted':'Action outstanding';label.append(detail);row.append(icon,label);list.append(row);} }catch(e){$('progressCount').textContent='Unable to load progress: '+e.message;} } async function loadManagementTalks(){ const target=$('managementTalkProgress');target.textContent='Loading completion records…'; try{ const [{data:talks,error:te},{data:profiles,error:pe},{data:acks,error:ae}]=await Promise.all([ supabase.from('toolbox_talks').select('id,title,version,created_at').eq('is_published',true).order('created_at',{ascending:false}), supabase.from('staff_profiles').select('user_id,full_name').order('full_name').limit(250), supabase.from('toolbox_acknowledgements').select('talk_id,user_id,acknowledged_at') ]); if(te)throw te;if(pe)throw pe;if(ae)throw ae; target.replaceChildren();const staff=profiles||[]; if(!talks?.length){target.textContent='No published toolbox talks yet.';return;} const intro=document.createElement('p');intro.className='subtle';intro.textContent='Completion is shown for the '+staff.length+' profiles currently visible to management (maximum 250). This is not a verified employee-only roster.';target.append(intro); for(const talk of talks){const box=document.createElement('div');box.className='talk-card';const h=document.createElement('h4');h.textContent=talk.title+' · v'+talk.version;box.append(h); const acknowledged=new Set((acks||[]).filter(a=>a.talk_id===talk.id).map(a=>a.user_id));const count=staff.filter(p=>acknowledged.has(p.user_id)).length; const p=document.createElement('p');p.textContent=count+' / '+staff.length+' visible profiles acknowledged';box.append(p); const outstanding=staff.filter(p=>!acknowledged.has(p.user_id)).map(p=>p.full_name||'Unnamed profile'); const details=document.createElement('details');const summary=document.createElement('summary');summary.textContent='View outstanding profiles';details.append(summary);const names=document.createElement('p');names.textContent=outstanding.join(', ')||'None';details.append(names);box.append(details);target.append(box); } }catch(e){target.textContent='Unable to load management progress: '+e.message;} } $('publishTalkForm').addEventListener('submit',async event=>{ event.preventDefault();const button=$('publishTalkBtn'),status=$('publishTalkStatus');button.disabled=true;status.textContent='Publishing…'; try{const {error}=await supabase.from('toolbox_talks').insert({title:$('talkTitle').value.trim(),content:$('talkContent').value.trim(),version:1,is_published:true});if(error)throw error;status.className='status ok';status.textContent='Published to all employees.';$('publishTalkForm').reset();await loadManagementTalks();} catch(e){status.className='status error';status.textContent='Could not publish: '+e.message;}finally{button.disabled=false;} }); // Company policy access: the bucket is private; signed links expire after 2 minutes. async function openPolicyFile(path){ const {data,error}=await supabase.storage.from('company-policies').createSignedUrl(path,120); if(error||!data?.signedUrl)throw new Error(error?.message||'Could not create a secure document link'); window.open(data.signedUrl,'_blank','noopener,noreferrer'); } async function loadEmployeePolicies(){ const target=$('employeePolicies');target.textContent='Loading company policies…'; try{ const {data:{user}}=await supabase.auth.getUser();if(!user)throw new Error('Please sign in again'); const [{data:policies,error:pe},{data:acks,error:ae}]=await Promise.all([ supabase.from('company_policies').select('id,title,version,file_path,created_at').eq('is_published',true).order('title'), supabase.from('policy_acknowledgements').select('policy_id,acknowledged_at').eq('employee_id',user.id) ]); if(pe)throw pe;if(ae)throw ae; target.replaceChildren();if(!policies?.length){target.textContent='No company policies have been published yet.';return;} const ackMap=new Map((acks||[]).map(a=>[a.policy_id,a.acknowledged_at])); for(const policy of policies){ const card=document.createElement('div');card.className='talk-card'; const title=document.createElement('h4');title.textContent=policy.title+' · version '+policy.version;card.append(title); const status=document.createElement('p');status.className='muted'; const date=ackMap.get(policy.id);if(date){status.className='policy-ack-success';const tick=document.createElement('span');tick.className='policy-ack-tick';tick.textContent='✓';tick.setAttribute('aria-hidden','true');const label=document.createElement('span');label.textContent='Acknowledged · '+new Date(date).toLocaleString('en-GB');status.append(tick,label)}else{status.textContent='Not yet acknowledged'}card.append(status); const open=document.createElement('button');open.type='button';open.className='secondary';open.textContent='Open policy PDF'; open.addEventListener('click',async()=>{open.disabled=true;try{await openPolicyFile(policy.file_path)}catch(e){alert('Unable to open policy: '+e.message)}finally{open.disabled=false}});card.append(open); if(!date){ const label=document.createElement('label');label.style.display='block';label.style.marginTop='14px'; const check=document.createElement('input');check.type='checkbox';check.style.marginRight='8px';label.append(check,document.createTextNode('I confirm I have read and understood this policy.'));card.append(label); const ack=document.createElement('button');ack.type='button';ack.textContent='Acknowledge policy';ack.disabled=true;check.addEventListener('change',()=>ack.disabled=!check.checked); ack.addEventListener('click',async()=>{ack.disabled=true;const {error}=await supabase.from('policy_acknowledgements').insert({policy_id:policy.id,employee_id:user.id});if(error){alert('Unable to record acknowledgement: '+error.message);ack.disabled=false;return;}await loadEmployeePolicies();await loadProgress();});card.append(ack); } target.append(card); } }catch(e){target.textContent='Could not load company policies: '+e.message;} } let policyReportRows=[]; function csvSafe(value){const v=String(value??'');const safe=/^[=+@\-\t\r]/.test(v)?"'"+v:v;return '"'+safe.replace(/"/g,'""')+'"'} function downloadPolicyReport(){ const header=['Employee','Policy','Version','Status','Acknowledged at']; const lines=[header,...policyReportRows.map(r=>[r.employee,r.policy,r.version,r.status,r.date])].map(row=>row.map(csvSafe).join(',')); const blob=new Blob(['\uFEFF'+lines.join('\r\n')],{type:'text/csv;charset=utf-8'});const url=URL.createObjectURL(blob); const link=document.createElement('a');link.href=url;link.download='north-east-security-policy-compliance.csv';document.body.append(link);link.click();link.remove();URL.revokeObjectURL(url); } async function loadManagementPolicies(){ const target=$('managementPolicyProgress');target.textContent='Loading policy acknowledgements…'; try{ const [{data:policies,error:pe},{data:staff,error:se},{data:acks,error:ae}]=await Promise.all([ supabase.from('company_policies').select('id,title,version,is_published').order('title'), supabase.from('staff_profiles').select('user_id,full_name').order('full_name').limit(250), supabase.from('policy_acknowledgements').select('policy_id,employee_id,acknowledged_at') ]);if(pe)throw pe;if(se)throw se;if(ae)throw ae; target.replaceChildren();const published=(policies||[]).filter(p=>p.is_published);const people=staff||[]; if(!published.length){target.textContent='No published company policies yet.';return;} const ackMap=new Map((acks||[]).map(a=>[a.policy_id+'|'+a.employee_id,a.acknowledged_at])); policyReportRows=[]; for(const person of people)for(const policy of published){const date=ackMap.get(policy.id+'|'+person.user_id);policyReportRows.push({employee:person.full_name||'Unnamed profile',policy:policy.title,version:policy.version,status:date?'Acknowledged':'Outstanding',date:date?new Date(date).toLocaleString('en-GB'):''});} const total=policyReportRows.length;const done=policyReportRows.filter(r=>r.status==='Acknowledged').length; const metrics=document.createElement('div');metrics.className='compliance-grid'; for(const [label,value] of [['Staff profiles',String(people.length)],['Published policies',String(published.length)],['Acknowledged',done+' / '+total],['Overall completion',total?Math.round(done/total*100)+'%':'—']]){ const box=document.createElement('div');box.className='compliance-stat';const number=document.createElement('strong');number.textContent=value;const caption=document.createElement('small');caption.textContent=label;box.append(number,caption);metrics.append(box); }target.append(metrics); const note=document.createElement('p');note.className='subtle';note.textContent='Based on up to 250 visible staff profiles, including any management profiles. Counts are not a verified employee-only roster. Unpublished policies are excluded.';target.append(note); const controls=document.createElement('div');controls.className='compliance-controls'; const search=document.createElement('input');search.className='field';search.type='search';search.placeholder='Search employee or policy';search.setAttribute('aria-label','Search employee or policy'); const status=document.createElement('select');status.className='field';status.setAttribute('aria-label','Filter acknowledgement status'); for(const [value,label] of [['all','All statuses'],['Outstanding','Outstanding only'],['Acknowledged','Acknowledged only']]){const opt=document.createElement('option');opt.value=value;opt.textContent=label;status.append(opt)} const exportBtn=document.createElement('button');exportBtn.type='button';exportBtn.className='secondary';exportBtn.textContent='Export CSV';exportBtn.addEventListener('click',downloadPolicyReport); controls.append(search,status,exportBtn);target.append(controls); const summary=document.createElement('div');target.append(summary); const wrap=document.createElement('div');wrap.className='compliance-table-wrap';const table=document.createElement('table');table.className='compliance-table'; const head=document.createElement('thead');const tr=document.createElement('tr');for(const h of ['Employee','Policy','Version','Status','Acknowledged date']){const th=document.createElement('th');th.textContent=h;tr.append(th)}head.append(tr);table.append(head); const body=document.createElement('tbody');table.append(body);wrap.append(table);target.append(wrap); function render(){const q=search.value.trim().toLowerCase();const filtered=policyReportRows.filter(r=>(status.value==='all'||r.status===status.value)&&(!q||(r.employee+' '+r.policy).toLowerCase().includes(q))); summary.textContent=filtered.length+' record(s) shown · '+filtered.filter(r=>r.status==='Outstanding').length+' outstanding';body.replaceChildren(); for(const r of filtered){const row=document.createElement('tr');for(const value of [r.employee,r.policy,r.version,r.status,r.date||'—']){const td=document.createElement('td');td.textContent=value;if(value===r.status)td.className=r.status==='Acknowledged'?'compliance-done':'compliance-wait';row.append(td)}body.append(row)} }search.addEventListener('input',render);status.addEventListener('change',render);render(); }catch(e){target.textContent='Unable to load policy acknowledgements: '+e.message;} } $('refreshPolicyManagement').addEventListener('click',loadManagementPolicies); $('refreshProgress').addEventListener('click',async()=>{await loadEmployeeTalks();await loadEmployeePolicies();await loadProgress();}); $('refreshTalkManagement').addEventListener('click',loadManagementTalks); $('reloadStaff').addEventListener('click',loadStaff); $('staffForm').addEventListener('submit',async event=>{event.preventDefault();const button=$('createStaffBtn');button.disabled=true; const message=$('staffStatus');message.className='status';message.textContent='Sending invitation…'; try{const {data:{session}}=await supabase.auth.getSession();if(!session){message.textContent='Please sign in again.';return} const response=await fetch(SUPABASE_URL+'/functions/v1/create-employee',{method:'POST',headers:{'Content-Type':'application/json','apikey':PUBLISHABLE_KEY,'Authorization':'Bearer '+session.access_token},body:JSON.stringify({full_name:$('staffName').value,email:$('staffEmail').value})}); const result=await response.json();message.textContent=result.message||result.error||'Unexpected server response';message.className='status '+(response.ok?'ok':'error');if(response.ok){$('staffForm').reset();loadStaff()} }catch(e){message.className='status error';message.textContent='Could not reach the employee creation service. Check that the Edge Function is deployed.'}finally{button.disabled=false}}); $('passwordForm').addEventListener('submit',async event=>{event.preventDefault();const status=$('passwordStatus');const a=$('newPassword').value,b=$('confirmPassword').value;if(a!==b){status.textContent='Passwords do not match.';return}const button=$('savePasswordBtn');button.disabled=true;const {data:{session}}=await supabase.auth.getSession(); if(!session){button.disabled=false;status.textContent='This link is invalid or expired. Request a fresh password reset email.';return;} const {error}=await supabase.auth.updateUser({password:a});button.disabled=false; if(error){status.textContent=error.message;return} inviteFlow=false;recoveryFlow=false;history.replaceState(null,'',location.pathname);$('newPassword').value='';$('confirmPassword').value='';await refresh()}); $('forgotPasswordBtn').addEventListener('click',async()=>{ const email=prompt('Enter your North East Security account email address:'); if(email===null)return; if(!email.trim()){loginMessage('Enter your email address to request a reset.');return;} const button=$('forgotPasswordBtn');button.disabled=true; try{ const redirectTo=location.origin+'/reset.html'; const {error}=await supabase.auth.resetPasswordForEmail(email.trim(),{redirectTo}); loginMessage(error?'Could not request reset: '+error.message:'If that account exists, a password reset email will be sent. Check your inbox and junk folder.',!!error); }catch(e){loginMessage('Unable to request password reset. Please try again.');} finally{button.disabled=false;} }); $('loginForm').addEventListener('submit',async e=>{e.preventDefault();const button=$('signInBtn');button.disabled=true;loginMessage('Signing in…',false);try{const {error}=await supabase.auth.signInWithPassword({email:$('email').value.trim(),password:$('password').value});if(error){loginMessage(error.message);return}$('password').value='';await refresh()}catch{loginMessage('Sign-in could not be completed. Please try again.')}finally{button.disabled=false}}); $('signOutBtn').addEventListener('click',async()=>{const button=$('signOutBtn');button.disabled=true;try{const {error}=await supabase.auth.signOut();if(error){alert('Sign out failed: '+error.message);return}++renderSequence;show('login');loginMessage('You have signed out.',false)}finally{button.disabled=false}}); supabase.auth.onAuthStateChange((event)=>{if(event==='SIGNED_OUT'){++renderSequence;inviteFlow=false;show('login')}else if(event==='PASSWORD_RECOVERY'){inviteFlow=true;recoveryFlow=true;passwordScreen()}}); // Supabase may emit SIGNED_IN rather than PASSWORD_RECOVERY after a redirect. // The explicit auth=recovery query parameter keeps the reset form visible. // Training & Qualifications Tracker — North East Security const trainingDate = s => s ? new Date(s+'T12:00:00Z') : null; const trainingDays = s => s ? Math.ceil((trainingDate(s)-new Date())/86400000) : null; const trainingState = r => { if(r.review_status==='rejected') return ['Rejected','rejected']; if(r.review_status!=='verified') return ['Awaiting management verification','pending']; if(!r.expires_on) return ['Verified · no recorded expiry','verified']; const d=trainingDays(r.expires_on); return d<0?['Expired','rejected']:d<=30?['Expires in '+d+' days','rejected']:d<=90?['Expires in '+d+' days','pending']:['Verified','verified']; }; const trainingText = (parent,text,tag='p',className='muted') => {const e=document.createElement(tag);e.className=className;e.textContent=text;parent.append(e);return e}; const trainingButton = (parent,text,fn,cls='secondary') => {const b=document.createElement('button');b.type='button';b.className=cls;b.textContent=text;b.addEventListener('click',fn);parent.append(b);return b}; let trainingCourses=[]; async function trainingGetCourses(){const {data,error}=await supabase.from('training_courses').select('*').eq('is_active',true).order('title');if(error)throw error;trainingCourses=data||[];return trainingCourses} function trainingOptions(select,courses){select.replaceChildren();for(const c of courses){const o=document.createElement('option');o.value=c.id;o.textContent=c.title+(c.is_required?' (required)':'');select.append(o)}} async function loadEmployeeTraining(){const root=$('employeeTraining');if(!root)return;root.textContent='Loading your qualifications…';try{ const {data:{user}}=await supabase.auth.getUser();if(!user)throw Error('Please sign in'); const [courses,{data:records,error}]=await Promise.all([trainingGetCourses(),supabase.from('training_records').select('*').eq('employee_id',user.id).order('created_at',{ascending:false})]);if(error)throw error; root.replaceChildren();trainingText(root,'Record completed training and upload your certificate. Management must verify each submission. Training expiry dates should be checked against the certificate or awarding body.'); const form=document.createElement('form');form.className='verify-card';const course=document.createElement('select');course.className='field';course.required=true;trainingOptions(course,courses); const completed=document.createElement('input');completed.type='date';completed.className='field';completed.required=true;completed.max=new Date().toISOString().slice(0,10); const expiry=document.createElement('input');expiry.type='date';expiry.className='field'; const file=document.createElement('input');file.type='file';file.accept='.pdf,.jpg,.jpeg,.png,.webp';file.className='field';file.required=true; trainingText(form,'Qualification','label','');form.append(course);trainingText(form,'Date completed','label','');form.append(completed);trainingText(form,'Expiry date (leave blank if not applicable)','label','');form.append(expiry);trainingText(form,'Certificate (PDF or image, maximum 10 MB)','label','');form.append(file); const save=document.createElement('button');save.type='submit';save.textContent='Submit certificate for verification';form.append(save);const feedback=trainingText(form,'','p','status');root.append(form); form.addEventListener('submit',async e=>{e.preventDefault();save.disabled=true;feedback.textContent='Uploading…';try{ const f=file.files[0];if(!f)throw Error('Select a certificate');if(f.size>10485760)throw Error('File must be 10 MB or smaller');if(!['application/pdf','image/jpeg','image/png','image/webp'].includes(f.type))throw Error('Use PDF, JPG, PNG or WEBP');if(expiry.value&&expiry.value[c.id,c.title]));trainingText(root,'My certificates','h4',''); if(!records?.length)trainingText(root,'No training records submitted yet.'); for(const r of records||[]){const card=document.createElement('div');card.className='verify-card';trainingText(card,names.get(r.course_id)||'Qualification','h4','');trainingText(card,'Completed: '+r.completed_on+' · Expires: '+(r.expires_on||'Not recorded'));const [label,style]=trainingState(r);trainingText(card,label,'span','verify-status '+style);if(r.rejection_reason)trainingText(card,'Reason: '+r.rejection_reason,'p','error');if(r.certificate_path)trainingButton(card,'View certificate',async()=>{const {data,error}=await supabase.storage.from('training-certificates').createSignedUrl(r.certificate_path,90);if(error)alert(error.message);else window.open(data.signedUrl,'_blank','noopener,noreferrer')});root.append(card)} }catch(e){root.textContent='Unable to load training: '+e.message}} async function loadEmployeeTrainingProgress(){const root=$('trainingProgress');if(!root)return;try{ const {data:{user}}=await supabase.auth.getUser();if(!user)return; const [courses,{data:records,error},{data:assigned,error:ae},{data:requirements,error:re}]=await Promise.all([trainingGetCourses(),supabase.from('training_records').select('*').eq('employee_id',user.id),supabase.from('training_employee_job_roles').select('job_role_id').eq('employee_id',user.id),supabase.from('training_role_requirements').select('job_role_id,course_id')]);if(error||ae||re)throw error||ae||re; root.replaceChildren();trainingText(root,'Training & qualifications','h4','');const myRoles=new Set((assigned||[]).map(x=>x.job_role_id));const required=courses.filter(c=>c.is_required||(requirements||[]).some(x=>myRoles.has(x.job_role_id)&&x.course_id===c.id)); for(const c of required){const all=(records||[]).filter(r=>r.course_id===c.id);const valid=all.some(r=>r.review_status==='verified'&&(!r.expires_on||trainingDays(r.expires_on)>=0));const row=document.createElement('div');row.className='compliance-row';trainingText(row,c.title,'span','');trainingText(row,valid?'✓ Verified':'○ Outstanding / not verified','span',valid?'compliance-done':'compliance-wait');root.append(row)} const due=(records||[]).filter(r=>r.review_status==='verified'&&r.expires_on&&trainingDays(r.expires_on)<=90);for(const r of due){const c=courses.find(x=>x.id===r.course_id);trainingText(root,(c?.title||'Qualification')+': '+trainingState(r)[0]+' ('+r.expires_on+')','p','notice')} }catch(e){root.textContent='Training progress unavailable: '+e.message}} async function loadManagementTraining(){const root=$('managementTraining');if(!root)return;root.textContent='Loading training compliance…';try{ const [courses,{data:profiles,error:pe},{data:roles,error:roleErr},{data:records,error:re}]=await Promise.all([trainingGetCourses(),supabase.from('staff_profiles').select('user_id,full_name').limit(1000),supabase.from('staff_roles').select('user_id,role').eq('role','employee').limit(1000),supabase.from('training_records').select('*').order('created_at',{ascending:false}).limit(5000)]);if(pe||re||roleErr)throw pe||re||roleErr; root.replaceChildren();const employeeIds=new Set((roles||[]).map(r=>r.user_id));const staff=(profiles||[]).filter(p=>employeeIds.has(p.user_id));const byId=new Map(staff.map(p=>[p.user_id,p.full_name||'Unnamed employee']));const courseNames=new Map(courses.map(c=>[c.id,c.title]));const required=courses.filter(c=>c.is_required); const valid=(id,c)=> (records||[]).some(r=>r.employee_id===id&&r.course_id===c.id&&r.review_status==='verified'&&(!r.expires_on||trainingDays(r.expires_on)>=0)); const pending=(records||[]).filter(r=>r.review_status==='pending');const expired=(records||[]).filter(r=>r.review_status==='verified'&&r.expires_on&&trainingDays(r.expires_on)<0);const upcoming=(records||[]).filter(r=>r.review_status==='verified'&&r.expires_on&&trainingDays(r.expires_on)>=0&&trainingDays(r.expires_on)<=90); const metrics=document.createElement('div');metrics.className='compliance-grid';for(const [k,v] of [['Employees',staff.length],['Awaiting verification',pending.length],['Expired certificates',expired.length],['Expiring within 90 days',upcoming.length],['Required qualifications outstanding',staff.reduce((n,p)=>n+required.filter(c=>!valid(p.user_id,c)).length,0)]]){const tile=document.createElement('div');tile.className='compliance-stat';trainingText(tile,String(v),'strong','');trainingText(tile,k,'small','');metrics.append(tile)}root.append(metrics); const manage=document.createElement('div');manage.className='verify-card';trainingText(manage,'Training requirements','h4','');trainingText(manage,'Tick qualifications required for every employee. Adjust validity guidance; individual certificate expiry dates remain authoritative.');for(const c of courses){const row=document.createElement('div');row.className='verify-row';const checkbox=document.createElement('input');checkbox.type='checkbox';checkbox.checked=c.is_required;const label=document.createElement('label');label.textContent=c.title;row.append(checkbox,label);const months=document.createElement('input');months.type='number';months.min='1';months.max='240';months.placeholder='Validity months (optional)';months.value=c.validity_months||'';months.className='field';months.style.maxWidth='200px';row.append(months);trainingButton(row,'Save',async()=>{const {error}=await supabase.from('training_courses').update({is_required:checkbox.checked,validity_months:months.value?Number(months.value):null}).eq('id',c.id);if(error)alert(error.message);else loadManagementTraining()});manage.append(row)} const newName=document.createElement('input');newName.className='field';newName.placeholder='New qualification name';newName.maxLength=180;manage.append(newName);trainingButton(manage,'Add qualification',async()=>{if(!newName.value.trim())return;const {error}=await supabase.from('training_courses').insert({title:newName.value.trim()});if(error)alert(error.message);else loadManagementTraining()});root.append(manage); trainingText(root,'Certificate verification','h4','');const search=document.createElement('input');search.className='field';search.placeholder='Search employee or qualification';root.append(search);const list=document.createElement('div');root.append(list); function render(){list.replaceChildren();const q=search.value.toLowerCase();for(const r of (records||[]).filter(r=>(byId.get(r.employee_id)||'').toLowerCase().includes(q)||(courseNames.get(r.course_id)||'').toLowerCase().includes(q))){const card=document.createElement('div');card.className='verify-card';trainingText(card,(byId.get(r.employee_id)||'Employee')+' — '+(courseNames.get(r.course_id)||'Qualification'),'h4','');trainingText(card,'Completed '+r.completed_on+' · Expiry '+(r.expires_on||'Not recorded'));trainingText(card,trainingState(r)[0],'span','verify-status '+trainingState(r)[1]);if(r.rejection_reason)trainingText(card,'Rejection reason: '+r.rejection_reason,'p','error');const actions=document.createElement('div');actions.className='verify-row';if(r.certificate_path)trainingButton(actions,'View certificate',async()=>{const {data,error}=await supabase.storage.from('training-certificates').createSignedUrl(r.certificate_path,90);if(error)alert(error.message);else window.open(data.signedUrl,'_blank','noopener,noreferrer')}); if(r.review_status==='pending'){const reason=document.createElement('input');reason.className='field';reason.placeholder='Rejection reason (required if rejecting)';actions.append(reason);async function decide(status){if(status==='rejected'&&!reason.value.trim()){alert('Enter a rejection reason');return}const {data:{user}}=await supabase.auth.getUser();const {error}=await supabase.from('training_records').update({review_status:status,rejection_reason:status==='rejected'?reason.value.trim():null,reviewed_by:user.id,reviewed_at:new Date().toISOString(),updated_at:new Date().toISOString()}).eq('id',r.id).eq('review_status','pending');if(error)alert(error.message);else loadManagementTraining()} trainingButton(actions,'Verify',()=>decide('verified'));trainingButton(actions,'Reject',()=>decide('rejected'))}card.append(actions);list.append(card)}if(!list.children.length)trainingText(list,'No matching certificates.')} search.addEventListener('input',render);render(); trainingText(root,'Employee compliance report','h4','');const table=document.createElement('div');table.className='compliance-table-wrap';const t=document.createElement('table');t.className='compliance-table';const head=document.createElement('tr');for(const s of ['Employee',...required.map(c=>c.title),'Status'])trainingText(head,s,'th','');t.append(head); const report=[];for(const p of staff){const statuses=required.map(c=>valid(p.user_id,c)?'Verified':'Outstanding');const overall=statuses.every(s=>s==='Verified')?'Required training verified':'Action required';report.push([p.full_name||'Unnamed employee',...statuses,overall]);const tr=document.createElement('tr');for(const v of report[report.length-1])trainingText(tr,v,'td','');t.append(tr)}table.append(t);root.append(table); trainingButton(root,'Export training compliance CSV',()=>{const csv=[['Employee',...required.map(c=>c.title),'Status'],...report].map(row=>row.map(csvSafe).join(',')).join('\r\n');const blob=new Blob(['\ufeff'+csv],{type:'text/csv;charset=utf-8'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download='north-east-security-training-compliance.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),2000)}); trainingButton(root,'Export all certificate records CSV',()=>{const rows=[['Employee','Qualification','Completed','Expires','Review status','Rejection reason'],...(records||[]).map(r=>[byId.get(r.employee_id)||'Employee',courseNames.get(r.course_id)||'',r.completed_on,r.expires_on||'',r.review_status,r.rejection_reason||''])];const blob=new Blob(['\ufeff'+rows.map(row=>row.map(csvSafe).join(',')).join('\r\n')],{type:'text/csv;charset=utf-8'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download='north-east-security-training-records.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),2000)}); }catch(e){root.textContent='Unable to load training management: '+e.message+' — run setup-training-tracker.sql first.'}} $('refreshTrainingManagement').addEventListener('click',()=>{loadManagementTraining();loadManagementTrainingMatrix()}); $('refreshTrainingMatrix').addEventListener('click',loadManagementTrainingMatrix); $('refreshTrainingEmployee').addEventListener('click',loadEmployeeTraining); // Management training matrix. All statuses derived from existing verified certificate records. const matrixEl = document.getElementById('managementTrainingMatrix'); const matrixNotice = (p,s) => trainingText(p,s,'p','muted'); function matrixStatus(records, employeeId, courseId) { const matches=records.filter(r=>r.employee_id===employeeId&&r.course_id===courseId); const verified=matches.filter(r=>r.review_status==='verified').sort((a,b)=>(b.expires_on||'9999-12-31').localeCompare(a.expires_on||'9999-12-31')); const current=verified.find(r=>!r.expires_on||trainingDays(r.expires_on)>=0); if(current){if(current.expires_on&&trainingDays(current.expires_on)<=90)return {label:'Expiring soon',kind:'soon',expiry:current.expires_on};return {label:'Valid',kind:'valid',expiry:current.expires_on||''};} if(verified.length)return {label:'Expired',kind:'expired',expiry:verified[0].expires_on||''}; if(matches.some(r=>r.review_status==='pending'))return {label:'Pending',kind:'pending',expiry:''}; if(matches.some(r=>r.review_status==='rejected'))return {label:'Rejected',kind:'rejected',expiry:''}; return {label:'Outstanding',kind:'missing',expiry:''}; } function matrixExport(filename,rows){const csv=rows.map(row=>row.map(csvSafe).join(',')).join('\r\n');const blob=new Blob(['\ufeff'+csv],{type:'text/csv;charset=utf-8'});const u=URL.createObjectURL(blob);const a=document.createElement('a');a.href=u;a.download=filename;a.click();setTimeout(()=>URL.revokeObjectURL(u),2000)} async function loadManagementTrainingMatrix(){ if(!matrixEl)return;matrixEl.textContent='Loading training matrix…'; try { const [courses,{data:roles,error:roleError},{data:profiles,error:profileError},{data:staffRoles,error:staffError},{data:records,error:recordError},{data:assignments,error:assignError},{data:requirements,error:reqError}]=await Promise.all([ trainingGetCourses(),supabase.from('training_job_roles').select('*').order('name'),supabase.from('staff_profiles').select('user_id,full_name').limit(1000),supabase.from('staff_roles').select('user_id,role').eq('role','employee').limit(1000),supabase.from('training_records').select('*').limit(5000),supabase.from('training_employee_job_roles').select('*').limit(5000),supabase.from('training_role_requirements').select('*').limit(5000) ]); const failure=roleError||profileError||staffError||recordError||assignError||reqError;if(failure)throw failure; const employeeIds=new Set((staffRoles||[]).map(x=>x.user_id));const staff=(profiles||[]).filter(x=>employeeIds.has(x.user_id)).sort((a,b)=>(a.full_name||'').localeCompare(b.full_name||'')); const jobRoles=roles||[];const linked=assignments||[];const needed=requirements||[];const allRecords=records||[]; const requiredFor=id=>{const roleIds=new Set(linked.filter(x=>x.employee_id===id).map(x=>x.job_role_id));return courses.filter(c=>c.is_required||needed.some(x=>roleIds.has(x.job_role_id)&&x.course_id===c.id));}; const roleNames=id=>jobRoles.filter(r=>linked.some(x=>x.employee_id===id&&x.job_role_id===r.id)).map(r=>r.name); matrixEl.replaceChildren();matrixNotice(matrixEl,'Assign job roles and role-specific training requirements. Company-wide required qualifications remain required for everyone. Only verified, unexpired evidence counts as current.'); const controls=document.createElement('div');controls.className='verify-card';trainingText(controls,'Manage job roles and requirements','h4',''); const newRole=document.createElement('input');newRole.className='field';newRole.placeholder='New job role name';newRole.maxLength=120;controls.append(newRole); trainingButton(controls,'Add job role',async()=>{const name=newRole.value.trim();if(!name)return;const {error}=await supabase.from('training_job_roles').insert({name});if(error)alert(error.message);else loadManagementTrainingMatrix()}); for(const role of jobRoles){const details=document.createElement('details');details.className='verify-card';const summary=document.createElement('summary');summary.textContent=role.name+' — role training requirements';details.append(summary); for(const course of courses){const line=document.createElement('label');line.className='matrix-check';const input=document.createElement('input');input.type='checkbox';input.checked=needed.some(x=>x.job_role_id===role.id&&x.course_id===course.id);input.addEventListener('change',async()=>{input.disabled=true;const q=supabase.from('training_role_requirements');const {error}=input.checked?await q.insert({job_role_id:role.id,course_id:course.id}):await q.delete().eq('job_role_id',role.id).eq('course_id',course.id);if(error){alert(error.message);input.checked=!input.checked;}else await loadManagementTrainingMatrix();input.disabled=false});line.append(input,document.createTextNode(course.title+(course.is_required?' (company-wide required)':'')));details.append(line)}controls.append(details) }matrixEl.append(controls); const assign=document.createElement('div');assign.className='verify-card';trainingText(assign,'Assign employee job roles','h4',''); const empSelect=document.createElement('select');empSelect.className='field';for(const p of staff){const o=document.createElement('option');o.value=p.user_id;o.textContent=p.full_name||'Unnamed employee';empSelect.append(o)}assign.append(empSelect); const roleArea=document.createElement('div');assign.append(roleArea);function showAssignments(){roleArea.replaceChildren();for(const role of jobRoles){const line=document.createElement('label');line.className='matrix-check';const check=document.createElement('input');check.type='checkbox';check.checked=linked.some(x=>x.employee_id===empSelect.value&&x.job_role_id===role.id);check.addEventListener('change',async()=>{check.disabled=true;const q=supabase.from('training_employee_job_roles');const {error}=check.checked?await q.insert({employee_id:empSelect.value,job_role_id:role.id}):await q.delete().eq('employee_id',empSelect.value).eq('job_role_id',role.id);if(error){alert(error.message);check.checked=!check.checked}else loadManagementTrainingMatrix();check.disabled=false});line.append(check,document.createTextNode(role.name));roleArea.append(line)}}empSelect.addEventListener('change',showAssignments);showAssignments();matrixEl.append(assign); trainingText(matrixEl,'Company-wide employee training matrix','h4',''); const filters=document.createElement('div');filters.className='verify-row';const search=document.createElement('input');search.className='field';search.placeholder='Search employee or role';filters.append(search);const stateFilter=document.createElement('select');stateFilter.className='field';for(const [value,label] of [['all','All employees'],['gaps','Training gaps only'],['soon','Expiring within 90 days']]){const opt=document.createElement('option');opt.value=value;opt.textContent=label;stateFilter.append(opt)}filters.append(stateFilter);matrixEl.append(filters); const legend=trainingText(matrixEl,'Valid = verified and current · Expiring soon = verified and within 90 days · Pending / Rejected = not yet compliant · Outstanding = no approved evidence.','p','muted'); const tableWrap=document.createElement('div');tableWrap.className='compliance-table-wrap';matrixEl.append(tableWrap);const summary=trainingText(matrixEl,'','p','muted'); const exportRow=document.createElement('div');exportRow.className='verify-row';matrixEl.append(exportRow); function build(){tableWrap.replaceChildren();exportRow.replaceChildren();const q=search.value.trim().toLowerCase();const columns=courses.filter(c=>c.is_required||needed.some(x=>x.course_id===c.id));const table=document.createElement('table');table.className='compliance-table matrix-table';const header=document.createElement('tr');for(const h of ['Employee','Job role(s)',...columns.map(c=>c.title),'Requirements met'])trainingText(header,h,'th','');table.append(header); const report=[['Employee','Job role(s)',...columns.map(c=>c.title),'Requirements met','Required','Compliant','Gaps']];let visible=0, gaps=0; for(const p of staff){const names=roleNames(p.user_id);const required=requiredFor(p.user_id);const statuses=columns.map(c=>required.some(r=>r.id===c.id)?matrixStatus(allRecords,p.user_id,c.id):null);const compliant=statuses.filter(s=>s&&(s.kind==='valid'||s.kind==='soon')).length;const missing=required.length-compliant;const hasSoon=statuses.some(s=>s&&s.kind==='soon');if(q&&!(p.full_name||'').toLowerCase().includes(q)&&!names.join(' ').toLowerCase().includes(q))continue;if(stateFilter.value==='gaps'&&!missing)continue;if(stateFilter.value==='soon'&&!hasSoon)continue;visible++;gaps+=missing; const tr=document.createElement('tr');trainingText(tr,p.full_name||'Unnamed employee','td','');trainingText(tr,names.join(', ')||'Unassigned','td','');for(const s of statuses){const cell=document.createElement('td');if(s){const pill=trainingText(cell,s.label,'span','matrix-status matrix-'+s.kind);if(s.expiry)trainingText(cell,s.expiry,'small','muted')}else trainingText(cell,'Not required','span','muted');tr.append(cell)}trainingText(tr,required.length?`${compliant}/${required.length}`:'No requirements','td','');table.append(tr); report.push([p.full_name||'Unnamed employee',names.join('; ')||'Unassigned',...statuses.map(s=>s?(s.label+(s.expiry?' ('+s.expiry+')':'')):'Not required'),required.length?`${compliant}/${required.length}`:'No requirements',required.length,compliant,missing]); }tableWrap.append(table);summary.textContent=`Showing ${visible} of ${staff.length} employees · ${gaps} outstanding requirements among displayed employees`; trainingButton(exportRow,'Export filtered matrix CSV',()=>matrixExport('north-east-security-training-matrix.csv',report)); trainingButton(exportRow,'Export training gaps CSV',()=>{const rows=[['Employee','Job roles','Required qualification','Status','Expiry date']];for(const p of staff){const rolesText=roleNames(p.user_id).join('; ');for(const c of requiredFor(p.user_id)){const s=matrixStatus(allRecords,p.user_id,c.id);if(s.kind!=='valid'&&s.kind!=='soon')rows.push([p.full_name||'Unnamed employee',rolesText,c.title,s.label,s.expiry])}}matrixExport('north-east-security-training-gaps.csv',rows)}); }search.addEventListener('input',build);stateFilter.addEventListener('change',build);build(); }catch(e){matrixEl.textContent='Unable to load training matrix: '+e.message+' — run setup-training-matrix.sql before deploying.'} } // Staff Assignment & Deployment Compliance Checker — management only. const deploymentRoot=document.getElementById('managementDeployment'); async function loadManagementDeployment(){ if(!deploymentRoot)return; deploymentRoot.textContent='Loading venue compliance…'; try{ const [sitesQ,siteRolesQ,siteCoursesQ,inductionsQ,rolesQ,empRolesQ,reqQ,courses,recordsQ,profilesQ,staffRolesQ,authQ]=await Promise.all([ supabase.from('deployment_sites').select('*').order('name'), supabase.from('deployment_site_roles').select('*'), supabase.from('deployment_site_courses').select('*'), supabase.from('deployment_site_inductions').select('*'), supabase.from('training_job_roles').select('*').order('name'), supabase.from('training_employee_job_roles').select('*'), supabase.from('training_role_requirements').select('*'), trainingGetCourses(), supabase.from('training_records').select('*').limit(5000), supabase.from('staff_profiles').select('user_id,full_name,sia_licence_number,sia_expiry_date').limit(1000), supabase.from('staff_roles').select('user_id,role').eq('role','employee').limit(1000), supabase.auth.getUser() ]); for(const q of [sitesQ,siteRolesQ,siteCoursesQ,inductionsQ,rolesQ,empRolesQ,reqQ,recordsQ,profilesQ,staffRolesQ])if(q.error)throw q.error; const sites=sitesQ.data||[],siteRoles=siteRolesQ.data||[],siteCourses=siteCoursesQ.data||[],inductions=inductionsQ.data||[],roles=rolesQ.data||[],empRoles=empRolesQ.data||[],roleRequirements=reqQ.data||[],records=recordsQ.data||[]; const employeeIds=new Set((staffRolesQ.data||[]).map(x=>x.user_id)); const staff=(profilesQ.data||[]).filter(p=>employeeIds.has(p.user_id)).sort((a,b)=>(a.full_name||'').localeCompare(b.full_name||'')); const me=authQ.data?.user?.id; deploymentRoot.replaceChildren(); trainingText(deploymentRoot,'Internal pre-deployment evidence check only. A green result does not verify live SIA status, licence category, right to work, fitness, or suitability for an assignment. Management must complete those checks separately.','p','muted'); const createBox=document.createElement('div');createBox.className='verify-card';trainingText(createBox,'Add a venue / assignment','h4',''); const siteName=document.createElement('input');siteName.className='field';siteName.placeholder='Venue or assignment name';siteName.maxLength=160;createBox.append(siteName); const siteNotes=document.createElement('input');siteNotes.className='field';siteNotes.placeholder='Optional venue notes';siteNotes.maxLength=400;createBox.append(siteNotes); trainingButton(createBox,'Create venue',async()=>{const name=siteName.value.trim();if(!name)return alert('Enter a venue name');const {error}=await supabase.from('deployment_sites').insert({name,notes:siteNotes.value.trim()||null});if(error)alert(error.message);else loadManagementDeployment()}); deploymentRoot.append(createBox); if(!sites.length){trainingText(deploymentRoot,'Create your first venue to configure its requirements and check staff.','p','muted');return} const choose=document.createElement('select');choose.className='field';for(const s of sites){const opt=document.createElement('option');opt.value=s.id;opt.textContent=s.name;choose.append(opt)}deploymentRoot.append(choose); const body=document.createElement('div');deploymentRoot.append(body); function renderSite(){body.replaceChildren();const site=sites.find(s=>s.id===choose.value);if(!site)return; const settings=document.createElement('div');settings.className='verify-card';trainingText(settings,site.name+' — assignment requirements','h4',''); trainingText(settings,'Manage the requirements used for venue deployment checks. Changes are saved to the shared Supabase database and affect both test and live portals.','p','muted'); const notesLabel=document.createElement('label');notesLabel.textContent='Venue notes';settings.append(notesLabel); const notesField=document.createElement('textarea');notesField.className='field';notesField.rows=2;notesField.maxLength=400;notesField.value=site.notes||'';settings.append(notesField); const notesStatus=trainingText(settings,'','p','muted'); trainingButton(settings,'Save venue notes',async()=>{const {error}=await supabase.from('deployment_sites').update({notes:notesField.value.trim()||null}).eq('id',site.id);if(error)notesStatus.textContent='Could not save: '+error.message;else{site.notes=notesField.value.trim()||null;notesStatus.textContent='Venue notes saved.'}}); const activityLabel=document.createElement('label');activityLabel.textContent='Required SIA licensed activity';settings.append(activityLabel); const activitySelect=document.createElement('select');activitySelect.className='field'; for(const activity of ['Door Supervision','Security Guarding','Public Space Surveillance (CCTV)','Close Protection','Cash and Valuables in Transit','Key Holding','Vehicle Immobilisation']){const option=document.createElement('option');option.value=activity;option.textContent=activity;activitySelect.append(option)} const emptyActivity=document.createElement('option');emptyActivity.value='';emptyActivity.textContent='Not specified — review required';activitySelect.prepend(emptyActivity); activitySelect.value=site.required_sia_activity||'';settings.append(activitySelect); const activityStatus=trainingText(settings,'This setting records the venue activity. The existing deployment overview does not independently verify SIA licence categories.','p','muted'); trainingButton(settings,'Save SIA activity',async()=>{const value=activitySelect.value||null;if(site.requires_sia&&!value){activityStatus.textContent='Select an activity before saving for an SIA-required venue.';return}const {error}=await supabase.from('deployment_sites').update({required_sia_activity:value}).eq('id',site.id);if(error)activityStatus.textContent='Could not save: '+error.message;else{site.required_sia_activity=value;activityStatus.textContent='SIA activity saved. This does not itself authorise shift claiming.'}}); for(const [field,label] of [['requires_sia','Recorded SIA licence details and future expiry required'],['requires_induction','Venue induction required']]){ const line=document.createElement('label');line.className='matrix-check';const check=document.createElement('input');check.type='checkbox';check.checked=!!site[field];check.addEventListener('change',async()=>{check.disabled=true;const {error}=await supabase.from('deployment_sites').update({[field]:check.checked}).eq('id',site.id);if(error){alert(error.message);check.checked=!check.checked}else{site[field]=check.checked;renderSite()}});line.append(check,document.createTextNode(label));settings.append(line) } trainingText(settings,'Permitted job roles — an employee needs at least one selected role','h4',''); trainingText(settings,'For World Head Quarters, Door Supervisor OR Other qualifies for the role check. No selected roles means no role restriction in this existing deployment report; future shift claiming will remain blocked until its separate eligibility rules are verified.','p','muted'); for(const role of roles){const line=document.createElement('label');line.className='matrix-check';const check=document.createElement('input');check.type='checkbox';check.checked=siteRoles.some(x=>x.site_id===site.id&&x.job_role_id===role.id);check.addEventListener('change',async()=>{const q=supabase.from('deployment_site_roles');const {error}=check.checked?await q.insert({site_id:site.id,job_role_id:role.id}):await q.delete().eq('site_id',site.id).eq('job_role_id',role.id);if(error){alert(error.message);check.checked=!check.checked}else loadManagementDeployment()});line.append(check,document.createTextNode(role.name));settings.append(line)} trainingText(settings,'Mandatory venue-specific training — all selected courses are required','h4',''); for(const course of courses){const line=document.createElement('label');line.className='matrix-check';const check=document.createElement('input');check.type='checkbox';check.checked=siteCourses.some(x=>x.site_id===site.id&&x.course_id===course.id);check.addEventListener('change',async()=>{const q=supabase.from('deployment_site_courses');const {error}=check.checked?await q.insert({site_id:site.id,course_id:course.id}):await q.delete().eq('site_id',site.id).eq('course_id',course.id);if(error){alert(error.message);check.checked=!check.checked}else loadManagementDeployment()});line.append(check,document.createTextNode(course.title));settings.append(line)}body.append(settings); const inductionBox=document.createElement('div');inductionBox.className='verify-card';trainingText(inductionBox,'Record verified venue induction','h4',''); const employeeSelect=document.createElement('select');employeeSelect.className='field';for(const p of staff){const o=document.createElement('option');o.value=p.user_id;o.textContent=p.full_name||'Unnamed employee';employeeSelect.append(o)}inductionBox.append(employeeSelect); const dateInput=document.createElement('input');dateInput.type='date';dateInput.className='field';dateInput.value=new Date().toISOString().slice(0,10);inductionBox.append(dateInput); const inductionStatus=trainingText(inductionBox,'Only record completion after checking the actual venue induction evidence.','p','muted'); trainingButton(inductionBox,'Record induction',async()=>{if(!employeeSelect.value||!dateInput.value)return;const {error}=await supabase.from('deployment_site_inductions').upsert({site_id:site.id,employee_id:employeeSelect.value,completed_on:dateInput.value,verified_by:me,verified_at:new Date().toISOString()},{onConflict:'site_id,employee_id'});if(error)alert(error.message);else loadManagementDeployment()}); const existing=inductions.filter(i=>i.site_id===site.id);for(const item of existing){const p=staff.find(p=>p.user_id===item.employee_id);const line=document.createElement('div');line.className='verify-row';trainingText(line,(p?.full_name||'Employee')+' — '+item.completed_on,'span','');trainingButton(line,'Revoke',async()=>{if(!confirm('Remove this recorded venue induction?'))return;const {error}=await supabase.from('deployment_site_inductions').delete().eq('site_id',site.id).eq('employee_id',item.employee_id);if(error)alert(error.message);else loadManagementDeployment()});inductionBox.append(line)}body.append(inductionBox); trainingText(body,'Pre-deployment staff evidence overview','h4',''); const filter=document.createElement('input');filter.className='field';filter.placeholder='Search employees';body.append(filter); const tableWrap=document.createElement('div');tableWrap.className='compliance-table-wrap';body.append(tableWrap); const exportBox=document.createElement('div');exportBox.className='verify-row';body.append(exportBox); function reportRows(){const permitted=siteRoles.filter(r=>r.site_id===site.id).map(r=>r.job_role_id);const venueCourseIds=siteCourses.filter(r=>r.site_id===site.id).map(r=>r.course_id);const output=[]; for(const p of staff){const myRoleIds=empRoles.filter(r=>r.employee_id===p.user_id).map(r=>r.job_role_id);const roleOk=!permitted.length||myRoleIds.some(id=>permitted.includes(id));const siaOk=!site.requires_sia||!!(p.sia_licence_number&&/^\d{16}$/.test(String(p.sia_licence_number))&&p.sia_expiry_date&&trainingDays(p.sia_expiry_date)>=0);const inductionOk=!site.requires_induction||existing.some(i=>i.employee_id===p.user_id);const requiredIds=new Set([...courses.filter(c=>c.is_required).map(c=>c.id),...roleRequirements.filter(r=>myRoleIds.includes(r.job_role_id)).map(r=>r.course_id),...venueCourseIds]);const gaps=[];for(const id of requiredIds){const state=matrixStatus(records,p.user_id,id);if(!['valid','soon'].includes(state.kind))gaps.push((courses.find(c=>c.id===id)?.title||'Qualification')+' ('+state.label+')')} const reasons=[];if(!roleOk)reasons.push('Role not permitted');if(!siaOk)reasons.push('Recorded SIA details missing or expired');if(!inductionOk)reasons.push('Venue induction missing');reasons.push(...gaps);const status=reasons.length?'Action required':'Meets recorded requirements';output.push({p,status,reasons,roleOk,siaOk,inductionOk,required:requiredIds.size,gaps:gaps.length})}return output} function draw(){tableWrap.replaceChildren();exportBox.replaceChildren();const rows=reportRows().filter(x=>(x.p.full_name||'').toLowerCase().includes(filter.value.toLowerCase()));const table=document.createElement('table');table.className='compliance-table matrix-table';const head=document.createElement('tr');for(const h of ['Employee','Recorded SIA','Role','Venue induction','Training gaps','Evidence status'])trainingText(head,h,'th','');table.append(head); for(const r of rows){const tr=document.createElement('tr');for(const val of [r.p.full_name||'Unnamed',r.siaOk?'OK':'Action required',r.roleOk?'OK':'Not permitted',r.inductionOk?'Complete':'Missing',String(r.gaps),r.status]){const td=document.createElement('td');trainingText(td,val,'span',val==='Meets recorded requirements'?'matrix-status matrix-valid':val==='Action required'?'matrix-status matrix-missing':'');tr.append(td)}table.append(tr);if(r.reasons.length){const extra=document.createElement('tr');const cell=document.createElement('td');cell.colSpan=6;cell.className='muted';cell.textContent='Actions: '+r.reasons.join(' · ');extra.append(cell);table.append(extra)}}tableWrap.append(table); trainingButton(exportBox,'Export venue compliance CSV',()=>{const report=[['Venue','Employee','Recorded SIA check','Role check','Induction check','Training gaps','Evidence status','Outstanding actions'],...rows.map(r=>[site.name,r.p.full_name||'Unnamed',r.siaOk?'Pass':'Action required',r.roleOk?'Pass':'Not permitted',r.inductionOk?'Complete':'Missing',r.gaps,r.status,r.reasons.join('; ')])];matrixExport('north-east-security-deployment-compliance.csv',report)})} filter.addEventListener('input',draw);draw(); } choose.addEventListener('change',renderSite);renderSite(); }catch(e){deploymentRoot.textContent='Unable to load deployment checker: '+e.message+' — confirm setup-deployment-checker.sql has been run.'} } document.getElementById('refreshDeployment').addEventListener('click',loadManagementDeployment); refresh();