const {useEffect,useMemo,useState}=React;
const NAV=[
{g:'Command',items:[['dashboard','fa-chart-pie','Executive Dashboard'],['projects','fa-folder-tree','Projects']]},
{g:'HSE Assurance',items:[['docs','fa-file-signature','HSE Documents'],['hemp','fa-triangle-exclamation','HEMP / Risk Register'],['mersa','fa-heart-pulse','MERSA'],['nerf','fa-truck-medical','NERF'],['vessel','fa-ship','Vessel Assurance']]},
{g:'Operations',items:[['daily_hse','fa-clipboard-check','Daily HSE'],['simops','fa-tower-observation','SIMOPS'],['incidents','fa-bolt','Incidents'],['actions','fa-list-check','Actions & Close-Out']]},
{g:'Resources',items:[['personnel','fa-users','Personnel'],['equipment','fa-plane-up','UAV & Equipment'],['audit','fa-clock-rotate-left','Audit Trail']]},
{g:'Administration',items:[['client_admin','fa-user-shield','Client Portal']]},
];
const statusType=(s='')=>{s=s.toLowerCase();if(s.includes('approv')||s.includes('verified')||s.includes('closed')||s.includes('ready')||s.includes('active'))return'safe';if(s.includes('critical')||s.includes('reject')||s.includes('overdue')||s.includes('open incident'))return'crit';if(s.includes('review')||s.includes('progress')||s.includes('conditional'))return'info';if(s.includes('pending')||s.includes('draft')||s.includes('tbc')||s.includes('not started'))return'warn';return'wait'};
const typeClass={safe:'text-status-safe border-status-safe/50 bg-status-safe/10',crit:'text-status-crit border-status-crit/50 bg-status-crit/10',info:'text-status-info border-status-info/50 bg-status-info/10',warn:'text-status-warn border-status-warn/50 bg-status-warn/10',wait:'text-status-wait border-status-wait/50 bg-status-wait/10'};
const Badge=({children,type})=>{children||'—'} ;
const fmtDate=v=>v?new Date(v.replace(' ','T')).toLocaleDateString('en-MY',{day:'2-digit',month:'short',year:'numeric'}):'—';
const today=()=>new Date().toISOString().slice(0,10);
async function api(action,payload,opts={}){
const res=await fetch(`api.php?action=${encodeURIComponent(action)}`,{method:payload?'POST':'GET',headers:payload&&!opts.form?{'Content-Type':'application/json'}:undefined,body:payload?(opts.form?payload:JSON.stringify(payload)):undefined});
let data;try{data=await res.json()}catch{data={ok:false,error:'Invalid server response'}}
if(!res.ok||data.ok===false)throw new Error(data.error||`Request failed (${res.status})`);return data;
}
function IconButton({icon,title,onClick,danger=false}){return }
function Card({title,sub,action,children,className=''}){return }
function KPI({label,value,icon,sub,tone='pgr'}){const bg=tone==='crit'?'bg-status-crit/10 text-status-crit':tone==='warn'?'bg-status-warn/10 text-status-warn':tone==='info'?'bg-status-info/10 text-status-info':'bg-pgr/10 text-pgr';return
{value}
{label}
{sub&&
{sub}
}
}
function Modal({title,onClose,children,wide=false}){return {if(e.target===e.currentTarget)onClose()}}>
}
function FormModal({title,fields,initial={},onClose,onSave}){
const [form,setForm]=useState({...initial});const [busy,setBusy]=useState(false);const set=(k,v)=>setForm(f=>({...f,[k]:v}));
const submit=async e=>{e.preventDefault();setBusy(true);try{await onSave(form);onClose()}finally{setBusy(false)}};
return }
function Confirm({text,onYes,onClose}){return {text}
Cancel {await onYes();onClose()}}> Delete
}
function Login({onLogin}){const[email,setEmail]=useState('admin@pgrgeospatial.com');const[pw,setPw]=useState('');const[show,setShow]=useState(false);const[err,setErr]=useState('');const[busy,setBusy]=useState(false);const submit=async e=>{e.preventDefault();setBusy(true);setErr('');try{const r=await api('login',{email,password:pw});onLogin(r.user)}catch(e){setErr(e.message)}finally{setBusy(false)}};return PRESTIGE GEOMATIC RESOURCES
HSE ASSURANCE PLATFORM
HSE COMMANDCENTRE Health, Safety, Environment, marine assurance and mobilisation readiness for PGR UAV LiDAR operations.
{['Plan','Verify','Approve','Mobilise Safely'].map(x=>{x} )}
Controlled access • MySQL-backed • Hostinger deployment
}
function Sidebar({view,setView,open,setOpen}){return }
function Topbar({data,activeProjectId,setActiveProjectId,setEmergency,query,setQuery,onLogout,onPassword,setSidebarOpen}){const [menu,setMenu]=useState(false);return }
function Dashboard({data,project,setView}){const projectRisks=data.risks.filter(x=>!project||!x.project_id||Number(x.project_id)===project.id);const readiness=project?.readiness??0;const criticalRisks=projectRisks.filter(x=>(x.initial_rating||'').includes(' H')).length;const tbcDocs=data.documents.filter(d=>(d.status||'').toLowerCase().includes('review')).length;const gate=[['HSE Case (PGR-HSE-001)','For Client Review'],['MERSA (PGR-HSE-002)','For Client Review'],['NERF (PGR-HSE-003)','For Client Review'],['HEMP (PGR-HSE-004)','For Client Review'],['Mode 3 Boat Acceptance','For Client Review'],['Vessel particulars / registration',data.vessels.length?'In Progress':'TBC'],['Personnel competency',data.personnel.length?'In Progress':'TBC'],['Open critical actions',data.stats.critical_actions?`${data.stats.critical_actions} Open`:'None']];return Operational HSE Assurance
Executive Dashboard One command centre for controlled documents, risk, marine assurance and operational readiness.
Active Project
{project?.name||'No project selected'}
{project?.project_code||'—'} • {project?.location||'Location TBC'}
=80?'safe':readiness>=50?'warn':'crit'}>{readiness}%}>Client
{project?.client||'TBC'}
Project Manager
{project?.project_manager||'TBC'}
HSE Lead
{project?.hse_lead||'TBC'}
Mobilisation
{fmtDate(project?.mobilisation_date)}
setView('projects')}> Update Project Readiness setView('docs')}>Open register}>{data.documents.slice(0,5).map(d=>
)}
setView('hemp')}>Open HEMP}>{projectRisks.slice(0,5).map(r=>
{r.initial_rating||'TBC'} {r.top_event}
)}
}
const projectFields=[{name:'project_code',label:'Project Code',required:true},{name:'name',label:'Project Name',required:true},{name:'client',label:'Client'},{name:'location',label:'Location / Field'},{name:'project_manager',label:'Project Manager'},{name:'hse_lead',label:'HSE Lead'},{name:'start_date',label:'Start Date',type:'date'},{name:'mobilisation_date',label:'Mobilisation Date',type:'date'},{name:'status',label:'Status',type:'select',options:['Planning','HSE Preparation','For Client Review','Mobilisation Ready','Active','On Hold','Closed']},{name:'readiness',label:'Readiness %',type:'number',min:0,max:100},{name:'is_active',label:'Active Project',type:'checkbox',help:'Include in active project list'}];
function CrudTable({rows,columns,onEdit,onDelete,empty='No records yet'}){return {rows.length?
{columns.map(c=>{c.label} )}{(onEdit||onDelete)&&Actions } {rows.map(r=>{columns.map(c=>{c.render?c.render(r):r[c.key]||'—'} )}{(onEdit||onDelete)&&{onEdit&&onEdit(r)}/>} {onDelete&&onDelete(r)}/>}
} )}
:
}
}
function EntityModule({title,sub,entity,rows,columns,fields,onRefresh,query,defaults={},addLabel='Add Record'}){const[edit,setEdit]=useState(null);const[del,setDel]=useState(null);const filtered=useMemo(()=>{const q=query.toLowerCase().trim();if(!q)return rows;return rows.filter(r=>JSON.stringify(r).toLowerCase().includes(q))},[rows,query]);const save=async form=>{await api('save',{entity,data:{...defaults,...form}});await onRefresh()};const remove=async()=>{await api('delete',{entity,id:del.id});await onRefresh()};return setEdit(r)} onDelete={r=>setDel(r)}/>{edit!==null&&setEdit(null)} onSave={save}/>} {del&&setDel(null)} onYes={remove}/>} }
function Projects({data,onRefresh,query}){return {r.project_code} },{key:'name',label:'Project'},{key:'client',label:'Client',render:r=>r.client||TBC },{key:'location',label:'Location'},{key:'status',label:'Status',render:r=>{r.status} },{key:'readiness',label:'Readiness',render:r=>} ]}/>}
function DocumentPreview({doc,onClose}){const[html,setHtml]=useState('');const[err,setErr]=useState('');const[busy,setBusy]=useState(true);useEffect(()=>{(async()=>{try{if((doc.file_path||'').toLowerCase().endsWith('.pdf')){setHtml('PDF')}else{const r=await fetch(`document.php?id=${doc.id}`);if(!r.ok)throw new Error('Unable to load document file');const ab=await r.arrayBuffer();const out=await mammoth.convertToHtml({arrayBuffer:ab});setHtml(out.value)}}catch(e){setErr(e.message)}finally{setBusy(false)}})()},[doc]);return {busy?:err?{err}
:html==='PDF'?:
}Embedded DOCX preview is for quick reference; the controlled original remains the downloadable source file.
}
function Documents({data,onRefresh,query}){const[preview,setPreview]=useState(null);const[edit,setEdit]=useState(null);const[del,setDel]=useState(null);const[upload,setUpload]=useState(false);const filtered=data.documents.filter(d=>!query||JSON.stringify(d).toLowerCase().includes(query.toLowerCase()));const fields=[{name:'document_no',label:'Document No.',required:true},{name:'title',label:'Title',required:true},{name:'rev',label:'Revision'},{name:'owner',label:'Owner'},{name:'issue_date',label:'Issue Date',type:'date'},{name:'status',label:'PGR Status',type:'select',options:['Draft','For Client Review','Under Review','Approved','Superseded']},{name:'client_status',label:'Client Status',type:'select',options:['Pending','For Client Review','Under Review','Revision Required','Accepted','Approved']},{name:'file_name',label:'File Name',help:'Do not change unless replacing the underlying file.'},{name:'file_path',label:'File Path',full:true,help:'Relative server path to the controlled file.'}];const save=async f=>{await api('save',{entity:'documents',data:f});await onRefresh()};return HSE Document Register Five attached Rev 0 controlled documents are embedded and downloadable from this register.
setUpload(true)}> Upload Document Document Rev Owner Issue Date Client Status Status Commands {filtered.map(d=>{d.document_no}
{d.title}
{d.rev} {d.owner} {fmtDate(d.issue_date)} {d.client_status} {d.status} setPreview(d)}/>setEdit(d)}/>setDel(d)}/> )}
{preview&&
setPreview(null)}/>} {edit&&setEdit(null)} onSave={save}/>} {del&&setDel(null)} onYes={async()=>{await api('delete',{entity:'documents',id:del.id});await onRefresh()}}/>}{upload&&setUpload(false)} onDone={onRefresh}/>} }
function UploadDocument({onClose,onDone}){const[busy,setBusy]=useState(false);const[err,setErr]=useState('');const submit=async e=>{e.preventDefault();setBusy(true);setErr('');try{const fd=new FormData(e.currentTarget);await api('upload_document',fd,{form:true});await onDone();onClose()}catch(e){setErr(e.message)}finally{setBusy(false)}};return {err&&{err}
}Document No.
Revision
Title
Owner
Issue Date
PGR Status Draft For Client Review Under Review Approved
Client Status Pending For Client Review Under Review Accepted
DOCX / PDF
Cancel Upload
}
function HEMP({data,project,onRefresh,query}){const fields=[{name:'risk_code',label:'Risk ID',required:true},{name:'hazard',label:'Activity / Hazard',required:true,full:true},{name:'top_event',label:'Top Event',full:true},{name:'consequence',label:'Credible Consequence',type:'textarea',full:true},{name:'initial_rating',label:'Initial Risk'},{name:'residual_rating',label:'Residual Risk'},{name:'controls',label:'Preventive / Recovery Controls',type:'textarea',full:true},{name:'owner',label:'Control Owner'},{name:'verification_status',label:'Verification',type:'select',options:['Pending Verification','In Progress','Verified','Not Applicable']}];const rows=data.risks.filter(r=>(!project||!r.project_id||Number(r.project_id)===project.id));return {r.risk_code} },{key:'hazard',label:'Activity / Hazard'},{key:'top_event',label:'Top Event'},{key:'initial_rating',label:'Initial',render:r=>{r.initial_rating} },{key:'owner',label:'Owner'},{key:'residual_rating',label:'Residual',render:r=>{r.residual_rating} },{key:'verification_status',label:'Verification',render:r=>{r.verification_status} } ]}/>}
function ModuleRecords({title,sub,module,data,project,onRefresh,query,addLabel='Add Record'}){const rows=data.modules.filter(r=>r.module===module&&(!project||!r.project_id||Number(r.project_id)===project.id));const fields=[{name:'title',label:'Title / Check',required:true,full:true},{name:'details',label:'Details / Arrangement',type:'textarea',full:true},{name:'owner',label:'Owner'},{name:'status',label:'Status',type:'select',options:['TBC','Not Started','Pending','In Progress','Ready for Contact Completion','Verified','Completed','Closed']},{name:'date_value',label:'Date',type:'date'}];return {r.details||'—'}
},{key:'owner',label:'Owner',render:r=>r.owner||TBC },{key:'date_value',label:'Date',render:r=>fmtDate(r.date_value)},{key:'status',label:'Status',render:r=>{r.status} } ]}/>}
function Vessel({data,project,onRefresh,query}){const fields=[{name:'vessel_name',label:'Vessel Name',help:'Leave blank until confirmed.'},{name:'registration_no',label:'Registration No.',help:'Leave blank until confirmed.'},{name:'owner_operator',label:'Owner / Operator'},{name:'vessel_type',label:'Vessel Type'},{name:'assurance_status',label:'Assurance Status',type:'select',options:['Pending','Document Review','Physical Inspection','Conditional','Accepted','Rejected']},{name:'status',label:'Operational Status',type:'select',options:['TBC','Proposed','Available','Mobilisation Ready','Active','Unavailable']},{name:'notes',label:'Due Diligence Notes',type:'textarea',full:true}];const rows=data.vessels.filter(v=>!project||!v.project_id||Number(v.project_id)===project.id);return r.vessel_name||TBC },{key:'registration_no',label:'Registration',render:r=>r.registration_no||TBC },{key:'owner_operator',label:'Owner / Operator'},{key:'vessel_type',label:'Type'},{key:'assurance_status',label:'Assurance',render:r=>{r.assurance_status} },{key:'status',label:'Status',render:r=>{r.status} } ]}/>}
function Actions({data,project,onRefresh,query}){const fields=[{name:'title',label:'Action',required:true,full:true},{name:'owner',label:'Owner'},{name:'due_date',label:'Due Date',type:'date'},{name:'priority',label:'Priority',type:'select',options:['Low','Medium','High','Critical']},{name:'status',label:'Status',type:'select',options:['Open','In Progress','Pending','Closed']},{name:'source',label:'Source / Reference'},{name:'notes',label:'Notes / Evidence',type:'textarea',full:true}];const rows=data.actions.filter(r=>!project||!r.project_id||Number(r.project_id)===project.id);return r.owner||TBC },{key:'due_date',label:'Due',render:r=>fmtDate(r.due_date)},{key:'priority',label:'Priority',render:r=>{r.priority} },{key:'status',label:'Status',render:r=>{r.status} } ]}/>}
function Incidents({data,project,onRefresh,query}){const fields=[{name:'incident_no',label:'Incident No.',required:true},{name:'event_type',label:'Event / Scenario',required:true},{name:'severity',label:'Severity',type:'select',options:['Near Miss','Low','Medium','High','Critical']},{name:'event_date',label:'Event Date & Time',type:'datetime-local'},{name:'description',label:'Initial Description',type:'textarea',full:true},{name:'status',label:'Status',type:'select',options:['Open','Under Investigation','Corrective Action','Closed']}];const rows=data.incidents.filter(r=>!project||!r.project_id||Number(r.project_id)===project.id);return {r.incident_no} },{key:'event_type',label:'Event'},{key:'event_date',label:'Date',render:r=>fmtDate(r.event_date)},{key:'severity',label:'Severity',render:r=>{r.severity} },{key:'description',label:'Description',render:r=>{r.description}
},{key:'status',label:'Status',render:r=>{r.status} } ]}/>}
function Personnel({data,onRefresh,query}){
const fields=[
{name:'personnel_no',label:'No.',type:'number'},
{name:'name',label:'Name',required:true},
{name:'role',label:'Position'},
{name:'status',label:'Status',type:'select',options:['Active','Standby','Unavailable']},
{name:'competency',label:'Certificate Achieved',full:true,help:'Example: OGSP, RCOC, CIDB. Keep this as the certificate summary.'},
{name:'phone',label:'Phone'},
{name:'email',label:'Email',type:'email'},
{name:'ogsp_expiry',label:'OGSP Expiry',type:'date'},
{name:'rcoc_expiry',label:'RCoC Expiry',type:'date'},
{name:'cidb_expiry',label:'CIDB Expiry',type:'date'},
{name:'medical_expiry',label:'Medical / Fit-to-Work Expiry',type:'date'},
{name:'first_aid_expiry',label:'First Aid / CPR Expiry',type:'date'},
{name:'bosiet_expiry',label:'BOSIET / FOET Expiry',type:'date'},
{name:'other_certificate',label:'Other Certificate / Competency'},
{name:'other_expiry',label:'Other Certificate Expiry',type:'date'},
{name:'medical_status',label:'Medical / Fit-to-Work Status',type:'select',options:['TBC','Pending','Fit','Restricted','Expired']}
];
const certItems=r=>[
['OGSP',r.ogsp_expiry],['RCoC',r.rcoc_expiry],['CIDB',r.cidb_expiry],['Medical',r.medical_expiry],
['First Aid',r.first_aid_expiry],['BOSIET/FOET',r.bosiet_expiry],[r.other_certificate||'Other',r.other_expiry]
].filter(x=>x[1]);
const tone=d=>{if(!d)return'wait';const x=new Date(d+'T00:00:00'),now=new Date();now.setHours(0,0,0,0);const days=Math.ceil((x-now)/86400000);return days<0?'crit':days<=60?'warn':'safe'};
const expirySummary=r=>{const xs=certItems(r);return xs.length?{xs.map(([n,d])=>
{n} {fmtDate(d)}
)}
:TBC };
return r.personnel_no||'—'},
{key:'name',label:'Name'},
{key:'role',label:'Position'},
{key:'competency',label:'Certificate Achieved',render:r=>r.competency||'—'},
{key:'certificate_expiries',label:'Certificate Expiry',render:expirySummary},
{key:'medical_status',label:'Medical Status',render:r=>{r.medical_status} },
{key:'status',label:'Status',render:r=>{r.status} }
]}/>
}
function Equipment({data,onRefresh,query}){
const fields=[
{name:'asset_code',label:'Asset Code'},
{name:'name',label:'Equipment / Resource Name',required:true},
{name:'equipment_group',label:'Equipment Group',type:'select',options:['Aerial Mapping & LiDAR','Aerial Photogrammetry Drone','Supporting Equipment – Processing & GCP','Topographic & Engineering Survey Equipment','Supporting Equipment – Field & HSE','Other']},
{name:'category',label:'Category',type:'select',options:['UAV','LiDAR Payload','Battery','GNSS Receiver','Workstation','Software','GCP / Survey Support','Communication','PPE','Field Device','Survey Consumables','Charger','Emergency Equipment','Other']},
{name:'quantity',label:'Quantity',type:'number',min:0},
{name:'associated_with',label:'Associated With',help:'Example: battery associated with a specific UAV model.'},
{name:'serial_no',label:'Serial No. / Licence Ref.'},
{name:'status',label:'Status',type:'select',options:['Available','Assigned','Inspection Due','Quarantined','Maintenance','Unavailable']},
{name:'inspection_due',label:'Inspection / Renewal Due',type:'date'},
{name:'notes',label:'Notes',type:'textarea',full:true}
];
const rows=data.equipment||[];
const totalQty=rows.reduce((n,r)=>n+(Number(r.quantity)||0),0);
const uavQty=rows.filter(r=>r.category==='UAV').reduce((n,r)=>n+(Number(r.quantity)||0),0);
const lidarQty=rows.filter(r=>r.category==='LiDAR Payload').reduce((n,r)=>n+(Number(r.quantity)||0),0);
const gnssQty=rows.filter(r=>r.category==='GNSS Receiver').reduce((n,r)=>n+(Number(r.quantity)||0),0);
return
{r.asset_code||'—'} },
{key:'name',label:'Equipment / Resource'},
{key:'equipment_group',label:'Group',render:r=>{r.equipment_group||'—'}
},
{key:'category',label:'Category'},
{key:'quantity',label:'Qty',render:r=>{r.quantity??1} },
{key:'associated_with',label:'Associated With',render:r=>r.associated_with||'—'},
{key:'serial_no',label:'Serial / Ref.',render:r=>r.serial_no||'—'},
{key:'inspection_due',label:'Inspection / Renewal Due',render:r=>fmtDate(r.inspection_due)},
{key:'status',label:'Status',render:r=>{r.status} }
]}/>
}
function Audit({data,query}){const rows=data.audit.filter(r=>!query||JSON.stringify(r).toLowerCase().includes(query.toLowerCase()));return Audit Trail Recent sign-ins, creates, edits, uploads and deletions.
new Date(r.created_at.replace(' ','T')).toLocaleString('en-MY')},{key:'user_name',label:'User'},{key:'action',label:'Action',render:r=>{r.action} },{key:'entity',label:'Entity'},{key:'entity_id',label:'Record ID'},{key:'details',label:'Details'}]}/> }
function Emergency({data,project,onClose}){const nerf=data.modules.filter(r=>r.module==='nerf');const vessel=data.vessels.find(v=>!project||Number(v.project_id)===project.id)||{};const [event,setEvent]=useState(null);const contacts=data.personnel.filter(p=>p.phone).slice(0,6);return EMERGENCY RESPONSE MODE
NERF quick-response view
Exit Emergency View Project
{project?.name||'TBC'}
Worksite / AOI
{project?.location||'TBC'}
Vessel
{vessel.vessel_name||'TBC'}
Registration
{vessel.registration_no||'TBC'}
{(!vessel.vessel_name||!vessel.registration_no)&& Vessel emergency particulars are not yet confirmed.
}{contacts.length?contacts.map(p=>
{p.name}
{p.role||'Project personnel'}
):
Emergency contacts are TBC. Complete Personnel & Competency before mobilisation.
}
Life-saving response, rescue, first aid and immediate hazard control take priority over administrative reporting.
{nerf.map(n=>
setEvent(n)} className="min-h-[96px] text-left p-4 rounded-xl border border-marine-border bg-marine-bg hover:border-status-crit hover:bg-status-crit/5 transition">{n.title}
{n.details}
)}
INCIDENT / EMERGENCY
STOP WORK • MAKE SAFE • PROVIDE IMMEDIATE RESPONSE
RAISE ALARM • VESSEL MASTER / SITE AUTHORITY
NOTIFY PGR PROJECT MANAGER / HSE • CLIENT INTERFACE
{event&&
setEvent(null)}>{event.details}
{event.status}
}
}
function ClientPortalAdmin({data,project,onRefresh}){
const[clientEdit,setClientEdit]=useState(null);const[accessEdit,setAccessEdit]=useState(null);const[deleteAccess,setDeleteAccess]=useState(null);const[busy,setBusy]=useState('');const[tab,setTab]=useState('clients');
if(!data.client_portal_ready)return
CLIENT PORTAL MODULE
One-time database update required The interface files are installed, but the project-level client permission tables have not been created yet.
Run Client Portal Update ;
const clients=data.client_users||[];const accesses=data.client_access||[];const reviews=data.document_reviews||[];const currentProjectId=project?.id?Number(project.id):0;
const clientFields=[{name:'name',label:'Client User Name',required:true},{name:'email',label:'Email',type:'email',required:true},{name:'company_name',label:'Company / Organisation'},{name:'role',label:'Role',type:'select',required:true,options:['Client Viewer','Client Approver']},{name:'password',label:'Password',type:'password',help:'Required for a new account. Leave blank while editing to keep the existing password.'},{name:'is_active',label:'Active Account',type:'checkbox',help:'Allow this client account to sign in.'}];
const userOptions=clients.filter(c=>Number(c.is_active)).map(c=>[c.id,`${c.company_name?c.company_name+' • ':''}${c.name} (${c.role})`]);
const projectOptions=data.projects.filter(p=>Number(p.is_active)).map(p=>[p.id,`${p.project_code} • ${p.name}`]);
const accessFields=[{name:'user_id',label:'Client User',type:'select',required:true,options:userOptions},{name:'project_id',label:'Project',type:'select',required:true,options:projectOptions},{name:'can_view_documents',label:'Documents',type:'checkbox',help:'View documents released to the client.'},{name:'can_review_documents',label:'Review / Accept Documents',type:'checkbox',help:'Approver can Accept or Request Revision.'},{name:'can_view_actions',label:'HSE Actions',type:'checkbox',help:'View client-visible actions.'},{name:'can_view_vessel',label:'Vessel Assurance',type:'checkbox',help:'View vessel assurance summary.'},{name:'can_view_risk_summary',label:'HEMP Risk Summary',type:'checkbox',help:'Aggregate risk summary only.'},{name:'can_view_mersa',label:'MERSA',type:'checkbox',help:'View medical emergency readiness items.'},{name:'can_view_nerf',label:'NERF',type:'checkbox',help:'View notification/emergency response items.'},{name:'can_view_incidents',label:'Incidents',type:'checkbox',help:'View incidents explicitly marked client-visible.'}];
const saveClient=async f=>{await api('client_save_user',{...f,id:f.id||0});await onRefresh()};
const saveAccess=async f=>{await api('client_save_access',f);await onRefresh()};
const toggleProject=async enabled=>{if(!currentProjectId)return;setBusy('project');try{await api('client_project_toggle',{project_id:currentProjectId,enabled});await onRefresh()}finally{setBusy('')}};
const setVisibility=async(entity,row,visible)=>{setBusy(`${entity}-${row.id}`);try{await api('client_set_visibility',{entity,id:row.id,visible,project_id:currentProjectId});await onRefresh()}finally{setBusy('')}};
const projectDocs=data.documents.filter(d=>Number(d.project_id||0)===currentProjectId||!d.project_id);const projectActions=data.actions.filter(a=>Number(a.project_id||0)===currentProjectId);const projectIncidents=data.incidents.filter(i=>Number(i.project_id||0)===currentProjectId);const projectAccess=accesses.filter(a=>Number(a.project_id)===currentProjectId);const projectReviews=reviews.filter(r=>Number(r.project_id)===currentProjectId);
const Toggle=({on,disabled,onChange})=>onChange(!Number(on))} className={`relative w-11 h-6 rounded-full transition ${Number(on)?'bg-pgr':'bg-marine-accent/50'} ${disabled?'opacity-50':''}`}> ;
return ADMINISTRATION
Client Portal Management Project-level access, client-visible content and document review controls.
Open Client Portal
Selected Project
{project?.project_code||'—'} • {project?.name||'No project selected'}
Client portal can be disabled instantly without deleting client accounts or history.
{Number(project?.client_portal_enabled)?'PORTAL ENABLED':'PORTAL DISABLED'}
{[['clients','Client Accounts'],['access','Project Access'],['visibility','Client Visibility'],['reviews','Review History']].map(([id,label])=>setTab(id)} className={`btn ${tab===id?'btn-primary':'btn-ghost'}`}>{label} )}
{tab==='clients'&&
setClientEdit({role:'Client Viewer',is_active:1,password:''})}> Add Client}>Name Company Email Role Status Commands {clients.map(c=>{c.name} {c.company_name||'—'} {c.email} {c.role} {Number(c.is_active)?'Active':'Disabled'} setClientEdit({...c,password:''})}/> )}{!clients.length&&No client accounts yet. }
}
{tab==='access'&&
setAccessEdit({user_id:'',project_id:currentProjectId||'',can_view_documents:1,can_review_documents:0,can_view_actions:1,can_view_vessel:1,can_view_risk_summary:1,can_view_mersa:1,can_view_nerf:1,can_view_incidents:0})}> Assign Project}>{projectAccess.map(a=>
{a.user_name}
{a.company_name||'Client'} • {a.email} • {a.role}
{[['Docs',a.can_view_documents],['Review',a.can_review_documents],['Actions',a.can_view_actions],['Vessel',a.can_view_vessel],['Risk',a.can_view_risk_summary],['MERSA',a.can_view_mersa],['NERF',a.can_view_nerf],['Incidents',a.can_view_incidents]].filter(x=>Number(x[1])).map(x=>{x[0]} )}
setAccessEdit(a)}/>setDeleteAccess(a)}/>
)}{!projectAccess.length&&
No client account is assigned to this project.
}
}
{tab==='visibility'&&
{projectDocs.map(d=>
{d.document_no} • Rev {d.rev}
{d.title}
setVisibility('documents',d,v)}/> )}{!projectDocs.length&&
No documents available.
}
{projectActions.map(a=>
{a.title}
{a.priority} • {a.status}
setVisibility('actions',a,v)}/> )}{!projectActions.length&&
No project actions.
}
{projectIncidents.map(i=>
{i.incident_no} • {i.event_type}
{i.severity} • {i.status}
setVisibility('incidents',i,v)}/> )}{!projectIncidents.length&&
No project incidents.
}
}
{tab==='reviews'&&
{projectReviews.map(r=>
{r.document_no} • Rev {r.rev}
{r.reviewer} {r.company_name?`• ${r.company_name}`:''} • {r.created_at}
{r.comments&&
{r.comments}
}
{r.decision} )}{!projectReviews.length&&
No client reviews recorded for this project.
}
}
{clientEdit&&
setClientEdit(null)} onSave={saveClient}/>} {accessEdit&&setAccessEdit(null)} onSave={saveAccess}/>} {deleteAccess&&setDeleteAccess(null)} onYes={async()=>{await api('client_delete_access',{id:deleteAccess.id});await onRefresh()}}/>}
}
function PasswordModal({onClose}){const[err,setErr]=useState('');const[ok,setOk]=useState('');const submit=async e=>{e.preventDefault();const fd=new FormData(e.currentTarget);const a=fd.get('new_password'),b=fd.get('confirm');if(a!==b){setErr('New password and confirmation do not match.');return}try{await api('change_password',{current_password:fd.get('current_password'),new_password:a});setOk('Password changed successfully.');setErr('')}catch(e){setErr(e.message)}};return {err&&{err}
}{ok&&{ok}
}Current Password
New Password
Confirm New Password
Close Change Password
}
function App(){const[user,setUser]=useState(null);const[data,setData]=useState(null);const[view,setView]=useState('dashboard');const[activeProjectId,setActiveProjectId]=useState(null);const[query,setQuery]=useState('');const[loading,setLoading]=useState(true);const[err,setErr]=useState('');const[emergency,setEmergency]=useState(false);const[pwModal,setPwModal]=useState(false);const[sidebarOpen,setSidebarOpen]=useState(false);
const load=async()=>{setErr('');try{const r=await api('bootstrap');setData(r);setUser(r.user);setActiveProjectId(id=>id||r.projects?.[0]?.id||null)}catch(e){if(e.message==='Unauthorised'){setUser(null);setData(null)}else setErr(e.message)}};
useEffect(()=>{(async()=>{try{const s=await api('session');if(s.authenticated){setUser(s.user);await load()}}catch(e){setErr(e.message)}finally{setLoading(false)}})()},[]);
const project=useMemo(()=>data?.projects?.find(p=>Number(p.id)===Number(activeProjectId))||data?.projects?.[0]||null,[data,activeProjectId]);
const logout=async()=>{try{await api('logout',{})}catch{}setUser(null);setData(null);setEmergency(false)};
if(loading)return Loading HSE Command Centre…
;
if(!user)return {setUser(u);setLoading(true);await load();setLoading(false)}}/>;
if(!data)return Unable to load dashboard
{err}
Retry ;
let content=null;const props={data,project,onRefresh:load,query};switch(view){case'dashboard':content=;break;case'projects':content= ;break;case'docs':content= ;break;case'hemp':content= ;break;case'mersa':content= ;break;case'nerf':content= ;break;case'vessel':content= ;break;case'daily_hse':content= ;break;case'simops':content= ;break;case'incidents':content= ;break;case'actions':content= ;break;case'personnel':content= ;break;case'equipment':content= ;break;case'audit':content=;break;case'client_admin':content= ;break;default:content=}
return {setView(v);setQuery('')}} open={sidebarOpen} setOpen={setSidebarOpen}/>{sidebarOpen&&setSidebarOpen(false)}>
}setPwModal(true)} setSidebarOpen={setSidebarOpen}/>{err&& {err}
}{content} {emergency&&setEmergency(false)}/>} {pwModal&&setPwModal(false)}/>} }
ReactDOM.createRoot(document.getElementById('root')).render( );