From 1780586185baf5c57a95540ce978c138187b03ad Mon Sep 17 00:00:00 2001 From: weeihan Date: Sat, 11 Jul 2026 15:16:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20CAPA=20board=20=E2=80=94=20kanban/table?= =?UTF-8?q?=20view,=20create=20CAPA=20from=20incident,=20status=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr --- app/(protected)/hse/capa/page.tsx | 33 +++++ .../hse/incidents/[id]/capa/new/page.tsx | 47 ++++++ app/api/capa/[id]/route.ts | 65 +++++++++ app/api/capa/route.ts | 74 ++++++++++ components/capa/capa-board.tsx | 135 ++++++++++++++++++ components/capa/capa-form.tsx | 122 ++++++++++++++++ 6 files changed, 476 insertions(+) create mode 100644 app/(protected)/hse/capa/page.tsx create mode 100644 app/(protected)/hse/incidents/[id]/capa/new/page.tsx create mode 100644 app/api/capa/[id]/route.ts create mode 100644 app/api/capa/route.ts create mode 100644 components/capa/capa-board.tsx create mode 100644 components/capa/capa-form.tsx diff --git a/app/(protected)/hse/capa/page.tsx b/app/(protected)/hse/capa/page.tsx new file mode 100644 index 0000000..7caf64a --- /dev/null +++ b/app/(protected)/hse/capa/page.tsx @@ -0,0 +1,33 @@ +export const dynamic = 'force-dynamic' + +import { redirect } from 'next/navigation' +import Link from 'next/link' +import { createClient } from '@/lib/supabase/server' +import { CapaBoard } from '@/components/capa/capa-board' + +export default async function CapaListPage() { + const supabase = await createClient() + const { data, error: authError } = await supabase.auth.getUser() + if (authError || !data?.user) redirect('/login?redirect=/hse/capa') + + const { data: capas } = await supabase + .from('capa_actions') + .select(` + id, incident_id, description, department, due_date, priority, status, + incidents (reference_no, incident_type), + owner:users!owner_user_id (name, email) + `) + .order('due_date', { ascending: true }) + + return ( +
+
+

CAPA Board

+ + ← Incidents + +
+ [0]['capas']} /> +
+ ) +} diff --git a/app/(protected)/hse/incidents/[id]/capa/new/page.tsx b/app/(protected)/hse/incidents/[id]/capa/new/page.tsx new file mode 100644 index 0000000..a33ac83 --- /dev/null +++ b/app/(protected)/hse/incidents/[id]/capa/new/page.tsx @@ -0,0 +1,47 @@ +export const dynamic = 'force-dynamic' + +import { notFound, redirect } from 'next/navigation' +import Link from 'next/link' +import { createClient } from '@/lib/supabase/server' +import { CapaForm } from '@/components/capa/capa-form' + +interface Props { + params: Promise<{ id: string }> +} + +export default async function NewCapaPage({ params }: Props) { + const { id } = await params + const supabase = await createClient() + const { data, error: authError } = await supabase.auth.getUser() + if (authError || !data?.user) redirect(`/login?redirect=/hse/incidents/${id}/capa/new`) + + const { data: profile } = await supabase + .from('users').select('role').eq('id', data.user.id).single() + if (!profile || !['hse', 'admin'].includes(profile.role)) redirect('/hse/incidents') + + const { data: incident } = await supabase + .from('incidents').select('id, reference_no, status').eq('id', id).single() + if (!incident) notFound() + + const { data: users } = await supabase + .from('users') + .select('id, name, department') + .not('department', 'is', null) + .order('name') + + return ( +
+ + ← Back to incident + +

Add CAPA Action

+

+ {(incident as { reference_no: string | null }).reference_no ?? id} +

+ } + /> +
+ ) +} diff --git a/app/api/capa/[id]/route.ts b/app/api/capa/[id]/route.ts new file mode 100644 index 0000000..2abfc3f --- /dev/null +++ b/app/api/capa/[id]/route.ts @@ -0,0 +1,65 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +export async function GET( + _: 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('capa_actions') + .select(` + id, incident_id, root_cause_ref, description, owner_user_id, department, + due_date, priority, status, completed_at, verified_by, verified_at, created_at, + incidents (reference_no, incident_type), + owner:users!owner_user_id (name, email) + `) + .eq('id', id) + .single() + + if (error || !data) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json(data) +} + +export async function PATCH( + 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 body = await request.json() + const allowed = ['description', 'due_date', 'priority', 'status', 'department', 'root_cause_ref'] + const update: Record = {} + for (const key of allowed) { + if (key in body) update[key] = body[key] + } + + if (body.status === 'pending_verification') { + update.completed_at = new Date().toISOString() + } + + const { error } = await supabase + .from('capa_actions') + .update(update) + .eq('id', id) + + if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 }) + + await supabase.rpc('write_audit_log', { + p_table_name: 'capa_actions', + p_record_id: id, + p_action: 'updated', + p_new_value: update as Record, + }) + + return NextResponse.json({ ok: true }) +} diff --git a/app/api/capa/route.ts b/app/api/capa/route.ts new file mode 100644 index 0000000..b7afe30 --- /dev/null +++ b/app/api/capa/route.ts @@ -0,0 +1,74 @@ +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: profile } = await supabase + .from('users').select('role').eq('id', user.id).single() + if (!profile) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + let query = supabase + .from('capa_actions') + .select(` + id, incident_id, root_cause_ref, description, owner_user_id, department, + due_date, priority, status, completed_at, verified_by, verified_at, created_at, + incidents (reference_no, incident_type), + owner:users!owner_user_id (name, email) + `) + .order('due_date', { ascending: true }) + + if (profile.role === 'supervisor' || profile.role === 'worker') { + query = query.eq('owner_user_id', user.id) + } + + const { data, error } = await query + if (error) return NextResponse.json({ error: 'Fetch failed' }, { status: 500 }) + return NextResponse.json(data ?? []) +} + +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 { 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 body = await request.json() + const { incident_id, description, owner_user_id, department, due_date, priority, root_cause_ref } = body + + if (!incident_id || !description || !owner_user_id || !department || !due_date) + return NextResponse.json({ error: 'Missing required fields' }, { status: 422 }) + + const { data: capa, error } = await supabase + .from('capa_actions') + .insert({ + incident_id, + description, + owner_user_id, + department, + due_date, + priority: priority ?? 'med', + root_cause_ref: root_cause_ref ?? null, + }) + .select('id') + .single() + + if (error || !capa) return NextResponse.json({ error: 'Insert failed' }, { status: 500 }) + + await supabase.rpc('write_audit_log', { + p_table_name: 'capa_actions', + p_record_id: capa.id, + p_action: 'created', + p_new_value: { incident_id, description, owner_user_id, department, due_date }, + }) + + return NextResponse.json({ id: capa.id }, { status: 201 }) +} diff --git a/components/capa/capa-board.tsx b/components/capa/capa-board.tsx new file mode 100644 index 0000000..f0ec02d --- /dev/null +++ b/components/capa/capa-board.tsx @@ -0,0 +1,135 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' + +type CapaStatus = 'open' | 'in_progress' | 'overdue' | 'pending_verification' | 'verified' | 'reopened' | 'closed' + +type CapaAction = { + id: string + incident_id: string + description: string + department: string + due_date: string + priority: 'low' | 'med' | 'high' + status: CapaStatus + incidents: { reference_no: string | null } | null + owner: { name: string } | null +} + +const STATUS_ORDER: CapaStatus[] = ['open', 'in_progress', 'overdue', 'pending_verification', 'verified', 'closed'] +const STATUS_LABELS: Record = { + open: 'Open', + in_progress: 'In Progress', + overdue: 'Overdue', + pending_verification: 'Pending Verification', + verified: 'Verified', + reopened: 'Reopened', + closed: 'Closed', +} +const STATUS_COLORS: Record = { + open: 'bg-blue-50 border-blue-200', + in_progress: 'bg-yellow-50 border-yellow-200', + overdue: 'bg-red-50 border-red-200', + pending_verification: 'bg-purple-50 border-purple-200', + verified: 'bg-green-50 border-green-200', + reopened: 'bg-orange-50 border-orange-200', + closed: 'bg-gray-50 border-gray-200', +} +const PRIORITY_BADGE: Record<'low' | 'med' | 'high', string> = { + low: 'bg-gray-100 text-gray-600', + med: 'bg-yellow-100 text-yellow-700', + high: 'bg-red-100 text-red-700', +} + +interface Props { + capas: CapaAction[] +} + +export function CapaBoard({ capas }: Props) { + const [view, setView] = useState<'board' | 'table'>('board') + + const byStatus = STATUS_ORDER.reduce>((acc, s) => { + acc[s] = capas.filter(c => c.status === s) + return acc + }, {} as Record) + + if (view === 'table') { + return ( +
+
+ +
+
+ + + + + + + + + + + + + {capas.map(c => ( + + + + + + + + + ))} + +
RefActionOwnerDuePriorityStatus
+ {(c.incidents as { reference_no: string | null } | null)?.reference_no ?? '—'} + + + {c.description} + + {(c.owner as { name: string } | null)?.name ?? '—'}{c.due_date} + + {c.priority} + + {STATUS_LABELS[c.status]}
+
+
+ ) + } + + return ( +
+
+ +
+
+ {STATUS_ORDER.filter(s => s !== 'reopened').map(status => ( +
+

+ {STATUS_LABELS[status]} ({byStatus[status].length}) +

+
+ {byStatus[status].map(c => ( + +

{c.description}

+
+ {(c.owner as { name: string } | null)?.name ?? '—'} + {c.priority} +
+

Due {c.due_date}

+ + ))} + {byStatus[status].length === 0 && ( +

Empty

+ )} +
+
+ ))} +
+
+ ) +} diff --git a/components/capa/capa-form.tsx b/components/capa/capa-form.tsx new file mode 100644 index 0000000..66d9030 --- /dev/null +++ b/components/capa/capa-form.tsx @@ -0,0 +1,122 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' + +interface User { + id: string + name: string + department: string +} + +interface Props { + incidentId: string + users: User[] +} + +export function CapaForm({ incidentId, users }: Props) { + const router = useRouter() + const [description, setDescription] = useState('') + const [ownerId, setOwnerId] = useState('') + const [department, setDepartment] = useState('') + const [dueDate, setDueDate] = useState('') + const [priority, setPriority] = useState<'low' | 'med' | 'high'>('med') + const [rootCauseRef, setRootCauseRef] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + function handleOwnerChange(id: string) { + setOwnerId(id) + const u = users.find(u => u.id === id) + if (u) setDepartment(u.department) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!ownerId || !description || !dueDate) { + setError('Description, owner, and due date are required') + return + } + setSaving(true) + setError(null) + const res = await fetch('/api/capa', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + incident_id: incidentId, + description, + owner_user_id: ownerId, + department, + due_date: dueDate, + priority, + root_cause_ref: rootCauseRef || null, + }), + }) + if (!res.ok) { + const data = await res.json() + setError(data.error ?? 'Create failed') + setSaving(false) + return + } + router.push(`/hse/incidents/${incidentId}`) + router.refresh() + } + + return ( +
+
+ +