feat: Phase 5 & 6 — usability, compliance hardening, analytics
Phase 5 (usability + compliance): - In-app notification bell/badge: migration 016 adds read state + per-user RLS + create_in_app_notification SECURITY DEFINER RPC; /api/notifications; wired into incident creation, CAPA assign/verify, escalation cron - Incident closure: new POST /api/incidents/[id]/close (requires verification status + all CAPAs verified); migration 017 locks closed incidents at DB level (update/delete triggers) with append-only incident_addenda + UI panel - Server-side pagination on HSE/supervisor incident inboxes (.range, 25/page) - Investigation form: alcohol/urine test result + witness statement refs (existing schema columns, now editable) - Type-specific intake fields: migration 018 adds incidents.type_details JSONB; whitelist validation; environmental/asset/security/fire field groups in report form; EN/MS/ZH labels; offline queue support - JKKP 8 annual register CSV export (/api/reports/jkkp8) + dashboard button + January statutory deadline banner - Admin page: user invite (service-role client), role/site/active management, site + zone CRUD with QR report links — replaces Phase 0 stub - Evidence gallery thumbnails via Supabase render transform with fallback Phase 6 (analytics): - 12-month stacked trend chart (leading/lagging/other) + top root causes (lib/dashboard/trends.ts pure helpers) - AI rising-risk zones: /api/dashboard/ai/risk-flags aggregates 90-day zone stats, claude-opus-4-8 forced tool_use, panel on HSE + management dashboards, suggestion audit-logged Also fixes 9 pre-existing missing /ims basePath prefixes in client fetches and download links. 132 tests passing, tsc clean, next build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
This commit is contained in:
@@ -1,9 +1,37 @@
|
||||
// app/(protected)/admin/page.tsx
|
||||
export default function AdminHome() {
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { UserManager, type AdminUser, type SiteOption } from '@/components/admin/user-manager'
|
||||
import { SiteZoneManager, type SiteWithZones } from '@/components/admin/site-zone-manager'
|
||||
|
||||
export default async function AdminHome() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from('users').select('role').eq('id', user.id).single()
|
||||
if (!profile || profile.role !== 'admin') redirect('/login')
|
||||
|
||||
const [{ data: users }, { data: sites }] = await Promise.all([
|
||||
supabase
|
||||
.from('users')
|
||||
.select('id, name, email, role, department, site_id, active')
|
||||
.order('created_at', { ascending: false }),
|
||||
supabase
|
||||
.from('sites')
|
||||
.select('id, name, address, zones (id, name, qr_code_token)')
|
||||
.order('name'),
|
||||
])
|
||||
|
||||
const siteOptions: SiteOption[] = (sites ?? []).map(s => ({ id: s.id, name: s.name }))
|
||||
|
||||
return (
|
||||
<main className="p-8 max-w-4xl mx-auto">
|
||||
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
|
||||
<p className="mt-2 text-gray-500">Phase 0: User management and site config coming here.</p>
|
||||
<main className="max-w-4xl mx-auto px-4 py-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Admin</h1>
|
||||
<UserManager users={(users ?? []) as AdminUser[]} sites={siteOptions} />
|
||||
<SiteZoneManager sites={(sites ?? []) as unknown as SiteWithZones[]} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ export const dynamic = 'force-dynamic'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { StatCard } from '@/components/dashboard/stat-card'
|
||||
import { bucketIncidentsByMonth, topRootCauses } from '@/lib/dashboard/trends'
|
||||
import { RiskFlagsPanel } from '@/components/dashboard/risk-flags-panel'
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
injury: 'Injury',
|
||||
@@ -22,6 +24,7 @@ export default async function HseDashboardPage() {
|
||||
thirtyDaysAgo.setDate(now.getDate() - 30)
|
||||
const ninetyDaysAgo = new Date(now)
|
||||
ninetyDaysAgo.setDate(now.getDate() - 90)
|
||||
const twelveMonthsAgo = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 11, 1))
|
||||
|
||||
const [
|
||||
{ data: incidents },
|
||||
@@ -29,6 +32,8 @@ export default async function HseDashboardPage() {
|
||||
{ data: zoneIncidents },
|
||||
{ data: completedCapas },
|
||||
{ data: doshPendingRows },
|
||||
{ data: yearIncidents },
|
||||
{ data: investigations },
|
||||
] = await Promise.all([
|
||||
supabase.from('incidents').select('id, status, incident_type, sites (name)'),
|
||||
supabase
|
||||
@@ -48,6 +53,14 @@ export default async function HseDashboardPage() {
|
||||
.from('dosh_reports')
|
||||
.select('id')
|
||||
.eq('status', 'pending'),
|
||||
supabase
|
||||
.from('incidents')
|
||||
.select('reported_at, incident_type')
|
||||
.gte('reported_at', twelveMonthsAgo.toISOString()),
|
||||
supabase
|
||||
.from('investigations')
|
||||
.select('root_cause_summary')
|
||||
.not('root_cause_summary', 'is', null),
|
||||
])
|
||||
|
||||
// --- Existing metrics ---
|
||||
@@ -105,23 +118,47 @@ export default async function HseDashboardPage() {
|
||||
// --- DOSH pending filings ---
|
||||
const doshPendingCount = doshPendingRows?.length ?? 0
|
||||
|
||||
// --- 12-month trend + top root causes ---
|
||||
const monthly = bucketIncidentsByMonth(yearIncidents ?? [], 12, now)
|
||||
const monthlyMax = Math.max(1, ...monthly.map(m => m.total))
|
||||
const rootCauses = topRootCauses(investigations ?? [])
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<div className="flex gap-3">
|
||||
<a
|
||||
href="/api/dashboard/export?role=hse"
|
||||
href="/ims/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"
|
||||
>
|
||||
Export CSV
|
||||
</a>
|
||||
<a
|
||||
href={`/ims/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"
|
||||
>
|
||||
JKKP 8 Register
|
||||
</a>
|
||||
<Link href="/hse/incidents" className="text-sm text-blue-600 hover:underline">
|
||||
View all incidents →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JKKP 8 statutory deadline reminder — register due to DOSH before 31 January */}
|
||||
{now.getMonth() === 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 mb-6">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>JKKP 8 annual register due:</strong> the {now.getFullYear() - 1} register must be
|
||||
submitted to DOSH before 31 January {now.getFullYear()}.{' '}
|
||||
<a href={`/ims/api/reports/jkkp8?year=${now.getFullYear() - 1}`} className="underline font-medium">
|
||||
Download {now.getFullYear() - 1} register
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
|
||||
<StatCard label="Total Incidents" value={total} />
|
||||
@@ -161,6 +198,54 @@ export default async function HseDashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RiskFlagsPanel />
|
||||
|
||||
{/* 12-month incident trend */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-5 mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-1">
|
||||
Incident Trend — Last 12 Months
|
||||
</h2>
|
||||
<p className="text-xs text-gray-400 mb-4">
|
||||
Blue: leading (hazard + near miss) · Red: lagging (injury) · Grey: other
|
||||
</p>
|
||||
<div className="flex items-end gap-1 h-32">
|
||||
{monthly.map(m => {
|
||||
const other = m.total - m.leading - m.lagging
|
||||
return (
|
||||
<div key={m.month} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="w-full flex flex-col-reverse" style={{ height: '100px' }}>
|
||||
<div className="w-full bg-blue-400" style={{ height: `${(m.leading / monthlyMax) * 100}px` }} />
|
||||
<div className="w-full bg-red-400" style={{ height: `${(m.lagging / monthlyMax) * 100}px` }} />
|
||||
<div className="w-full bg-gray-300" style={{ height: `${(Math.max(0, other) / monthlyMax) * 100}px` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-400">{m.label}</span>
|
||||
<span className="text-[10px] font-semibold text-gray-600">{m.total || ''}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top root causes */}
|
||||
{rootCauses.length > 0 && (
|
||||
<div className="bg-white rounded-xl shadow-sm p-5 mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">
|
||||
Top Root Causes
|
||||
</h2>
|
||||
<ol className="space-y-2">
|
||||
{rootCauses.map((rc, i) => (
|
||||
<li key={rc.cause} className="flex items-start justify-between gap-3">
|
||||
<span className="text-sm text-gray-700">
|
||||
<span className="text-gray-400 mr-2">{i + 1}.</span>
|
||||
{rc.cause}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-900 shrink-0">{rc.count}×</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zone heatmap — last 90 days */}
|
||||
{by_zone.length > 0 && (
|
||||
<div className="bg-white rounded-xl shadow-sm p-5 mb-4">
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { IncidentDetail, type Incident } from '@/components/incidents/incident-detail'
|
||||
import { SimilarIncidentsPanel } from '@/components/incidents/similar-incidents-panel'
|
||||
import { ClosurePanel } from '@/components/incidents/closure-panel'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>
|
||||
@@ -18,7 +19,7 @@ export default async function HseIncidentDetailPage({ params }: Props) {
|
||||
.from('incidents')
|
||||
.select(`
|
||||
id, reference_no, incident_type, description, severity, status,
|
||||
injury_involved, asset_involved, medical_status, lost_days,
|
||||
injury_involved, asset_involved, medical_status, lost_days, type_details,
|
||||
is_fatality, is_serious_bodily_injury, is_dangerous_occurrence, is_occupational_disease,
|
||||
reported_at, closed_at,
|
||||
sites (id, name),
|
||||
@@ -80,14 +81,14 @@ export default async function HseIncidentDetailPage({ params }: Props) {
|
||||
) && (
|
||||
<div className="mt-4 flex gap-3">
|
||||
<a
|
||||
href={`/api/incidents/${id}/jkkp-pdf?form=jkkp6`}
|
||||
href={`/ims/api/incidents/${id}/jkkp-pdf?form=jkkp6`}
|
||||
target="_blank"
|
||||
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)
|
||||
</a>
|
||||
<a
|
||||
href={`/api/incidents/${id}/jkkp-pdf?form=jkkp7`}
|
||||
href={`/ims/api/incidents/${id}/jkkp-pdf?form=jkkp7`}
|
||||
target="_blank"
|
||||
className="inline-block bg-gray-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-700"
|
||||
>
|
||||
@@ -95,6 +96,7 @@ export default async function HseIncidentDetailPage({ params }: Props) {
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<ClosurePanel incidentId={id} status={status} canClose canAddAddenda />
|
||||
<SimilarIncidentsPanel incidentId={id} />
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -2,28 +2,39 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { IncidentList, type Incident } from '@/components/incidents/incident-list'
|
||||
import { Pagination } from '@/components/incidents/pagination'
|
||||
|
||||
export default async function HseInboxPage() {
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
export default async function HseInboxPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string }>
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
const params = await searchParams
|
||||
const page = Math.max(1, Number.parseInt(params.page ?? '1', 10) || 1)
|
||||
const from = (page - 1) * PAGE_SIZE
|
||||
|
||||
const { data: incidents } = await supabase
|
||||
const { data: incidents, count } = await supabase
|
||||
.from('incidents')
|
||||
.select(`
|
||||
id, reference_no, incident_type, status, severity, reported_at,
|
||||
sites (name),
|
||||
zones (name),
|
||||
reporter:users!reported_by (name)
|
||||
`)
|
||||
`, { count: 'exact' })
|
||||
.order('reported_at', { ascending: false })
|
||||
.limit(100)
|
||||
.range(from, from + PAGE_SIZE - 1)
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Incident Inbox</h1>
|
||||
<span className="text-sm text-gray-500">{incidents?.length ?? 0} incidents</span>
|
||||
<span className="text-sm text-gray-500">{count ?? incidents?.length ?? 0} incidents</span>
|
||||
</div>
|
||||
<IncidentList incidents={(incidents ?? []) as unknown as Incident[]} basePath="/hse" />
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={count ?? 0} href="/hse/incidents" />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { NotificationBell } from '@/components/notifications/bell'
|
||||
|
||||
export default async function ProtectedLayout({
|
||||
children,
|
||||
@@ -16,5 +17,10 @@ export default async function ProtectedLayout({
|
||||
|
||||
if (!user) redirect('/login')
|
||||
|
||||
return <>{children}</>
|
||||
return (
|
||||
<>
|
||||
<NotificationBell />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { StatCard } from '@/components/dashboard/stat-card'
|
||||
import { RiskFlagsPanel } from '@/components/dashboard/risk-flags-panel'
|
||||
|
||||
export default async function ManagementPage() {
|
||||
const supabase = await createClient()
|
||||
@@ -71,13 +72,15 @@ export default async function ManagementPage() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Management Dashboard</h1>
|
||||
<a
|
||||
href="/api/dashboard/export?role=management"
|
||||
href="/ims/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"
|
||||
>
|
||||
Export CSV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<RiskFlagsPanel />
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
|
||||
<StatCard
|
||||
|
||||
@@ -2,28 +2,39 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { IncidentList, type Incident } from '@/components/incidents/incident-list'
|
||||
import { Pagination } from '@/components/incidents/pagination'
|
||||
|
||||
export default async function SupervisorInboxPage() {
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
export default async function SupervisorInboxPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string }>
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
const params = await searchParams
|
||||
const page = Math.max(1, Number.parseInt(params.page ?? '1', 10) || 1)
|
||||
const from = (page - 1) * PAGE_SIZE
|
||||
|
||||
const { data: incidents } = await supabase
|
||||
const { data: incidents, count } = await supabase
|
||||
.from('incidents')
|
||||
.select(`
|
||||
id, reference_no, incident_type, status, severity, reported_at,
|
||||
sites (name),
|
||||
zones (name),
|
||||
reporter:users!reported_by (name)
|
||||
`)
|
||||
`, { count: 'exact' })
|
||||
.order('reported_at', { ascending: false })
|
||||
.limit(100)
|
||||
.range(from, from + PAGE_SIZE - 1)
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Incident Inbox</h1>
|
||||
<span className="text-sm text-gray-500">{incidents?.length ?? 0} incidents</span>
|
||||
<span className="text-sm text-gray-500">{count ?? incidents?.length ?? 0} incidents</span>
|
||||
</div>
|
||||
<IncidentList incidents={(incidents ?? []) as unknown as Incident[]} basePath="/supervisor" />
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={count ?? 0} href="/supervisor/incidents" />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
async function requireAdmin() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user }, error } = await supabase.auth.getUser()
|
||||
if (error || !user) return { supabase, user: null }
|
||||
const { data: profile } = await supabase
|
||||
.from('users').select('role').eq('id', user.id).single()
|
||||
if (!profile || profile.role !== 'admin') return { supabase, user: null }
|
||||
return { supabase, user }
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { supabase, user } = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const body: { kind?: 'site' | 'zone'; name?: string; address?: string; site_id?: string } =
|
||||
await request.json().catch(() => ({}))
|
||||
const name = (body.name ?? '').trim()
|
||||
if (!name) return NextResponse.json({ error: 'name required' }, { status: 422 })
|
||||
|
||||
if (body.kind === 'zone') {
|
||||
if (!body.site_id) return NextResponse.json({ error: 'site_id required for zone' }, { status: 422 })
|
||||
const { data: zone, error } = await supabase
|
||||
.from('zones')
|
||||
.insert({ site_id: body.site_id, name })
|
||||
.select('id, qr_code_token')
|
||||
.single()
|
||||
if (error || !zone) return NextResponse.json({ error: 'Insert failed' }, { status: 500 })
|
||||
|
||||
await supabase.rpc('write_audit_log', {
|
||||
p_table_name: 'zones',
|
||||
p_record_id: zone.id,
|
||||
p_action: 'INSERT',
|
||||
p_new_value: { name, site_id: body.site_id },
|
||||
})
|
||||
return NextResponse.json({ id: zone.id, qr_code_token: zone.qr_code_token }, { status: 201 })
|
||||
}
|
||||
|
||||
const { data: site, error } = await supabase
|
||||
.from('sites')
|
||||
.insert({ name, address: body.address ?? null })
|
||||
.select('id')
|
||||
.single()
|
||||
if (error || !site) return NextResponse.json({ error: 'Insert failed' }, { status: 500 })
|
||||
|
||||
await supabase.rpc('write_audit_log', {
|
||||
p_table_name: 'sites',
|
||||
p_record_id: site.id,
|
||||
p_action: 'INSERT',
|
||||
p_new_value: { name, address: body.address ?? null },
|
||||
})
|
||||
return NextResponse.json({ id: site.id }, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { isValidRole } from '@/lib/auth/roles'
|
||||
|
||||
async function requireAdmin() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user }, error } = await supabase.auth.getUser()
|
||||
if (error || !user) return { supabase, user: null }
|
||||
const { data: profile } = await supabase
|
||||
.from('users').select('role').eq('id', user.id).single()
|
||||
if (!profile || profile.role !== 'admin') return { supabase, user: null }
|
||||
return { supabase, user }
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const { supabase, user } = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('users')
|
||||
.select('id, name, email, phone, role, department, site_id, active, created_at')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) return NextResponse.json({ error: 'Fetch failed' }, { status: 500 })
|
||||
return NextResponse.json(data ?? [])
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { supabase, user } = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const body: { email?: string; name?: string; role?: string; site_id?: string } =
|
||||
await request.json().catch(() => ({}))
|
||||
const email = (body.email ?? '').trim().toLowerCase()
|
||||
if (!email || !email.includes('@'))
|
||||
return NextResponse.json({ error: 'Valid email required' }, { status: 422 })
|
||||
if (body.role && !isValidRole(body.role))
|
||||
return NextResponse.json({ error: 'Invalid role' }, { status: 422 })
|
||||
|
||||
let admin
|
||||
try {
|
||||
admin = createAdminClient()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'User invites unavailable: SUPABASE_SERVICE_ROLE_KEY not configured' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data: invited, error: inviteError } = await admin.auth.admin.inviteUserByEmail(email, {
|
||||
data: { full_name: body.name ?? '' },
|
||||
})
|
||||
if (inviteError || !invited?.user)
|
||||
return NextResponse.json({ error: inviteError?.message ?? 'Invite failed' }, { status: 500 })
|
||||
|
||||
// handle_new_auth_user trigger creates the profile row; set role/site on top of it
|
||||
const { error: profileError } = await admin
|
||||
.from('users')
|
||||
.update({
|
||||
name: body.name ?? '',
|
||||
role: body.role ?? 'reporter',
|
||||
site_id: body.site_id ?? null,
|
||||
})
|
||||
.eq('id', invited.user.id)
|
||||
if (profileError)
|
||||
return NextResponse.json({ error: 'Invite sent but profile update failed' }, { status: 500 })
|
||||
|
||||
await supabase.rpc('write_audit_log', {
|
||||
p_table_name: 'users',
|
||||
p_record_id: invited.user.id,
|
||||
p_action: 'invited',
|
||||
p_new_value: { email, role: body.role ?? 'reporter', site_id: body.site_id ?? null },
|
||||
})
|
||||
|
||||
return NextResponse.json({ id: invited.user.id }, { status: 201 })
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const { supabase, user } = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const body: {
|
||||
id?: string
|
||||
role?: string
|
||||
site_id?: string | null
|
||||
active?: boolean
|
||||
department?: string | null
|
||||
} = await request.json().catch(() => ({}))
|
||||
|
||||
if (!body.id) return NextResponse.json({ error: 'id required' }, { status: 422 })
|
||||
if (body.role !== undefined && !isValidRole(body.role))
|
||||
return NextResponse.json({ error: 'Invalid role' }, { status: 422 })
|
||||
if (body.id === user.id && body.active === false)
|
||||
return NextResponse.json({ error: 'Cannot deactivate your own account' }, { status: 422 })
|
||||
if (body.id === user.id && body.role !== undefined && body.role !== 'admin')
|
||||
return NextResponse.json({ error: 'Cannot demote your own account' }, { status: 422 })
|
||||
|
||||
const update: Record<string, unknown> = {}
|
||||
if (body.role !== undefined) update.role = body.role
|
||||
if (body.site_id !== undefined) update.site_id = body.site_id
|
||||
if (body.active !== undefined) update.active = body.active
|
||||
if (body.department !== undefined) update.department = body.department
|
||||
if (Object.keys(update).length === 0)
|
||||
return NextResponse.json({ error: 'Nothing to update' }, { status: 422 })
|
||||
|
||||
const { data: before } = await supabase
|
||||
.from('users').select('role, site_id, active, department').eq('id', body.id).single()
|
||||
|
||||
const { error } = await supabase.from('users').update(update).eq('id', body.id)
|
||||
if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 })
|
||||
|
||||
await supabase.rpc('write_audit_log', {
|
||||
p_table_name: 'users',
|
||||
p_record_id: body.id,
|
||||
p_action: 'admin_update',
|
||||
p_old_value: before ?? null,
|
||||
p_new_value: update,
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
@@ -19,7 +20,7 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { data: capa } = await supabase
|
||||
.from('capa_actions').select('status, incident_id').eq('id', id).single()
|
||||
.from('capa_actions').select('status, incident_id, owner_user_id').eq('id', id).single()
|
||||
if (!capa) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
if (capa.status !== 'pending_verification')
|
||||
return NextResponse.json({ error: 'CAPA must be pending_verification' }, { status: 409 })
|
||||
@@ -56,6 +57,18 @@ export async function POST(
|
||||
p_new_value: { ...update, reopen_reason: body.reopen_reason ?? null },
|
||||
})
|
||||
|
||||
if (capa.owner_user_id) {
|
||||
await createInAppNotifications(supabase, [{
|
||||
userId: capa.owner_user_id,
|
||||
title: body.verdict === 'verified'
|
||||
? 'Your CAPA action was verified'
|
||||
: `Your CAPA action was reopened${body.reopen_reason ? `: ${body.reopen_reason}` : ''}`,
|
||||
link: `/hse/capa/${id}`,
|
||||
incidentId: capa.incident_id,
|
||||
capaId: id,
|
||||
}])
|
||||
}
|
||||
|
||||
// Check if all CAPAs for this incident are verified — if so, transition incident to verification
|
||||
const { data: openCapas } = await supabase
|
||||
.from('capa_actions')
|
||||
|
||||
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
@@ -70,5 +71,13 @@ export async function POST(request: NextRequest) {
|
||||
p_new_value: { incident_id, description, owner_user_id, department, due_date },
|
||||
})
|
||||
|
||||
await createInAppNotifications(supabase, [{
|
||||
userId: owner_user_id,
|
||||
title: `CAPA assigned to you, due ${due_date}`,
|
||||
link: `/hse/capa/${capa.id}`,
|
||||
incidentId: incident_id,
|
||||
capaId: capa.id,
|
||||
}])
|
||||
|
||||
return NextResponse.json({ id: capa.id }, { status: 201 })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createAnthropicClient } from '@/lib/claude/client'
|
||||
import { getApiKey } from '@/lib/settings'
|
||||
|
||||
type ZoneAggregate = {
|
||||
zone: string
|
||||
site: string
|
||||
total: number
|
||||
near_miss: number
|
||||
hazard: number
|
||||
injury: number
|
||||
avg_severity: number | null
|
||||
first_half: number
|
||||
second_half: number
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
|
||||
if (!profile || !['hse', 'admin', 'management'].includes(profile.role))
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const now = new Date()
|
||||
const ninetyDaysAgo = new Date(now)
|
||||
ninetyDaysAgo.setDate(now.getDate() - 90)
|
||||
const midpoint = new Date(now)
|
||||
midpoint.setDate(now.getDate() - 45)
|
||||
|
||||
const { data: incidents } = await supabase
|
||||
.from('incidents')
|
||||
.select('incident_type, severity, reported_at, zones (name), sites (name)')
|
||||
.gte('reported_at', ninetyDaysAgo.toISOString())
|
||||
|
||||
const rows = incidents ?? []
|
||||
if (rows.length === 0)
|
||||
return NextResponse.json({ flags: [], summary: 'No incidents in the last 90 days.' })
|
||||
|
||||
const zoneMap = new Map<string, ZoneAggregate & { severitySum: number; severityCount: number }>()
|
||||
for (const r of rows) {
|
||||
const zone = (r.zones as unknown as { name: string } | null)?.name ?? 'Unknown zone'
|
||||
const site = (r.sites as unknown as { name: string } | null)?.name ?? 'Unknown site'
|
||||
const key = `${site}|${zone}`
|
||||
let agg = zoneMap.get(key)
|
||||
if (!agg) {
|
||||
agg = {
|
||||
zone, site, total: 0, near_miss: 0, hazard: 0, injury: 0,
|
||||
avg_severity: null, first_half: 0, second_half: 0, severitySum: 0, severityCount: 0,
|
||||
}
|
||||
zoneMap.set(key, agg)
|
||||
}
|
||||
agg.total++
|
||||
if (r.incident_type === 'near_miss') agg.near_miss++
|
||||
if (r.incident_type === 'hazard') agg.hazard++
|
||||
if (r.incident_type === 'injury') agg.injury++
|
||||
if (typeof r.severity === 'number') {
|
||||
agg.severitySum += r.severity
|
||||
agg.severityCount++
|
||||
}
|
||||
if (new Date(r.reported_at as string) < midpoint) agg.first_half++
|
||||
else agg.second_half++
|
||||
}
|
||||
|
||||
const aggregates: ZoneAggregate[] = [...zoneMap.values()].map(a => ({
|
||||
zone: a.zone,
|
||||
site: a.site,
|
||||
total: a.total,
|
||||
near_miss: a.near_miss,
|
||||
hazard: a.hazard,
|
||||
injury: a.injury,
|
||||
avg_severity: a.severityCount > 0 ? Math.round((a.severitySum / a.severityCount) * 10) / 10 : null,
|
||||
first_half: a.first_half,
|
||||
second_half: a.second_half,
|
||||
}))
|
||||
|
||||
const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY')
|
||||
const anthropic = createAnthropicClient(anthropicKey)
|
||||
|
||||
let message: Awaited<ReturnType<typeof anthropic.messages.create>>
|
||||
try {
|
||||
message = await anthropic.messages.create({
|
||||
model: 'claude-opus-4-8',
|
||||
thinking: { type: 'adaptive' },
|
||||
max_tokens: 2048,
|
||||
tools: [{
|
||||
name: 'flag_rising_risk',
|
||||
description: 'Flag warehouse zones showing rising safety risk from 90-day incident aggregates',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
flags: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
zone: { type: 'string' },
|
||||
site: { type: 'string' },
|
||||
risk_level: { type: 'string', enum: ['low', 'medium', 'high'] },
|
||||
rationale: { type: 'string', description: 'One or two sentences citing the numbers' },
|
||||
recommended_action: { type: 'string', description: 'One concrete preventive action' },
|
||||
},
|
||||
required: ['zone', 'site', 'risk_level', 'rationale', 'recommended_action'],
|
||||
},
|
||||
},
|
||||
summary: { type: 'string', description: 'Two-sentence overall risk picture' },
|
||||
},
|
||||
required: ['flags', 'summary'],
|
||||
},
|
||||
}],
|
||||
tool_choice: { type: 'tool', name: 'flag_rising_risk' },
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `You are an HSE risk analyst for a Malaysian 3PL warehouse operator. Below are 90-day incident aggregates per zone. "first_half" is incidents in days 90-46, "second_half" is days 45-0 — a rising second_half means worsening trend. Near-miss and hazard reports are leading indicators; injuries are lagging.
|
||||
|
||||
Flag zones with rising or elevated risk (at most 5 flags; do not flag healthy zones). Base every rationale strictly on the numbers given.
|
||||
|
||||
${JSON.stringify(aggregates, null, 2)}`,
|
||||
}],
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 })
|
||||
}
|
||||
|
||||
const toolBlock = message.content.find(b => b.type === 'tool_use')
|
||||
if (!toolBlock || toolBlock.type !== 'tool_use')
|
||||
return NextResponse.json({ error: 'AI suggestion failed' }, { status: 500 })
|
||||
|
||||
const input = toolBlock.input as { flags?: unknown; summary?: unknown }
|
||||
if (!Array.isArray(input.flags) || typeof input.summary !== 'string')
|
||||
return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 })
|
||||
|
||||
const flags = input.flags.filter(
|
||||
(f: unknown): f is { zone: string; site: string; risk_level: string; rationale: string; recommended_action: string } =>
|
||||
typeof f === 'object' && f !== null &&
|
||||
typeof (f as Record<string, unknown>).zone === 'string' &&
|
||||
typeof (f as Record<string, unknown>).site === 'string' &&
|
||||
['low', 'medium', 'high'].includes((f as Record<string, unknown>).risk_level as string) &&
|
||||
typeof (f as Record<string, unknown>).rationale === 'string' &&
|
||||
typeof (f as Record<string, unknown>).recommended_action === 'string',
|
||||
)
|
||||
|
||||
await supabase.rpc('write_audit_log', {
|
||||
p_table_name: 'incidents',
|
||||
p_record_id: user.id,
|
||||
p_action: 'ai_risk_flags',
|
||||
p_new_value: { flags, summary: input.summary, model: 'claude-opus-4-8' } as never,
|
||||
})
|
||||
|
||||
return NextResponse.json({ flags, summary: input.summary })
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('incident_addenda')
|
||||
.select('id, body, created_at, author:users!author (name)')
|
||||
.eq('incident_id', id)
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
if (error) return NextResponse.json({ error: 'Fetch failed' }, { status: 500 })
|
||||
return NextResponse.json(data ?? [])
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from('users').select('role').eq('id', user.id).single()
|
||||
if (!profile || !['hse', 'admin', 'supervisor'].includes(profile.role))
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const body: { body?: string } = await request.json().catch(() => ({}))
|
||||
const text = (body.body ?? '').trim()
|
||||
if (!text) return NextResponse.json({ error: 'body required' }, { status: 422 })
|
||||
|
||||
const { data: addendum, error } = await supabase
|
||||
.from('incident_addenda')
|
||||
.insert({ incident_id: id, author: user.id, body: text })
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error || !addendum) return NextResponse.json({ error: 'Insert failed' }, { status: 500 })
|
||||
|
||||
await supabase.rpc('write_audit_log', {
|
||||
p_table_name: 'incident_addenda',
|
||||
p_record_id: addendum.id,
|
||||
p_action: 'INSERT',
|
||||
p_new_value: { incident_id: id, author: user.id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ id: addendum.id }, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
||||
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from('users').select('role').eq('id', user.id).single()
|
||||
if (!profile || !['hse', 'admin'].includes(profile.role))
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { data: incident } = await supabase
|
||||
.from('incidents')
|
||||
.select('status, reference_no, reported_by')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
if (incident.status === 'closed')
|
||||
return NextResponse.json({ error: 'Already closed' }, { status: 409 })
|
||||
if (incident.status !== 'verification')
|
||||
return NextResponse.json({ error: 'Incident must be in verification status' }, { status: 409 })
|
||||
|
||||
const { data: openCapas } = await supabase
|
||||
.from('capa_actions')
|
||||
.select('id')
|
||||
.eq('incident_id', id)
|
||||
.not('status', 'in', '(verified,closed)')
|
||||
if (openCapas && openCapas.length > 0)
|
||||
return NextResponse.json({ error: 'All CAPA actions must be verified before closure' }, { status: 409 })
|
||||
|
||||
const closedAt = new Date().toISOString()
|
||||
const { error } = await supabase
|
||||
.from('incidents')
|
||||
.update({ status: 'closed', closed_at: closedAt })
|
||||
.eq('id', id)
|
||||
if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 })
|
||||
|
||||
await supabase.rpc('write_audit_log', {
|
||||
p_table_name: 'incidents',
|
||||
p_record_id: id,
|
||||
p_action: 'closed',
|
||||
p_new_value: { status: 'closed', closed_at: closedAt, closed_by: user.id },
|
||||
})
|
||||
|
||||
if (incident.reported_by) {
|
||||
await createInAppNotifications(supabase, [{
|
||||
userId: incident.reported_by,
|
||||
title: `Your incident report ${incident.reference_no ?? ''} has been closed`,
|
||||
link: '/reporter',
|
||||
incidentId: id,
|
||||
}])
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, closed_at: closedAt })
|
||||
}
|
||||
@@ -37,6 +37,8 @@ export async function POST(
|
||||
root_cause_summary: body.root_cause_summary ?? null,
|
||||
five_why_steps: method === 'five_why' ? (body.five_why_steps ?? []) : null,
|
||||
fishbone_categories: method === 'fishbone' ? (body.fishbone_categories ?? []) : null,
|
||||
alcohol_test_result: body.alcohol_test_result ?? null,
|
||||
witness_statement_refs: Array.isArray(body.witness_statement_refs) ? body.witness_statement_refs : [],
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
@@ -83,6 +85,8 @@ export async function PATCH(
|
||||
root_cause_summary: fields.root_cause_summary ?? null,
|
||||
five_why_steps: fields.five_why_steps ?? null,
|
||||
fishbone_categories: fields.fishbone_categories ?? null,
|
||||
alcohol_test_result: fields.alcohol_test_result ?? null,
|
||||
witness_statement_refs: Array.isArray(fields.witness_statement_refs) ? fields.witness_statement_refs : [],
|
||||
}
|
||||
if (complete) updateData.completed_at = new Date().toISOString()
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { validateIncidentInput, type IncidentType, type MedicalStatus } from '@/lib/incidents/validate'
|
||||
import { validateIncidentInput, validateTypeDetails, type IncidentType, type MedicalStatus } from '@/lib/incidents/validate'
|
||||
import { uploadEvidenceFile, type EvidenceStage } from '@/lib/supabase/storage'
|
||||
import { sendNewIncidentEmail } from '@/lib/notifications/email'
|
||||
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
||||
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
||||
import { getApiKey } from '@/lib/settings'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -42,6 +43,21 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Validation failed', details: validation.errors }, { status: 422 })
|
||||
}
|
||||
|
||||
let rawDetails: Record<string, unknown> | undefined
|
||||
if (typeof body.type_details === 'string' && body.type_details) {
|
||||
try {
|
||||
rawDetails = JSON.parse(body.type_details)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Validation failed', details: ['type_details must be valid JSON'] }, { status: 422 })
|
||||
}
|
||||
} else if (body.type_details && typeof body.type_details === 'object') {
|
||||
rawDetails = body.type_details as Record<string, unknown>
|
||||
}
|
||||
const detailsCheck = validateTypeDetails(input.incident_type, rawDetails)
|
||||
if (!detailsCheck.ok) {
|
||||
return NextResponse.json({ error: 'Validation failed', details: detailsCheck.errors }, { status: 422 })
|
||||
}
|
||||
|
||||
const { data: zone, error: zoneError } = await supabase
|
||||
.from('zones')
|
||||
.select('id, site_id')
|
||||
@@ -63,6 +79,7 @@ export async function POST(request: Request) {
|
||||
injury_involved: input.injury_involved,
|
||||
asset_involved: input.asset_involved,
|
||||
medical_status: input.injury_involved ? (input.medical_status ?? 'none') : null,
|
||||
type_details: detailsCheck.sanitized,
|
||||
})
|
||||
.select('id, reference_no')
|
||||
.single()
|
||||
@@ -121,9 +138,20 @@ export async function POST(request: Request) {
|
||||
const siteName = (siteData as { name: string } | null)?.name ?? 'Unknown'
|
||||
const { data: recipients } = await supabase
|
||||
.from('users')
|
||||
.select('phone')
|
||||
.select('id, phone')
|
||||
.in('role', ['supervisor', 'hse'])
|
||||
.eq('site_id', zone.site_id)
|
||||
|
||||
await createInAppNotifications(
|
||||
supabase,
|
||||
(recipients ?? []).map((r: { id: string }) => ({
|
||||
userId: r.id,
|
||||
title: `New ${input.incident_type.replace(/_/g, ' ')} incident ${incident.reference_no ?? ''} at ${siteName}`,
|
||||
link: `/hse/incidents/${incident.id}`,
|
||||
incidentId: incident.id,
|
||||
})),
|
||||
)
|
||||
|
||||
for (const r of recipients ?? []) {
|
||||
const phone = (r as { phone: string | null }).phone ?? ''
|
||||
if (!phone) continue
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { data: notifications, error } = await supabase
|
||||
.from('notifications_log')
|
||||
.select('id, title, link, incident_id, capa_id, sent_at, read_at')
|
||||
.eq('channel', 'in_app')
|
||||
.eq('recipient_user_id', user.id)
|
||||
.order('sent_at', { ascending: false })
|
||||
.limit(20)
|
||||
|
||||
if (error) return NextResponse.json({ error: 'Fetch failed' }, { status: 500 })
|
||||
|
||||
const { count } = await supabase
|
||||
.from('notifications_log')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('channel', 'in_app')
|
||||
.eq('recipient_user_id', user.id)
|
||||
.is('read_at', null)
|
||||
|
||||
return NextResponse.json({ notifications: notifications ?? [], unread: count ?? 0 })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const body: { ids?: string[]; all?: boolean } = await request.json().catch(() => ({}))
|
||||
|
||||
let query = supabase
|
||||
.from('notifications_log')
|
||||
.update({ read_at: new Date().toISOString() })
|
||||
.eq('recipient_user_id', user.id)
|
||||
.is('read_at', null)
|
||||
|
||||
if (!body.all) {
|
||||
if (!Array.isArray(body.ids) || body.ids.length === 0)
|
||||
return NextResponse.json({ error: 'ids required unless all:true' }, { status: 422 })
|
||||
query = query.in('id', body.ids)
|
||||
}
|
||||
|
||||
const { error } = await query
|
||||
if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 })
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { buildJkkp8Rows, jkkp8Csv, type Jkkp8Incident } from '@/lib/reports/jkkp8'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from('users').select('role').eq('id', user.id).single()
|
||||
if (!profile || !['hse', 'admin'].includes(profile.role))
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const yearParam = request.nextUrl.searchParams.get('year')
|
||||
const year = Number.parseInt(yearParam ?? '', 10) || new Date().getFullYear()
|
||||
|
||||
const { data: incidents, error } = await supabase
|
||||
.from('incidents')
|
||||
.select(`
|
||||
reference_no, incident_type, description, reported_at,
|
||||
medical_status, lost_days,
|
||||
is_fatality, is_serious_bodily_injury, is_dangerous_occurrence, is_occupational_disease,
|
||||
sites (name),
|
||||
zones (name),
|
||||
reporter:users!reported_by (name),
|
||||
dosh_reports (form_type, status, submitted_at)
|
||||
`)
|
||||
.gte('reported_at', `${year}-01-01T00:00:00Z`)
|
||||
.lt('reported_at', `${year + 1}-01-01T00:00:00Z`)
|
||||
.order('reported_at', { ascending: true })
|
||||
|
||||
if (error) return NextResponse.json({ error: 'Fetch failed' }, { status: 500 })
|
||||
|
||||
const rows = buildJkkp8Rows((incidents ?? []) as unknown as Jkkp8Incident[])
|
||||
const csv = jkkp8Csv(rows)
|
||||
|
||||
await supabase.rpc('write_audit_log', {
|
||||
p_table_name: 'incidents',
|
||||
p_record_id: user.id,
|
||||
p_action: 'jkkp8_register_export',
|
||||
p_new_value: { year, row_count: rows.length },
|
||||
})
|
||||
|
||||
return new NextResponse(csv, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="jkkp8-register-${year}.csv"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user