feat(config): phase 7 — drop /ims basePath, update all hardcoded paths, refresh env spec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 22:27:46 +08:00
co-authored by Claude Sonnet 4.6
parent d785d86636
commit 9d047e168f
18 changed files with 45 additions and 37 deletions
+17 -8
View File
@@ -1,14 +1,23 @@
# .env.local.example # Database (plain PostgreSQL — no Supabase)
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co DATABASE_URL=postgres://app_user:<password>@127.0.0.1:5432/ims
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here DATABASE_URL_ADMIN=postgres://app_admin:<password>@127.0.0.1:5432/ims
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Auth (custom JWT)
JWT_SECRET=<generate with: openssl rand -hex 32>
# App
APP_URL=http://localhost:3000
NEXT_PUBLIC_SITE_URL=http://localhost:3000 NEXT_PUBLIC_SITE_URL=http://localhost:3000
CRON_SECRET=<generate with: openssl rand -hex 32> CRON_SECRET=<generate with: openssl rand -hex 32>
# Server-side only — never expose to the client # Evidence storage (local filesystem)
SUPABASE_SERVICE_ROLE_KEY=<required for admin user invites (/admin)> EVIDENCE_DIR=/tmp/ims-evidence
RESEND_API_KEY=re_... EVIDENCE_URL_SECRET=<generate with: openssl rand -hex 32>
RESEND_FROM_EMAIL=alerts@yourdomain.com
# Email (Brevo)
BREVO_API_KEY=<your-brevo-api-key>
BREVO_FROM_EMAIL=noreply@setia.com.my
BREVO_FROM_NAME=Setia IMS
# AI keys — optional here; preferred location is the app_settings table (/hse/settings) # AI keys — optional here; preferred location is the app_settings table (/hse/settings)
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_API_KEY=sk-ant-...
+3 -3
View File
@@ -164,13 +164,13 @@ export default async function HseDashboardPage({
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1> <h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<div className="flex gap-3"> <div className="flex gap-3">
<a <a
href="/ims/api/dashboard/export?role=hse" href="/api/dashboard/export?role=hse"
className="text-sm text-gray-500 hover:text-gray-700 border border-gray-200 rounded-lg px-3 py-1.5" className="text-sm text-gray-500 hover:text-gray-700 border border-gray-200 rounded-lg px-3 py-1.5"
> >
Export CSV Export CSV
</a> </a>
<a <a
href={`/ims/api/reports/jkkp8?year=${now.getFullYear()}`} href={`/api/reports/jkkp8?year=${now.getFullYear()}`}
className="text-sm text-gray-500 hover:text-gray-700 border border-gray-200 rounded-lg px-3 py-1.5" className="text-sm text-gray-500 hover:text-gray-700 border border-gray-200 rounded-lg px-3 py-1.5"
> >
JKKP 8 JKKP 8
@@ -191,7 +191,7 @@ export default async function HseDashboardPage({
<p className="text-sm text-amber-800"> <p className="text-sm text-amber-800">
<strong>JKKP 8 annual register due:</strong> the {now.getFullYear() - 1} register must be <strong>JKKP 8 annual register due:</strong> the {now.getFullYear() - 1} register must be
submitted to DOSH before 31 January {now.getFullYear()}.{' '} submitted to DOSH before 31 January {now.getFullYear()}.{' '}
<a href={`/ims/api/reports/jkkp8?year=${now.getFullYear() - 1}`} className="underline font-medium"> <a href={`/api/reports/jkkp8?year=${now.getFullYear() - 1}`} className="underline font-medium">
Download {now.getFullYear() - 1} register Download {now.getFullYear() - 1} register
</a> </a>
</p> </p>
+2 -2
View File
@@ -173,14 +173,14 @@ export default async function HseIncidentDetailPage({ params }: Props) {
{needsJkkp && ( {needsJkkp && (
<div className="mt-4 flex gap-3 flex-wrap"> <div className="mt-4 flex gap-3 flex-wrap">
<a <a
href={`/ims/api/incidents/${id}/jkkp-pdf?form=jkkp6`} href={`/api/incidents/${id}/jkkp-pdf?form=jkkp6`}
target="_blank" target="_blank"
className="inline-block bg-gray-800 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-900" className="inline-block bg-gray-800 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-900"
> >
Download JKKP 6 (PDF) Download JKKP 6 (PDF)
</a> </a>
<a <a
href={`/ims/api/incidents/${id}/jkkp-pdf?form=jkkp7`} href={`/api/incidents/${id}/jkkp-pdf?form=jkkp7`}
target="_blank" target="_blank"
className="inline-block bg-gray-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-700" className="inline-block bg-gray-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-700"
> >
+1 -1
View File
@@ -92,7 +92,7 @@ export default async function ManagementPage() {
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Management Dashboard</h1> <h1 className="text-2xl font-bold text-gray-900">Management Dashboard</h1>
<a <a
href="/ims/api/dashboard/export?role=management" href="/api/dashboard/export?role=management"
className="text-sm text-gray-500 hover:text-gray-700 border border-gray-200 rounded-lg px-3 py-1.5" className="text-sm text-gray-500 hover:text-gray-700 border border-gray-200 rounded-lg px-3 py-1.5"
> >
Export CSV Export CSV
+2 -2
View File
@@ -33,7 +33,7 @@ export default async function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<head> <head>
<link rel="manifest" href="/ims/manifest.json" /> <link rel="manifest" href="/manifest.json" />
</head> </head>
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<I18nProvider messages={messages}> <I18nProvider messages={messages}>
@@ -41,7 +41,7 @@ export default async function RootLayout({
</I18nProvider> </I18nProvider>
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: `if('serviceWorker'in navigator){navigator.serviceWorker.register('/ims/sw.js',{scope:'/ims/'}).catch(console.error)}`, __html: `if('serviceWorker'in navigator){navigator.serviceWorker.register('/sw.js',{scope:'/'}).catch(console.error)}`,
}} }}
/> />
</body> </body>
+1 -1
View File
@@ -153,7 +153,7 @@ export function SiteZoneManager({ sites }: Props) {
<span>{z.name}</span> <span>{z.name}</span>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<a <a
href={`/ims/report?zone=${z.qr_code_token}`} href={`/report?zone=${z.qr_code_token}`}
target="_blank" target="_blank"
className="text-xs text-blue-600 hover:underline" className="text-xs text-blue-600 hover:underline"
> >
+1 -1
View File
@@ -127,7 +127,7 @@ export function TruckManager({ trucks }: Props) {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<a <a
href={`/ims/report?truck_id=${tk.id}`} href={`/report?truck_id=${tk.id}`}
target="_blank" target="_blank"
className="text-xs text-blue-600 hover:underline" className="text-xs text-blue-600 hover:underline"
> >
+1 -1
View File
@@ -51,7 +51,7 @@ export function UserManager({ users, sites }: Props) {
const deleteUser = async (id: string) => { const deleteUser = async (id: string) => {
setBusyId(id) setBusyId(id)
setError(null) setError(null)
const res = await fetch(`/ims/api/admin/users?id=${encodeURIComponent(id)}`, { method: 'DELETE' }) const res = await fetch(`/api/admin/users?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
setBusyId(null) setBusyId(null)
setConfirmDeleteId(null) setConfirmDeleteId(null)
if (!res.ok) { if (!res.ok) {
+1 -1
View File
@@ -15,7 +15,7 @@ export function CapaOwnerActions({ capaId, currentStatus }: Props) {
async function updateStatus(status: string) { async function updateStatus(status: string) {
setLoading(true) setLoading(true)
try { try {
const res = await fetch(`/ims/api/capa/${capaId}`, { const res = await fetch(`/api/capa/${capaId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }), body: JSON.stringify({ status }),
+1 -1
View File
@@ -17,7 +17,7 @@ export function CapaOwnerNotesForm({ capaId, initialNotes }: Props) {
setSaving(true) setSaving(true)
setSaved(false) setSaved(false)
setError(null) setError(null)
const res = await fetch(`/ims/api/capa/${capaId}`, { const res = await fetch(`/api/capa/${capaId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ owner_notes: notes }), body: JSON.stringify({ owner_notes: notes }),
+1 -1
View File
@@ -16,7 +16,7 @@ export function CloseCapaButton({ capaId }: Props) {
if (!confirm('Close this CAPA? This marks it as fully resolved.')) return if (!confirm('Close this CAPA? This marks it as fully resolved.')) return
setLoading(true) setLoading(true)
setError(null) setError(null)
const res = await fetch(`/ims/api/capa/${capaId}`, { const res = await fetch(`/api/capa/${capaId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'closed' }), body: JSON.stringify({ status: 'closed' }),
+1 -1
View File
@@ -23,7 +23,7 @@ export function VerifyForm({ capaId }: Props) {
} }
setSaving(true) setSaving(true)
setError(null) setError(null)
const res = await fetch(`/ims/api/capa/${capaId}/verify`, { const res = await fetch(`/api/capa/${capaId}/verify`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verdict, reopen_reason: reopenReason || null }), body: JSON.stringify({ verdict, reopen_reason: reopenReason || null }),
+4 -4
View File
@@ -28,7 +28,7 @@ export function ClosurePanel({ incidentId, status, canClose, canAddAddenda }: Pr
useEffect(() => { useEffect(() => {
if (!isClosed) return if (!isClosed) return
fetch(`/ims/api/incidents/${incidentId}/addenda`) fetch(`/api/incidents/${incidentId}/addenda`)
.then(r => (r.ok ? r.json() : Promise.reject())) .then(r => (r.ok ? r.json() : Promise.reject()))
.then((data: Addendum[]) => setAddenda(Array.isArray(data) ? data : [])) .then((data: Addendum[]) => setAddenda(Array.isArray(data) ? data : []))
.catch(() => {}) .catch(() => {})
@@ -38,7 +38,7 @@ export function ClosurePanel({ incidentId, status, canClose, canAddAddenda }: Pr
if (!confirm('Close this incident? The record will be locked — only addenda can be added afterwards.')) return if (!confirm('Close this incident? The record will be locked — only addenda can be added afterwards.')) return
setBusy(true) setBusy(true)
setError(null) setError(null)
const res = await fetch(`/ims/api/incidents/${incidentId}/close`, { method: 'POST' }) const res = await fetch(`/api/incidents/${incidentId}/close`, { method: 'POST' })
setBusy(false) setBusy(false)
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
@@ -53,7 +53,7 @@ export function ClosurePanel({ incidentId, status, canClose, canAddAddenda }: Pr
if (!text) return if (!text) return
setBusy(true) setBusy(true)
setError(null) setError(null)
const res = await fetch(`/ims/api/incidents/${incidentId}/addenda`, { const res = await fetch(`/api/incidents/${incidentId}/addenda`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: text }), body: JSON.stringify({ body: text }),
@@ -65,7 +65,7 @@ export function ClosurePanel({ incidentId, status, canClose, canAddAddenda }: Pr
return return
} }
setDraft('') setDraft('')
const list = await fetch(`/ims/api/incidents/${incidentId}/addenda`).then(r => r.json()).catch(() => []) const list = await fetch(`/api/incidents/${incidentId}/addenda`).then(r => r.json()).catch(() => [])
setAddenda(Array.isArray(list) ? list : []) setAddenda(Array.isArray(list) ? list : [])
} }
+3 -3
View File
@@ -64,7 +64,7 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props
async function getAiDraft() { async function getAiDraft() {
setAiDraftLoading(true) setAiDraftLoading(true)
try { try {
const res = await fetch(`/ims/api/incidents/${incidentId}/ai/rca-draft`, { method: 'POST' }) const res = await fetch(`/api/incidents/${incidentId}/ai/rca-draft`, { method: 'POST' })
if (!res.ok) return if (!res.ok) return
const draft = await res.json() as { const draft = await res.json() as {
five_why_steps: Array<{ why: string; answer: string }> five_why_steps: Array<{ why: string; answer: string }>
@@ -106,13 +106,13 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props
let res: Response let res: Response
if (existingInvestigationId) { if (existingInvestigationId) {
res = await fetch(`/ims/api/incidents/${incidentId}/investigation`, { res = await fetch(`/api/incidents/${incidentId}/investigation`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, investigation_id: existingInvestigationId, complete }), body: JSON.stringify({ ...payload, investigation_id: existingInvestigationId, complete }),
}) })
} else { } else {
res = await fetch(`/ims/api/incidents/${incidentId}/investigation`, { res = await fetch(`/api/incidents/${incidentId}/investigation`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -31,7 +31,7 @@ export function SimilarIncidentsPanel({ incidentId }: Props) {
const [error, setError] = useState(false) const [error, setError] = useState(false)
useEffect(() => { useEffect(() => {
fetch(`/ims/api/incidents/${incidentId}/similar`) fetch(`/api/incidents/${incidentId}/similar`)
.then(r => { .then(r => {
if (!r.ok) throw new Error('failed') if (!r.ok) throw new Error('failed')
return r.json() return r.json()
+2 -2
View File
@@ -42,7 +42,7 @@ export function TriageForm({ incidentId, currentSeverity }: Props) {
setAiLoading(true) setAiLoading(true)
setAiRationale(null) setAiRationale(null)
try { try {
const res = await fetch(`/ims/api/incidents/${incidentId}/ai/triage-suggest`, { method: 'POST' }) const res = await fetch(`/api/incidents/${incidentId}/ai/triage-suggest`, { method: 'POST' })
if (!res.ok) return if (!res.ok) return
const data = await res.json() as { const data = await res.json() as {
severity: number severity: number
@@ -69,7 +69,7 @@ export function TriageForm({ incidentId, currentSeverity }: Props) {
e.preventDefault() e.preventDefault()
setSaving(true) setSaving(true)
setError(null) setError(null)
const res = await fetch(`/ims/api/incidents/${incidentId}/triage`, { const res = await fetch(`/api/incidents/${incidentId}/triage`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
-1
View File
@@ -1,7 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'standalone', output: 'standalone',
basePath: '/ims',
} }
module.exports = nextConfig module.exports = nextConfig
+3 -3
View File
@@ -2,14 +2,14 @@
"name": "IMS — Incident Management", "name": "IMS — Incident Management",
"short_name": "IMS", "short_name": "IMS",
"description": "HSE Incident Management System — Setia Corporation", "description": "HSE Incident Management System — Setia Corporation",
"start_url": "/ims/report", "start_url": "/report",
"scope": "/ims/", "scope": "/",
"display": "standalone", "display": "standalone",
"background_color": "#ffffff", "background_color": "#ffffff",
"theme_color": "#2563eb", "theme_color": "#2563eb",
"icons": [ "icons": [
{ {
"src": "/ims/icons/icon.svg", "src": "/icons/icon.svg",
"sizes": "any", "sizes": "any",
"type": "image/svg+xml", "type": "image/svg+xml",
"purpose": "any maskable" "purpose": "any maskable"