diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx new file mode 100644 index 0000000..3852ecb --- /dev/null +++ b/app/(app)/layout.tsx @@ -0,0 +1,21 @@ +import Link from "next/link"; + +export default function AppLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+
+
+ + ScoutBase + + Premises compliance +
+
+
{children}
+
+ ); +} diff --git a/app/(app)/premises/[id]/actions/page.tsx b/app/(app)/premises/[id]/actions/page.tsx new file mode 100644 index 0000000..b068e0e --- /dev/null +++ b/app/(app)/premises/[id]/actions/page.tsx @@ -0,0 +1,46 @@ +import { Suspense } from "react"; +import { notFound, redirect } from "next/navigation"; +import { ActionTracker } from "@/components/actions/ActionTracker"; +import { PremisesPageHeader } from "@/components/premises/PremisesPageHeader"; +import { requireAuthContext } from "@/lib/auth"; +import { getPremisesForOrganisation } from "@/lib/premises"; + +type PageProps = { + params: { id: string }; +}; + +export default async function PremisesActionsPage({ params }: PageProps) { + let auth; + try { + auth = await requireAuthContext(); + } catch { + redirect("/"); + } + + const premises = await getPremisesForOrganisation( + params.id, + auth.organisationId, + ); + + if (!premises) { + notFound(); + } + + return ( +
+ + +
+ Loading actions…

}> + +
+
+
+ ); +} diff --git a/app/(app)/premises/[id]/audit/[auditId]/page.tsx b/app/(app)/premises/[id]/audit/[auditId]/page.tsx new file mode 100644 index 0000000..8a4f14c --- /dev/null +++ b/app/(app)/premises/[id]/audit/[auditId]/page.tsx @@ -0,0 +1,72 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { AuditWizard } from "@/components/audit/AuditWizard"; +import { requireAuthContext } from "@/lib/auth"; +import { buildAuditOverview, getAuditForOrganisation } from "@/lib/audit"; + +type PageProps = { + params: { id: string; auditId: string }; + searchParams: { section?: string }; +}; + +export default async function AuditWizardPage({ + params, + searchParams, +}: PageProps) { + let auth; + try { + auth = await requireAuthContext(); + } catch { + redirect("/"); + } + + const audit = await getAuditForOrganisation(params.auditId, auth.organisationId); + + if (!audit || audit.premises.id !== params.id) { + notFound(); + } + + const overview = buildAuditOverview(audit); + const initialSectionId = + searchParams.section && + overview.sections.some((section) => section.id === searchParams.section) + ? searchParams.section + : overview.sections[0]?.id; + + if (!initialSectionId) { + return ( +
+

+ This audit needs to be restarted +

+

+ No audit sections apply to the current premises profile. Update your + profile or start a new audit. +

+
+ + Review premises profile + + + Back to audit + +
+
+ ); + } + + return ( + + ); +} diff --git a/app/(app)/premises/[id]/audit/page.tsx b/app/(app)/premises/[id]/audit/page.tsx new file mode 100644 index 0000000..a8b594b --- /dev/null +++ b/app/(app)/premises/[id]/audit/page.tsx @@ -0,0 +1,54 @@ +import { notFound, redirect } from "next/navigation"; +import { AuditStatus } from "@prisma/client"; +import { AuditStartPanel } from "@/components/audit/AuditStartPanel"; +import { PremisesPageHeader } from "@/components/premises/PremisesPageHeader"; +import { requireAuthContext } from "@/lib/auth"; +import { getPremisesForOrganisation } from "@/lib/premises"; +import { prisma } from "@/lib/prisma"; + +type PageProps = { + params: { id: string }; +}; + +export default async function PremisesAuditPage({ params }: PageProps) { + let auth; + try { + auth = await requireAuthContext(); + } catch { + redirect("/"); + } + + const premises = await getPremisesForOrganisation(params.id, auth.organisationId); + + if (!premises) { + notFound(); + } + + const draft = await prisma.audit.findFirst({ + where: { + premisesId: premises.id, + status: AuditStatus.DRAFT, + }, + select: { id: true }, + }); + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/app/(app)/premises/[id]/profile/page.tsx b/app/(app)/premises/[id]/profile/page.tsx new file mode 100644 index 0000000..6b9ec91 --- /dev/null +++ b/app/(app)/premises/[id]/profile/page.tsx @@ -0,0 +1,87 @@ +import { notFound, redirect } from "next/navigation"; +import Link from "next/link"; +import { requireAuthContext } from "@/lib/auth"; +import { getPremisesForOrganisation } from "@/lib/premises"; +import { EMPTY_PROFILE_FORM } from "@/lib/profile-labels"; +import { ProfileWizard } from "@/components/profile/ProfileWizard"; +import { PremisesPageHeader } from "@/components/premises/PremisesPageHeader"; +import type { ProfileFormData } from "@/lib/profile-labels"; +import type { BuildingAgeBand, FloodRiskZone, OwnershipType } from "@prisma/client"; + +type PageProps = { + params: { id: string }; +}; + +function toFormData(profile: { + ownershipType: OwnershipType; + buildingAgeBand: BuildingAgeBand; + hasGas: boolean; + floodRiskZone: FloodRiskZone | null; + hasCateringKitchen: boolean; + hasGrounds: boolean; + hasSleeping: boolean; + hasVehicles: boolean; + hasPlantMachinery: boolean; + hasThirdPartyUsers: boolean; +}): ProfileFormData { + return { + ownershipType: profile.ownershipType, + buildingAgeBand: profile.buildingAgeBand, + hasGas: profile.hasGas, + floodRiskZone: profile.floodRiskZone, + hasCateringKitchen: profile.hasCateringKitchen, + hasGrounds: profile.hasGrounds, + hasSleeping: profile.hasSleeping, + hasVehicles: profile.hasVehicles, + hasPlantMachinery: profile.hasPlantMachinery, + hasThirdPartyUsers: profile.hasThirdPartyUsers, + }; +} + +export default async function PremisesProfilePage({ params }: PageProps) { + let auth; + try { + auth = await requireAuthContext(); + } catch { + redirect("/"); + } + + const premises = await getPremisesForOrganisation(params.id, auth.organisationId); + + if (!premises) { + notFound(); + } + + const initialProfile = premises.profile + ? toFormData(premises.profile) + : EMPTY_PROFILE_FORM; + + const initialApplicableSections = premises.profile?.applicableSections ?? []; + + return ( +
+ + {premises.profile ? ( + + Go to annual audit → + + ) : null} +
+ +
+
+ ); +} diff --git a/app/api/actions/[actionId]/route.ts b/app/api/actions/[actionId]/route.ts new file mode 100644 index 0000000..af8ba75 --- /dev/null +++ b/app/api/actions/[actionId]/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from "next/server"; +import { ActionStatus, Priority } from "@prisma/client"; +import { requireAuthContext } from "@/lib/auth"; +import { + deleteAction, + serializeActionListItem, + updateAction, +} from "@/lib/actions"; + +type RouteParams = { params: { actionId: string } }; + +const VALID_PRIORITIES = new Set(Object.values(Priority)); +const VALID_STATUSES = new Set(Object.values(ActionStatus)); + +export async function GET(_request: Request, { params }: RouteParams) { + try { + const auth = await requireAuthContext(); + const { getActionForOrganisation } = await import("@/lib/actions"); + const action = await getActionForOrganisation( + params.actionId, + auth.organisationId, + ); + + if (!action) { + return NextResponse.json( + { data: null, error: "Action not found" }, + { status: 404 }, + ); + } + + return NextResponse.json({ + data: serializeActionListItem(action), + error: null, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load action"; + const status = message === "Unauthorized" ? 401 : 500; + + return NextResponse.json({ data: null, error: message }, { status }); + } +} + +export async function PATCH(request: Request, { params }: RouteParams) { + try { + const auth = await requireAuthContext(); + const body = await request.json(); + + const title = body?.title as string | undefined; + const description = body?.description as string | undefined; + const priority = body?.priority as string | undefined; + const status = body?.status as string | undefined; + const dueDate = body?.dueDate as string | null | undefined; + + if (!title || !priority) { + return NextResponse.json( + { data: null, error: "title and priority are required" }, + { status: 400 }, + ); + } + + if (!VALID_PRIORITIES.has(priority)) { + return NextResponse.json( + { data: null, error: "Invalid priority value" }, + { status: 400 }, + ); + } + + if (status && !VALID_STATUSES.has(status)) { + return NextResponse.json( + { data: null, error: "Invalid status value" }, + { status: 400 }, + ); + } + + const action = await updateAction(params.actionId, auth.organisationId, { + title, + description, + priority: priority as Priority, + dueDate, + status: status as ActionStatus | undefined, + }); + + return NextResponse.json({ + data: serializeActionListItem(action), + error: null, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to update action"; + const status = + message === "Unauthorized" + ? 401 + : message === "Action not found" + ? 404 + : 500; + + return NextResponse.json({ data: null, error: message }, { status }); + } +} + +export async function DELETE(_request: Request, { params }: RouteParams) { + try { + const auth = await requireAuthContext(); + await deleteAction(params.actionId, auth.organisationId); + + return NextResponse.json({ data: { deleted: true }, error: null }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to delete action"; + const status = + message === "Unauthorized" + ? 401 + : message === "Action not found" + ? 404 + : 500; + + return NextResponse.json({ data: null, error: message }, { status }); + } +} diff --git a/app/api/audit/[auditId]/actions/route.ts b/app/api/audit/[auditId]/actions/route.ts new file mode 100644 index 0000000..e50805c --- /dev/null +++ b/app/api/audit/[auditId]/actions/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { Priority } from "@prisma/client"; +import { requireAuthContext } from "@/lib/auth"; +import { createAuditAction, serializeLinkedAction } from "@/lib/audit"; + +type RouteParams = { params: { auditId: string } }; + +const VALID_PRIORITIES = new Set(Object.values(Priority)); + +export async function POST(request: Request, { params }: RouteParams) { + try { + const auth = await requireAuthContext(); + const body = await request.json(); + + const title = body?.title as string | undefined; + const description = body?.description as string | undefined; + const priority = body?.priority as string | undefined; + const dueDate = body?.dueDate as string | null | undefined; + const itemId = body?.itemId as string | undefined; + + if (!title || !priority || !itemId) { + return NextResponse.json( + { + data: null, + error: "title, priority, and itemId are required", + }, + { status: 400 }, + ); + } + + if (!VALID_PRIORITIES.has(priority)) { + return NextResponse.json( + { data: null, error: "Invalid priority value" }, + { status: 400 }, + ); + } + + const action = await createAuditAction( + params.auditId, + auth.organisationId, + auth.userId, + { + title, + description, + priority: priority as Priority, + dueDate, + itemId, + }, + ); + + return NextResponse.json({ + data: serializeLinkedAction(action), + error: null, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to create action"; + const status = + message === "Unauthorized" + ? 401 + : message === "Audit not found" + ? 404 + : 500; + + return NextResponse.json({ data: null, error: message }, { status }); + } +} diff --git a/app/api/audit/[auditId]/responses/route.ts b/app/api/audit/[auditId]/responses/route.ts new file mode 100644 index 0000000..107c518 --- /dev/null +++ b/app/api/audit/[auditId]/responses/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server"; +import { ResponseValue } from "@prisma/client"; +import { requireAuthContext } from "@/lib/auth"; +import { saveAuditResponse } from "@/lib/audit"; + +type RouteParams = { params: { auditId: string } }; + +const VALID_RESPONSES = new Set([ + ResponseValue.YES, + ResponseValue.NO, + ResponseValue.NA, +]); + +export async function PUT(request: Request, { params }: RouteParams) { + try { + const auth = await requireAuthContext(); + const body = await request.json(); + + const itemId = body?.itemId as string | undefined; + const sectionId = body?.sectionId as string | undefined; + const response = body?.response as string | undefined; + const notes = body?.notes as string | null | undefined; + const recordedDate = body?.recordedDate as string | null | undefined; + const needsAction = Boolean(body?.needsAction); + + if (!itemId || !sectionId || !response) { + return NextResponse.json( + { data: null, error: "itemId, sectionId, and response are required" }, + { status: 400 }, + ); + } + + if (!VALID_RESPONSES.has(response)) { + return NextResponse.json( + { data: null, error: "Invalid response value" }, + { status: 400 }, + ); + } + + const result = await saveAuditResponse( + params.auditId, + auth.organisationId, + auth.userId, + itemId, + sectionId, + response as ResponseValue, + { notes, needsAction, recordedDate }, + ); + + return NextResponse.json({ + data: { + response: result.response, + sectionStatus: result.sectionStatus, + }, + error: null, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to save response"; + const status = + message === "Unauthorized" + ? 401 + : message === "Audit not found" || message === "Question not found in this section" + ? 404 + : message === "Cannot edit a completed audit" || + message.includes("required") || + message.includes("Needs action") + ? 400 + : 500; + + return NextResponse.json({ data: null, error: message }, { status }); + } +} diff --git a/app/api/audit/[auditId]/route.ts b/app/api/audit/[auditId]/route.ts new file mode 100644 index 0000000..322a1ce --- /dev/null +++ b/app/api/audit/[auditId]/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { requireAuthContext } from "@/lib/auth"; +import { buildAuditOverview, getAuditForOrganisation } from "@/lib/audit"; + +type RouteParams = { params: { auditId: string } }; + +export async function GET(_request: Request, { params }: RouteParams) { + try { + const auth = await requireAuthContext(); + const audit = await getAuditForOrganisation(params.auditId, auth.organisationId); + + if (!audit) { + return NextResponse.json( + { data: null, error: "Audit not found" }, + { status: 404 }, + ); + } + + return NextResponse.json({ + data: buildAuditOverview(audit), + error: null, + }); + } catch (error) { + return NextResponse.json( + { + data: null, + error: error instanceof Error ? error.message : "Failed to load audit", + }, + { status: error instanceof Error && error.message === "Unauthorized" ? 401 : 500 }, + ); + } +} diff --git a/app/api/audit/[auditId]/sections/[sectionId]/route.ts b/app/api/audit/[auditId]/sections/[sectionId]/route.ts new file mode 100644 index 0000000..13225c5 --- /dev/null +++ b/app/api/audit/[auditId]/sections/[sectionId]/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { requireAuthContext } from "@/lib/auth"; +import { + buildAuditSectionPayload, + getAuditForOrganisation, +} from "@/lib/audit"; + +type RouteParams = { + params: { auditId: string; sectionId: string }; +}; + +export async function GET(_request: Request, { params }: RouteParams) { + try { + const auth = await requireAuthContext(); + const audit = await getAuditForOrganisation(params.auditId, auth.organisationId); + + if (!audit) { + return NextResponse.json( + { data: null, error: "Audit not found" }, + { status: 404 }, + ); + } + + const payload = await buildAuditSectionPayload(audit, params.sectionId); + + return NextResponse.json({ data: payload, error: null }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to load audit section"; + const status = + message === "Unauthorized" + ? 401 + : message === "Section not found" + ? 404 + : 500; + + return NextResponse.json({ data: null, error: message }, { status }); + } +} diff --git a/app/api/premises/[id]/actions/route.ts b/app/api/premises/[id]/actions/route.ts new file mode 100644 index 0000000..7e98d88 --- /dev/null +++ b/app/api/premises/[id]/actions/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server"; +import { ActionStatus, Priority } from "@prisma/client"; +import { requireAuthContext } from "@/lib/auth"; +import { + listPremisesActions, + parseActionListFilters, + serializeActionListItem, +} from "@/lib/actions"; + +type RouteParams = { params: { id: string } }; + +const VALID_STATUSES = new Set(Object.values(ActionStatus)); +const VALID_PRIORITIES = new Set(Object.values(Priority)); +const VALID_DUE = new Set(["all", "overdue", "upcoming", "none"]); + +export async function GET(request: Request, { params }: RouteParams) { + try { + const auth = await requireAuthContext(); + const url = new URL(request.url); + const filters = parseActionListFilters( + Object.fromEntries(url.searchParams.entries()), + ); + + if (filters.status !== "ALL" && !VALID_STATUSES.has(filters.status)) { + return NextResponse.json( + { data: null, error: "Invalid status filter" }, + { status: 400 }, + ); + } + + if (filters.priority !== "ALL" && !VALID_PRIORITIES.has(filters.priority)) { + return NextResponse.json( + { data: null, error: "Invalid priority filter" }, + { status: 400 }, + ); + } + + if (!VALID_DUE.has(filters.due)) { + return NextResponse.json( + { data: null, error: "Invalid due date filter" }, + { status: 400 }, + ); + } + + const actions = await listPremisesActions( + params.id, + auth.organisationId, + filters, + ); + + const openCount = actions.filter((action) => action.status === "OPEN").length; + const inProgressCount = actions.filter( + (action) => action.status === "IN_PROGRESS", + ).length; + const overdueCount = actions.filter((action) => action.isOverdue).length; + + return NextResponse.json({ + data: { + actions, + summary: { + total: actions.length, + open: openCount, + inProgress: inProgressCount, + overdue: overdueCount, + }, + }, + error: null, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to load actions"; + const status = + message === "Unauthorized" + ? 401 + : message === "Premises not found" + ? 404 + : 500; + + return NextResponse.json({ data: null, error: message }, { status }); + } +} diff --git a/app/api/premises/[id]/audit/route.ts b/app/api/premises/[id]/audit/route.ts new file mode 100644 index 0000000..608ffff --- /dev/null +++ b/app/api/premises/[id]/audit/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server"; +import { AuditStatus } from "@prisma/client"; +import { requireAuthContext } from "@/lib/auth"; +import { buildAuditOverview, startOrResumeAudit } from "@/lib/audit"; +import { getPremisesForOrganisation } from "@/lib/premises"; +import { prisma } from "@/lib/prisma"; + +type RouteParams = { params: { id: string } }; + +export async function GET(_request: Request, { params }: RouteParams) { + try { + const auth = await requireAuthContext(); + const premises = await getPremisesForOrganisation( + params.id, + auth.organisationId, + ); + + if (!premises) { + return NextResponse.json( + { data: null, error: "Premises not found" }, + { status: 404 }, + ); + } + + const draft = await prisma.audit.findFirst({ + where: { + premisesId: params.id, + status: AuditStatus.DRAFT, + }, + select: { id: true, startedAt: true }, + }); + + return NextResponse.json({ + data: { + hasProfile: Boolean(premises.profile), + draftAudit: draft, + }, + error: null, + }); + } catch (error) { + return NextResponse.json( + { + data: null, + error: error instanceof Error ? error.message : "Failed to load audit", + }, + { status: error instanceof Error && error.message === "Unauthorized" ? 401 : 500 }, + ); + } +} + +export async function POST(_request: Request, { params }: RouteParams) { + try { + const auth = await requireAuthContext(); + const { audit, created } = await startOrResumeAudit( + params.id, + auth.organisationId, + auth.userId, + ); + + return NextResponse.json({ + data: { + auditId: audit.id, + created, + overview: buildAuditOverview(audit), + }, + error: null, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to start audit"; + const status = + message === "Unauthorized" + ? 401 + : message === "Premises not found" + ? 404 + : message.includes("profile") || message.includes("sections") + ? 400 + : 500; + + return NextResponse.json({ data: null, error: message }, { status }); + } +} diff --git a/app/api/premises/[id]/profile/route.ts b/app/api/premises/[id]/profile/route.ts new file mode 100644 index 0000000..b3ab61d --- /dev/null +++ b/app/api/premises/[id]/profile/route.ts @@ -0,0 +1,203 @@ +import { NextResponse } from "next/server"; +import type { BuildingAgeBand, FloodRiskZone, OwnershipType } from "@prisma/client"; +import { requireAuthContext } from "@/lib/auth"; +import { getPremisesForOrganisation } from "@/lib/premises"; +import { prisma } from "@/lib/prisma"; +import { computeApplicableSections } from "@/lib/sections"; +import { EMPTY_PROFILE_FORM, type ProfileFormData } from "@/lib/profile-labels"; + +type RouteParams = { params: { id: string } }; + +function toFormData(profile: { + ownershipType: OwnershipType; + buildingAgeBand: BuildingAgeBand; + hasGas: boolean; + floodRiskZone: FloodRiskZone | null; + hasCateringKitchen: boolean; + hasGrounds: boolean; + hasSleeping: boolean; + hasVehicles: boolean; + hasPlantMachinery: boolean; + hasThirdPartyUsers: boolean; +}): ProfileFormData { + return { + ownershipType: profile.ownershipType, + buildingAgeBand: profile.buildingAgeBand, + hasGas: profile.hasGas, + floodRiskZone: profile.floodRiskZone, + hasCateringKitchen: profile.hasCateringKitchen, + hasGrounds: profile.hasGrounds, + hasSleeping: profile.hasSleeping, + hasVehicles: profile.hasVehicles, + hasPlantMachinery: profile.hasPlantMachinery, + hasThirdPartyUsers: profile.hasThirdPartyUsers, + }; +} + +function parseProfileBody(body: unknown): ProfileFormData | null { + if (!body || typeof body !== "object") { + return null; + } + + const data = body as Record; + + return { + ownershipType: (data.ownershipType as OwnershipType) ?? "", + buildingAgeBand: (data.buildingAgeBand as BuildingAgeBand) ?? "", + hasGas: Boolean(data.hasGas), + floodRiskZone: + data.floodRiskZone === null || data.floodRiskZone === "" + ? null + : (data.floodRiskZone as FloodRiskZone), + hasCateringKitchen: Boolean(data.hasCateringKitchen), + hasGrounds: Boolean(data.hasGrounds), + hasSleeping: Boolean(data.hasSleeping), + hasVehicles: Boolean(data.hasVehicles), + hasPlantMachinery: Boolean(data.hasPlantMachinery), + hasThirdPartyUsers: Boolean(data.hasThirdPartyUsers), + }; +} + +function validateProfileForm(form: ProfileFormData): string | null { + if (!form.ownershipType) { + return "Ownership type is required"; + } + if (!form.buildingAgeBand) { + return "Building age is required"; + } + return null; +} + +export async function GET(_request: Request, { params }: RouteParams) { + try { + const auth = await requireAuthContext(); + const premises = await getPremisesForOrganisation(params.id, auth.organisationId); + + if (!premises) { + return NextResponse.json( + { data: null, error: "Premises not found" }, + { status: 404 }, + ); + } + + const form = premises.profile + ? toFormData(premises.profile) + : EMPTY_PROFILE_FORM; + + const applicableSections = premises.profile + ? premises.profile.applicableSections + : []; + + return NextResponse.json({ + data: { + premises: { + id: premises.id, + name: premises.name, + address: premises.address, + }, + profile: form, + applicableSections, + }, + error: null, + }); + } catch (error) { + return NextResponse.json( + { + data: null, + error: error instanceof Error ? error.message : "Failed to load profile", + }, + { status: error instanceof Error && error.message === "Unauthorized" ? 401 : 500 }, + ); + } +} + +export async function PUT(request: Request, { params }: RouteParams) { + try { + const auth = await requireAuthContext(); + const premises = await getPremisesForOrganisation(params.id, auth.organisationId); + + if (!premises) { + return NextResponse.json( + { data: null, error: "Premises not found" }, + { status: 404 }, + ); + } + + const body = await request.json(); + const form = parseProfileBody(body); + + if (!form) { + return NextResponse.json( + { data: null, error: "Invalid request body" }, + { status: 400 }, + ); + } + + const validationError = validateProfileForm(form); + if (validationError) { + return NextResponse.json( + { data: null, error: validationError }, + { status: 400 }, + ); + } + + const applicableSections = computeApplicableSections({ + buildingAgeBand: form.buildingAgeBand as BuildingAgeBand, + hasGas: form.hasGas, + hasSleeping: form.hasSleeping, + hasCateringKitchen: form.hasCateringKitchen, + hasGrounds: form.hasGrounds, + hasVehicles: form.hasVehicles, + hasPlantMachinery: form.hasPlantMachinery, + hasThirdPartyUsers: form.hasThirdPartyUsers, + floodRiskZone: form.floodRiskZone as FloodRiskZone | null, + }); + + const profile = await prisma.premisesProfile.upsert({ + where: { premisesId: premises.id }, + create: { + premisesId: premises.id, + ownershipType: form.ownershipType as OwnershipType, + buildingAgeBand: form.buildingAgeBand as BuildingAgeBand, + hasGas: form.hasGas, + floodRiskZone: form.floodRiskZone as FloodRiskZone | null, + hasCateringKitchen: form.hasCateringKitchen, + hasGrounds: form.hasGrounds, + hasSleeping: form.hasSleeping, + hasVehicles: form.hasVehicles, + hasPlantMachinery: form.hasPlantMachinery, + hasThirdPartyUsers: form.hasThirdPartyUsers, + applicableSections, + }, + update: { + ownershipType: form.ownershipType as OwnershipType, + buildingAgeBand: form.buildingAgeBand as BuildingAgeBand, + hasGas: form.hasGas, + floodRiskZone: form.floodRiskZone as FloodRiskZone | null, + hasCateringKitchen: form.hasCateringKitchen, + hasGrounds: form.hasGrounds, + hasSleeping: form.hasSleeping, + hasVehicles: form.hasVehicles, + hasPlantMachinery: form.hasPlantMachinery, + hasThirdPartyUsers: form.hasThirdPartyUsers, + applicableSections, + }, + }); + + return NextResponse.json({ + data: { + profile: toFormData(profile), + applicableSections: profile.applicableSections, + }, + error: null, + }); + } catch (error) { + return NextResponse.json( + { + data: null, + error: error instanceof Error ? error.message : "Failed to save profile", + }, + { status: error instanceof Error && error.message === "Unauthorized" ? 401 : 500 }, + ); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 7c92b9e..5842b22 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,8 +2,8 @@ import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { - title: "Vigil", - description: "Compliance management for volunteer-run community buildings", + title: "ScoutBase", + description: "Premises compliance for volunteer-run community buildings", }; export default function RootLayout({ diff --git a/app/page.tsx b/app/page.tsx index 696ec88..444aaf1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,10 +1,49 @@ -export default function Home() { +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; + +export default async function Home() { + const premises = await prisma.premises.findFirst({ + orderBy: { createdAt: "asc" }, + select: { id: true, name: true, profile: { select: { id: true } } }, + }); + return (
-

Vigil

-

+

ScoutBase

+

Compliance management for volunteer-run community buildings

+ {premises ? ( +
+ + {premises.profile ? "Edit" : "Set up"} {premises.name} profile + + {premises.profile ? ( + <> + + Start annual audit + + + View actions + + + ) : null} +
+ ) : ( +

+ Run npm run db:seed{" "} + to create a demo premises. +

+ )}
); } diff --git a/components/actions/ActionTracker.tsx b/components/actions/ActionTracker.tsx new file mode 100644 index 0000000..b8d5f54 --- /dev/null +++ b/components/actions/ActionTracker.tsx @@ -0,0 +1,507 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import type { ActionStatus, Priority } from "@prisma/client"; +import { ActionForm } from "@/components/audit/ActionRaiseForm"; +import type { AuditLinkedAction } from "@/types/audit"; +import { + ACTION_PRIORITY_LABELS, + ACTION_SOURCE_LABELS, + ACTION_STATUS_LABELS, + DEFAULT_ACTION_FILTERS, + type ActionListFilters, + type ActionListItem, +} from "@/types/action"; + +type ActionTrackerProps = { + premisesId: string; +}; + +type ActionSummary = { + total: number; + open: number; + inProgress: number; + overdue: number; +}; + +function formatDisplayDate(value: string | null): string { + if (!value) return "No due date"; + return new Date(`${value}T00:00:00`).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +function filtersToQuery(filters: ActionListFilters): string { + const params = new URLSearchParams(); + if (filters.status !== "ALL") params.set("status", filters.status); + if (filters.priority !== "ALL") params.set("priority", filters.priority); + if (filters.due !== "all") params.set("due", filters.due); + if (filters.dueBefore) params.set("dueBefore", filters.dueBefore); + if (filters.dueAfter) params.set("dueAfter", filters.dueAfter); + return params.toString(); +} + +function parseFiltersFromSearch(searchParams: URLSearchParams): ActionListFilters { + return { + status: (searchParams.get("status") || "ALL") as ActionListFilters["status"], + priority: (searchParams.get("priority") || "ALL") as ActionListFilters["priority"], + due: (searchParams.get("due") || "all") as ActionListFilters["due"], + dueBefore: searchParams.get("dueBefore") || "", + dueAfter: searchParams.get("dueAfter") || "", + }; +} + +function toLinkedAction(action: ActionListItem): AuditLinkedAction { + return { + id: action.id, + title: action.title, + description: action.description, + priority: action.priority, + dueDate: action.dueDate, + status: action.status, + }; +} + +export function ActionTracker({ premisesId }: ActionTrackerProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [filters, setFilters] = useState(() => + parseFiltersFromSearch(searchParams), + ); + const [actions, setActions] = useState([]); + const [summary, setSummary] = useState({ + total: 0, + open: 0, + inProgress: 0, + overdue: 0, + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editingAction, setEditingAction] = useState(null); + const [savingAction, setSavingAction] = useState(false); + const [deletingActionId, setDeletingActionId] = useState(null); + + const loadActions = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const query = filtersToQuery(filters); + const response = await fetch( + `/api/premises/${premisesId}/actions${query ? `?${query}` : ""}`, + ); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error ?? "Failed to load actions"); + } + + setActions(result.data.actions); + setSummary(result.data.summary); + } catch (loadError) { + setError( + loadError instanceof Error ? loadError.message : "Failed to load actions", + ); + } finally { + setLoading(false); + } + }, [filters, premisesId]); + + useEffect(() => { + loadActions(); + }, [loadActions]); + + function applyFilters(next: ActionListFilters) { + setFilters(next); + const query = filtersToQuery(next); + router.replace( + `/premises/${premisesId}/actions${query ? `?${query}` : ""}`, + { scroll: false }, + ); + } + + function resetFilters() { + applyFilters(DEFAULT_ACTION_FILTERS); + } + + async function handleSaveAction(input: { + title: string; + description: string; + priority: Priority; + dueDate: string; + status?: ActionStatus; + }) { + if (!editingAction) return; + + setSavingAction(true); + setError(null); + + try { + const response = await fetch(`/api/actions/${editingAction.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: input.title, + description: input.description || null, + priority: input.priority, + dueDate: input.dueDate || null, + status: input.status, + }), + }); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error ?? "Failed to save action"); + } + + setEditingAction(null); + await loadActions(); + } finally { + setSavingAction(false); + } + } + + async function handleDeleteAction(action: ActionListItem) { + if (!window.confirm(`Delete action "${action.title}"?`)) { + return; + } + + setDeletingActionId(action.id); + setError(null); + + try { + const response = await fetch(`/api/actions/${action.id}`, { + method: "DELETE", + }); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error ?? "Failed to delete action"); + } + + await loadActions(); + } catch (deleteError) { + setError( + deleteError instanceof Error + ? deleteError.message + : "Failed to delete action", + ); + } finally { + setDeletingActionId(null); + } + } + + async function handleQuickStatusChange( + action: ActionListItem, + status: ActionStatus, + ) { + setError(null); + + try { + const response = await fetch(`/api/actions/${action.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: action.title, + description: action.description, + priority: action.priority, + dueDate: action.dueDate, + status, + }), + }); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error ?? "Failed to update status"); + } + + await loadActions(); + } catch (statusError) { + setError( + statusError instanceof Error + ? statusError.message + : "Failed to update status", + ); + } + } + + return ( +
+
+ + + + +
+ +
+
+

Filters

+ +
+ +
+ + + + + + + + + + + + + + + applyFilters({ ...filters, dueBefore: event.target.value }) + } + className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" + /> + + + + + applyFilters({ ...filters, dueAfter: event.target.value }) + } + className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" + /> + +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {loading ? ( +

Loading actions…

+ ) : actions.length === 0 ? ( +
+

+ No actions match your filters. +

+

+ Actions raised during an audit will appear here. +

+
+ ) : ( +
+ {actions.map((action) => ( +
+
+
+
+

+ {action.title} +

+ {action.isOverdue ? ( + + Overdue + + ) : null} +
+ {action.description ? ( +

+ {action.description} +

+ ) : null} +

+ {ACTION_PRIORITY_LABELS[action.priority]} priority ·{" "} + {action.sourceType + ? ACTION_SOURCE_LABELS[action.sourceType] + : "Unknown source"}{" "} + · Due {formatDisplayDate(action.dueDate)} +

+
+ +
+ + + +
+
+
+ ))} +
+ )} + + {editingAction ? ( + setEditingAction(null)} + /> + ) : null} +
+ ); +} + +function SummaryCard({ + label, + value, + tone = "default", +}: { + label: string; + value: number; + tone?: "default" | "warning"; +}) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} + +function FilterField({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function ActionTrackerEditModal({ + action, + saving, + onSubmit, + onCancel, +}: { + action: ActionListItem; + saving: boolean; + onSubmit: (input: { + title: string; + description: string; + priority: Priority; + dueDate: string; + status?: ActionStatus; + }) => Promise; + onCancel: () => void; +}) { + const stubItem = { + id: action.sourceItemId ?? action.id, + question: action.title, + guidance: "", + responseType: "YES_NO_NA_ACTION" as const, + requiresDocument: false, + response: null, + actions: [], + }; + + return ( + + ); +} diff --git a/components/audit/ActionRaiseForm.tsx b/components/audit/ActionRaiseForm.tsx new file mode 100644 index 0000000..dc31d62 --- /dev/null +++ b/components/audit/ActionRaiseForm.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useState } from "react"; +import type { ActionStatus, Priority } from "@prisma/client"; +import type { AuditItemWithResponse, AuditLinkedAction } from "@/types/audit"; +import { ACTION_STATUS_LABELS } from "@/types/action"; +import { Button } from "@/components/ui/form"; + +const PRIORITY_OPTIONS: { value: Priority; label: string }[] = [ + { value: "HIGH", label: "High" }, + { value: "MEDIUM", label: "Medium" }, + { value: "LOW", label: "Low" }, +]; + +type ActionFormSubmitInput = { + title: string; + description: string; + priority: Priority; + dueDate: string; + status?: ActionStatus; +}; + +type ActionFormProps = { + item: AuditItemWithResponse; + existingAction?: AuditLinkedAction | null; + saving: boolean; + showStatus?: boolean; + onSubmit: (input: ActionFormSubmitInput) => Promise; + onCancel: () => void; +}; + +export function ActionForm({ + item, + existingAction = null, + saving, + showStatus = false, + onSubmit, + onCancel, +}: ActionFormProps) { + const isEditing = Boolean(existingAction); + + const [title, setTitle] = useState(existingAction?.title ?? item.question); + const [description, setDescription] = useState( + existingAction?.description ?? "", + ); + const [priority, setPriority] = useState( + existingAction?.priority ?? "MEDIUM", + ); + const [status, setStatus] = useState( + existingAction?.status ?? "OPEN", + ); + const [dueDate, setDueDate] = useState(existingAction?.dueDate ?? ""); + const [error, setError] = useState(null); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + + if (!title.trim()) { + setError("Title is required"); + return; + } + + try { + await onSubmit({ + title: title.trim(), + description: description.trim(), + priority, + dueDate, + ...(showStatus ? { status } : {}), + }); + } catch (submitError) { + setError( + submitError instanceof Error + ? submitError.message + : isEditing + ? "Failed to update action" + : "Failed to raise action", + ); + } + } + + return ( +
+
+

+ {isEditing ? "Edit action" : "Raise action"} +

+

+ {isEditing + ? "Update the follow-up task linked to this audit question." + : "Create a follow-up task from this audit question."} +

+ +
+
+ + setTitle(event.target.value)} + className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" + /> +
+ +
+ +