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:
+17
-8
@@ -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-...
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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 : [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user