diff --git a/CONTEXT.md b/CONTEXT.md index 929c0c3..f43e25e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -103,26 +103,39 @@ model PremisesProfile { } model AuditTemplate { - id String @id @default(cuid()) - version String // e.g. "2025-09" - publishedAt DateTime - sections Json // Array of { id, number, title, items: [{id, question, guidance, responseType, tags}] } - isActive Boolean @default(false) - audits Audit[] + id String @id @default(cuid()) + version String // e.g. "2025-09" — annual/questionnaire release + revision Int @default(1) // patch within a release + changeType AuditTemplateChangeType @default(RELEASE) // RELEASE | PATCH + description String? + publishedAt DateTime + sections Json // Array of sections/items + isActive Boolean @default(false) // template used for new audits + supersededAt DateTime? + audits Audit[] + + @@unique([version, revision]) } +// Each premises may have multiple audits over time. There is no one-audit-per-year rule. +// Draft audits follow the latest revision of the active template. +// Completed audits store a sectionsSnapshot so template patches do not rewrite history. + model Audit { - id String @id @default(cuid()) - premisesId String - premises Premises @relation(fields: [premisesId], references: [id]) - templateId String - template AuditTemplate @relation(fields: [templateId], references: [id]) - status AuditStatus // DRAFT | COMPLETE - startedBy String // userId - startedAt DateTime @default(now()) - completedAt DateTime? - responses AuditResponse[] - sections AuditSection[] + id String @id @default(cuid()) + premisesId String + premises Premises @relation(fields: [premisesId], references: [id]) + templateId String + template AuditTemplate @relation(fields: [templateId], references: [id]) + templateRevision Int // pinned at start; updated for drafts on template sync + auditDate DateTime @db.Date // date of the inspection/review (not enforced annually) + sectionsSnapshot Json? // frozen template JSON when status becomes COMPLETE + status AuditStatus // DRAFT | COMPLETE + startedBy String // userId + startedAt DateTime @default(now()) + completedAt DateTime? + responses AuditResponse[] + sections AuditSection[] } model AuditSection { diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 3852ecb..91a3fe9 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -12,7 +12,15 @@ export default function AppLayout({ 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/page.tsx b/app/(app)/premises/[id]/audit/page.tsx index 7603842..3980b9f 100644 --- a/app/(app)/premises/[id]/audit/page.tsx +++ b/app/(app)/premises/[id]/audit/page.tsx @@ -1,6 +1,7 @@ import { notFound, redirect } from "next/navigation"; import { AuditStatus } from "@prisma/client"; -import { AuditStartPanel } from "@/components/audit/AuditStartPanel"; +import { AuditsManager } from "@/components/audit/AuditsManager"; +import { PremisesPageHeader } from "@/components/premises/PremisesPageHeader"; import { requireAuthContext } from "@/lib/auth"; import { getPremisesForOrganisation } from "@/lib/premises"; import { prisma } from "@/lib/prisma"; @@ -28,21 +29,30 @@ export default async function PremisesAuditPage({ params }: PageProps) { premisesId: premises.id, status: AuditStatus.DRAFT, }, - select: { id: true }, + select: { id: true, auditDate: true }, }); return (
-

Annual safety audit

-

{premises.name}

-

{premises.address}

+
-
diff --git a/app/(app)/premises/[id]/profile/page.tsx b/app/(app)/premises/[id]/profile/page.tsx index a2c8319..6b9ec91 100644 --- a/app/(app)/premises/[id]/profile/page.tsx +++ b/app/(app)/premises/[id]/profile/page.tsx @@ -4,6 +4,7 @@ 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"; @@ -59,14 +60,13 @@ export default async function PremisesProfilePage({ params }: PageProps) { return (
-

Premises profile

-

{premises.name}

-

{premises.address}

-

- Answer a few questions about your premises. We'll tailor the annual - safety audit to what applies to your building — usually about five - minutes. -

+ {premises.profile ? ( +

Template library

+

+ Audit templates +

+

+ Upload new questionnaire releases and manage which template version is + active by default. Each audit is pinned to the template used when it was + started. +

+ +
+ +
+
+ ); +} diff --git a/app/api/actions/[actionId]/route.ts b/app/api/actions/[actionId]/route.ts index 088b82d..af8ba75 100644 --- a/app/api/actions/[actionId]/route.ts +++ b/app/api/actions/[actionId]/route.ts @@ -1,23 +1,25 @@ import { NextResponse } from "next/server"; -import { Priority } from "@prisma/client"; +import { ActionStatus, Priority } from "@prisma/client"; import { requireAuthContext } from "@/lib/auth"; -import { serializeLinkedAction, updateAuditAction, deleteAuditAction } from "@/lib/audit"; -import { prisma } from "@/lib/prisma"; +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 action = await prisma.action.findFirst({ - where: { - id: params.actionId, - premises: { organisationId: auth.organisationId }, - }, - }); + const { getActionForOrganisation } = await import("@/lib/actions"); + const action = await getActionForOrganisation( + params.actionId, + auth.organisationId, + ); if (!action) { return NextResponse.json( @@ -27,7 +29,7 @@ export async function GET(_request: Request, { params }: RouteParams) { } return NextResponse.json({ - data: serializeLinkedAction(action), + data: serializeActionListItem(action), error: null, }); } catch (error) { @@ -46,6 +48,7 @@ export async function PATCH(request: Request, { params }: RouteParams) { 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) { @@ -62,15 +65,23 @@ export async function PATCH(request: Request, { params }: RouteParams) { ); } - const action = await updateAuditAction(params.actionId, auth.organisationId, { + 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: serializeLinkedAction(action), + data: serializeActionListItem(action), error: null, }); } catch (error) { @@ -90,7 +101,7 @@ export async function PATCH(request: Request, { params }: RouteParams) { export async function DELETE(_request: Request, { params }: RouteParams) { try { const auth = await requireAuthContext(); - await deleteAuditAction(params.actionId, auth.organisationId); + await deleteAction(params.actionId, auth.organisationId); return NextResponse.json({ data: { deleted: true }, error: null }); } catch (error) { diff --git a/app/api/audit-templates/[templateId]/activate/route.ts b/app/api/audit-templates/[templateId]/activate/route.ts new file mode 100644 index 0000000..b2809b8 --- /dev/null +++ b/app/api/audit-templates/[templateId]/activate/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { requireAuthContext } from "@/lib/auth"; +import { activateAuditTemplate } from "@/lib/audit-templates"; + +type RouteParams = { params: { templateId: string } }; + +export async function POST(_request: Request, { params }: RouteParams) { + try { + await requireAuthContext(); + const template = await activateAuditTemplate(params.templateId); + + return NextResponse.json({ data: { template }, error: null }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to activate template"; + const status = + message === "Unauthorized" + ? 401 + : message === "Template not found" + ? 404 + : 500; + + return NextResponse.json({ data: null, error: message }, { status }); + } +} diff --git a/app/api/audit-templates/route.ts b/app/api/audit-templates/route.ts new file mode 100644 index 0000000..a2df85a --- /dev/null +++ b/app/api/audit-templates/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { requireAuthContext } from "@/lib/auth"; +import { + importAuditTemplateRelease, + listAuditTemplateCatalog, + listSelectableAuditTemplates, +} from "@/lib/audit-templates"; +import { parseUploadedTemplatePayload } from "@/lib/audit-template-import"; + +export async function GET(request: Request) { + try { + await requireAuthContext(); + const url = new URL(request.url); + const mode = url.searchParams.get("mode"); + + const templates = + mode === "selectable" + ? await listSelectableAuditTemplates() + : await listAuditTemplateCatalog(); + + return NextResponse.json({ data: { templates }, error: null }); + } catch (error) { + return NextResponse.json( + { + data: null, + error: + error instanceof Error ? error.message : "Failed to load templates", + }, + { status: error instanceof Error && error.message === "Unauthorized" ? 401 : 500 }, + ); + } +} + +export async function POST(request: Request) { + try { + await requireAuthContext(); + const body = await request.json(); + const input = parseUploadedTemplatePayload(body); + + const template = await importAuditTemplateRelease({ + version: input.version, + publishedAt: input.publishedAt, + description: input.description, + sections: input.sections, + setActive: input.setActive, + }); + + return NextResponse.json({ data: { template }, error: null }, { status: 201 }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to import template"; + const status = + message === "Unauthorized" + ? 401 + : message.includes("already exists") || message.includes("must") + ? 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 index 322a1ce..9a84e42 100644 --- a/app/api/audit/[auditId]/route.ts +++ b/app/api/audit/[auditId]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { requireAuthContext } from "@/lib/auth"; -import { buildAuditOverview, getAuditForOrganisation } from "@/lib/audit"; +import { buildAuditOverview, completeAudit, getAuditForOrganisation } from "@/lib/audit"; type RouteParams = { params: { auditId: string } }; @@ -30,3 +30,29 @@ export async function GET(_request: Request, { params }: RouteParams) { ); } } + +export async function POST(_request: Request, { params }: RouteParams) { + try { + const auth = await requireAuthContext(); + const audit = await completeAudit(params.auditId, auth.organisationId); + + return NextResponse.json({ + data: buildAuditOverview(audit), + error: null, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to complete audit"; + const status = + message === "Unauthorized" + ? 401 + : message === "Audit not found" + ? 404 + : message.includes("Complete all sections") || + message.includes("Only draft") + ? 400 + : 500; + + return NextResponse.json({ data: null, error: message }, { status }); + } +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 7629bc9..8a6c245 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -5,7 +5,8 @@ export async function GET() { try { const template = await prisma.auditTemplate.findFirst({ where: { isActive: true }, - select: { version: true, publishedAt: true, sections: true }, + orderBy: [{ publishedAt: "desc" }, { revision: "desc" }], + select: { version: true, revision: true, publishedAt: true, sections: true }, }); const sections = Array.isArray(template?.sections) @@ -17,7 +18,11 @@ export async function GET() { status: "ok", database: "connected", auditTemplate: template - ? { version: template.version, sections } + ? { + version: template.version, + revision: template.revision, + sections, + } : null, }, error: null, 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 index 608ffff..2c699be 100644 --- a/app/api/premises/[id]/audit/route.ts +++ b/app/api/premises/[id]/audit/route.ts @@ -1,8 +1,19 @@ import { NextResponse } from "next/server"; import { AuditStatus } from "@prisma/client"; import { requireAuthContext } from "@/lib/auth"; -import { buildAuditOverview, startOrResumeAudit } from "@/lib/audit"; +import { + buildAuditOverview, + listPremisesAudits, + startNewAudit, + startOrResumeAudit, +} from "@/lib/audit"; +import { + defaultAuditDate, + getActiveAuditTemplateSummary, + listSelectableAuditTemplates, +} from "@/lib/audit-templates"; import { getPremisesForOrganisation } from "@/lib/premises"; +import { isInputDate, parseInputDate } from "@/lib/dates"; import { prisma } from "@/lib/prisma"; type RouteParams = { params: { id: string } }; @@ -22,18 +33,30 @@ export async function GET(_request: Request, { params }: RouteParams) { ); } - const draft = await prisma.audit.findFirst({ - where: { - premisesId: params.id, - status: AuditStatus.DRAFT, - }, - select: { id: true, startedAt: true }, - }); + const [draft, audits, activeTemplate, templates] = await Promise.all([ + prisma.audit.findFirst({ + where: { + premisesId: params.id, + status: AuditStatus.DRAFT, + }, + select: { id: true, startedAt: true, auditDate: true, templateId: true }, + }), + listPremisesAudits(params.id, auth.organisationId), + getActiveAuditTemplateSummary(), + listSelectableAuditTemplates(), + ]); + + const canStartNewAudit = !draft; return NextResponse.json({ data: { hasProfile: Boolean(premises.profile), draftAudit: draft, + audits, + activeTemplate, + templates, + defaultAuditDate: defaultAuditDate().toISOString().slice(0, 10), + canStartNewAudit, }, error: null, }); @@ -48,9 +71,57 @@ export async function GET(_request: Request, { params }: RouteParams) { } } -export async function POST(_request: Request, { params }: RouteParams) { +export async function POST(request: Request, { params }: RouteParams) { try { const auth = await requireAuthContext(); + const body = await request.json().catch(() => ({})); + const action = + body && typeof body === "object" && "action" in body + ? String((body as { action?: string }).action) + : "resume"; + + if (action === "start") { + const auditDateInput = + body && + typeof body === "object" && + typeof (body as { auditDate?: unknown }).auditDate === "string" + ? (body as { auditDate: string }).auditDate + : defaultAuditDate().toISOString().slice(0, 10); + + if (!isInputDate(auditDateInput)) { + return NextResponse.json( + { data: null, error: "Invalid audit date" }, + { status: 400 }, + ); + } + + const templateId = + body && + typeof body === "object" && + typeof (body as { templateId?: unknown }).templateId === "string" + ? (body as { templateId: string }).templateId + : undefined; + + const { audit, created } = await startNewAudit( + params.id, + auth.organisationId, + auth.userId, + { + auditDate: parseInputDate(auditDateInput)!, + templateId, + }, + ); + + return NextResponse.json({ + data: { + auditId: audit.id, + created, + overview: buildAuditOverview(audit), + }, + error: null, + }); + } + const { audit, created } = await startOrResumeAudit( params.id, auth.organisationId, @@ -72,7 +143,9 @@ export async function POST(_request: Request, { params }: RouteParams) { ? 401 : message === "Premises not found" ? 404 - : message.includes("profile") || message.includes("sections") + : message.includes("profile") || + message.includes("sections") || + message.includes("draft audit") ? 400 : 500; diff --git a/app/api/premises/[id]/profile/route.ts b/app/api/premises/[id]/profile/route.ts index b3ab61d..abd9069 100644 --- a/app/api/premises/[id]/profile/route.ts +++ b/app/api/premises/[id]/profile/route.ts @@ -3,7 +3,7 @@ import type { BuildingAgeBand, FloodRiskZone, OwnershipType } from "@prisma/clie import { requireAuthContext } from "@/lib/auth"; import { getPremisesForOrganisation } from "@/lib/premises"; import { prisma } from "@/lib/prisma"; -import { computeApplicableSections } from "@/lib/sections"; +import { computeApplicableSections, getTemplateSectionsForProfile } from "@/lib/sections"; import { EMPTY_PROFILE_FORM, type ProfileFormData } from "@/lib/profile-labels"; type RouteParams = { params: { id: string } }; @@ -141,17 +141,20 @@ export async function PUT(request: Request, { params }: RouteParams) { ); } - 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 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, + }, + await getTemplateSectionsForProfile(), + ); const profile = await prisma.premisesProfile.upsert({ where: { premisesId: premises.id }, diff --git a/app/page.tsx b/app/page.tsx index d240981..acb876c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -22,12 +22,26 @@ export default async function Home() { {premises.profile ? "Edit" : "Set up"} {premises.name} profile {premises.profile ? ( - - Start annual audit - + <> + + Manage audits + + + View actions + + + Audit templates + + ) : null} ) : ( 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 index 71a40af..dc31d62 100644 --- a/components/audit/ActionRaiseForm.tsx +++ b/components/audit/ActionRaiseForm.tsx @@ -1,8 +1,9 @@ "use client"; import { useState } from "react"; -import type { Priority } from "@prisma/client"; +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 }[] = [ @@ -11,16 +12,20 @@ const PRIORITY_OPTIONS: { value: Priority; label: string }[] = [ { 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; - onSubmit: (input: { - title: string; - description: string; - priority: Priority; - dueDate: string; - }) => Promise; + showStatus?: boolean; + onSubmit: (input: ActionFormSubmitInput) => Promise; onCancel: () => void; }; @@ -28,6 +33,7 @@ export function ActionForm({ item, existingAction = null, saving, + showStatus = false, onSubmit, onCancel, }: ActionFormProps) { @@ -40,6 +46,9 @@ export function ActionForm({ 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); @@ -58,6 +67,7 @@ export function ActionForm({ description: description.trim(), priority, dueDate, + ...(showStatus ? { status } : {}), }); } catch (submitError) { setError( @@ -115,6 +125,31 @@ export function ActionForm({ /> + {showStatus ? ( +
+ + +
+ ) : null} +
Priority
diff --git a/components/audit/AuditStartPanel.tsx b/components/audit/AuditStartPanel.tsx deleted file mode 100644 index f7c41ad..0000000 --- a/components/audit/AuditStartPanel.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import { useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { Button } from "@/components/ui/form"; - -type AuditStartPanelProps = { - premisesId: string; - premisesName: string; - hasProfile: boolean; - draftAuditId: string | null; -}; - -export function AuditStartPanel({ - premisesId, - premisesName, - hasProfile, - draftAuditId, -}: AuditStartPanelProps) { - const router = useRouter(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - async function startAudit() { - setLoading(true); - setError(null); - - try { - const response = await fetch(`/api/premises/${premisesId}/audit`, { - method: "POST", - }); - const result = await response.json(); - - if (!response.ok) { - throw new Error(result.error ?? "Failed to start audit"); - } - - const firstSection = result.data.overview.sections[0]?.id; - const url = firstSection - ? `/premises/${premisesId}/audit/${result.data.auditId}?section=${firstSection}` - : `/premises/${premisesId}/audit/${result.data.auditId}`; - - router.push(url); - } catch (startError) { - setError( - startError instanceof Error ? startError.message : "Failed to start audit", - ); - } finally { - setLoading(false); - } - } - - if (!hasProfile) { - return ( -
-

- Complete your premises profile first -

-

- We need to know about your building before we can tailor the audit - for {premisesName}. -

- - Set up premises profile - -
- ); - } - - return ( -
-

Annual safety audit

-

- Work through the Scout Association premises audit section by section. - You can save your progress and come back later. -

- - {draftAuditId ? ( -

- You have a draft audit in progress. -

- ) : null} - - {error ? ( -

{error}

- ) : null} - -
- {draftAuditId ? ( - - Continue audit - - ) : ( - - )} -
-
- ); -} diff --git a/components/audit/AuditWizard.tsx b/components/audit/AuditWizard.tsx index 9366dd8..0c426dc 100644 --- a/components/audit/AuditWizard.tsx +++ b/components/audit/AuditWizard.tsx @@ -23,6 +23,14 @@ type AuditWizardProps = { initialSectionId: string; }; +function formatDisplayDate(value: string): string { + return new Date(`${value}T00:00:00`).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + type ActionDialogState = { item: AuditItemWithResponse; existingAction: AuditLinkedAction | null; @@ -125,8 +133,15 @@ export function AuditWizard({ const [actionDialog, setActionDialog] = useState(null); const [savingAction, setSavingAction] = useState(false); const [deletingActionId, setDeletingActionId] = useState(null); + const [completingAudit, setCompletingAudit] = useState(false); const [error, setError] = useState(null); + const isReadOnly = overview.status === "COMPLETE"; + const canCompleteAudit = + !isReadOnly && + overview.progress.completedSections === overview.progress.totalSections && + overview.progress.totalSections > 0; + const refreshOverview = useCallback(async () => { const response = await fetch(`/api/audit/${auditId}`); const result = await response.json(); @@ -341,6 +356,34 @@ export function AuditWizard({ } } + async function handleCompleteAudit() { + setCompletingAudit(true); + setError(null); + + try { + const response = await fetch(`/api/audit/${auditId}`, { + method: "POST", + }); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error ?? "Failed to complete audit"); + } + + setOverview(result.data); + router.push(`/premises/${premisesId}/audit`); + router.refresh(); + } catch (completeError) { + setError( + completeError instanceof Error + ? completeError.message + : "Failed to complete audit", + ); + } finally { + setCompletingAudit(false); + } + } + const activeSection = overview.sections.find( (section) => section.id === activeSectionId, ); @@ -359,16 +402,29 @@ export function AuditWizard({

- Template {overview.templateVersion} · Draft audit + Audit {formatDisplayDate(overview.auditDate)} · Template{" "} + {overview.templateLabel} · {isReadOnly ? "Completed" : "Draft"}

{overview.premises.name}

-

- {overview.progress.completedSections} of{" "} - {overview.progress.totalSections} sections complete -

+
+

+ {overview.progress.completedSections} of{" "} + {overview.progress.totalSections} sections complete +

+ {canCompleteAudit ? ( + + ) : null} +
@@ -397,6 +453,13 @@ export function AuditWizard({ ) : null} + {isReadOnly ? ( +
+ This audit is complete and read-only. Answers are frozen against + template {overview.templateLabel}. +
+ ) : null} + {error ? (
{error} diff --git a/components/audit/AuditsManager.tsx b/components/audit/AuditsManager.tsx new file mode 100644 index 0000000..912224a --- /dev/null +++ b/components/audit/AuditsManager.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/form"; +import type { AuditHistoryItem } from "@/types/audit"; + +type TemplateOption = { + id: string; + version: string; + revision: number; + label: string; + longLabel: string; + isActive: boolean; +}; + +type AuditsManagerProps = { + premisesId: string; + premisesName: string; + hasProfile: boolean; + draftAuditId: string | null; + draftAuditDate?: string | null; +}; + +function formatDate(value: string): string { + return new Date(`${value}T00:00:00`).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +export function AuditsManager({ + premisesId, + premisesName, + hasProfile, + draftAuditId, + draftAuditDate, +}: AuditsManagerProps) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [audits, setAudits] = useState([]); + const [templates, setTemplates] = useState([]); + const [activeTemplateId, setActiveTemplateId] = useState(""); + const [selectedTemplateId, setSelectedTemplateId] = useState(""); + const [auditDate, setAuditDate] = useState(""); + const [canStartNewAudit, setCanStartNewAudit] = useState(false); + const [statusFilter, setStatusFilter] = useState<"ALL" | "DRAFT" | "COMPLETE">( + "ALL", + ); + + useEffect(() => { + if (!hasProfile) { + return; + } + + async function loadAuditMeta() { + try { + const response = await fetch(`/api/premises/${premisesId}/audit`); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error ?? "Failed to load audits"); + } + + const nextTemplates: TemplateOption[] = result.data.templates ?? []; + const active = result.data.activeTemplate; + + setAudits(result.data.audits ?? []); + setTemplates(nextTemplates); + setCanStartNewAudit(Boolean(result.data.canStartNewAudit)); + setAuditDate(result.data.defaultAuditDate ?? ""); + + const defaultTemplateId = + active?.id ?? + nextTemplates.find((template) => template.isActive)?.id ?? + nextTemplates[0]?.id ?? + ""; + + setActiveTemplateId(defaultTemplateId); + setSelectedTemplateId(defaultTemplateId); + } catch (loadError) { + setError( + loadError instanceof Error ? loadError.message : "Failed to load audits", + ); + } + } + + loadAuditMeta(); + }, [hasProfile, premisesId]); + + async function startAudit() { + setLoading(true); + setError(null); + + try { + const response = await fetch(`/api/premises/${premisesId}/audit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "start", + auditDate, + templateId: selectedTemplateId || undefined, + }), + }); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error ?? "Failed to start audit"); + } + + const firstSection = result.data.overview.sections[0]?.id; + const url = firstSection + ? `/premises/${premisesId}/audit/${result.data.auditId}?section=${firstSection}` + : `/premises/${premisesId}/audit/${result.data.auditId}`; + + router.push(url); + } catch (startError) { + setError( + startError instanceof Error ? startError.message : "Failed to start audit", + ); + } finally { + setLoading(false); + } + } + + const filteredAudits = audits.filter((audit) => { + if (statusFilter === "ALL") { + return true; + } + return audit.status === statusFilter; + }); + + if (!hasProfile) { + return ( +
+

+ Complete your premises profile first +

+

+ We need to know about your building before we can tailor audits for{" "} + {premisesName}. +

+ + Set up premises profile + +
+ ); + } + + return ( +
+ {draftAuditId ? ( +
+

Draft in progress

+

+ You have an audit in progress + {draftAuditDate ? ` dated ${formatDate(draftAuditDate)}` : ""}. Finish + or continue it before starting another. +

+ + Continue draft audit + +
+ ) : ( +
+

Start a new audit

+

+ Choose an audit template and inspection date. Each audit is pinned to + that template version when you start. +

+ +
+ + + +
+ +

+ Need a new questionnaire?{" "} + + Upload or manage templates + + . +

+ + {error ? ( +

{error}

+ ) : null} + +
+ +
+
+ )} + +
+
+

Audit history

+ +
+ + {filteredAudits.length === 0 ? ( +

No audits match your filters.

+ ) : ( +
+ + + + + + + + + + + {filteredAudits.map((audit) => ( + + + + + + + + ))} + +
DateTemplateStatusProgress +
+ {formatDate(audit.auditDate)} + + {audit.templateLabel} + + + {audit.status === "COMPLETE" ? "Completed" : "Draft"} + + + {audit.progress.completedSections}/{audit.progress.totalSections}{" "} + sections + + + {audit.status === "COMPLETE" ? "View" : "Open"} + +
+
+ )} +
+ + {activeTemplateId && templates.length > 0 ? ( +

+ Organisation default template:{" "} + {templates.find((template) => template.id === activeTemplateId)?.longLabel ?? + "Not set"} +

+ ) : null} +
+ ); +} diff --git a/components/premises/PremisesNav.tsx b/components/premises/PremisesNav.tsx new file mode 100644 index 0000000..c76bbc2 --- /dev/null +++ b/components/premises/PremisesNav.tsx @@ -0,0 +1,41 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +type PremisesNavProps = { + premisesId: string; +}; + +const links = [ + { href: (id: string) => `/premises/${id}/profile`, label: "Profile" }, + { href: (id: string) => `/premises/${id}/audit`, label: "Audits" }, + { href: (id: string) => `/premises/${id}/actions`, label: "Actions" }, +] as const; + +export function PremisesNav({ premisesId }: PremisesNavProps) { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/components/premises/PremisesPageHeader.tsx b/components/premises/PremisesPageHeader.tsx new file mode 100644 index 0000000..c139cdf --- /dev/null +++ b/components/premises/PremisesPageHeader.tsx @@ -0,0 +1,33 @@ +import { PremisesNav } from "@/components/premises/PremisesNav"; + +type PremisesPageHeaderProps = { + premisesId: string; + premisesName: string; + address: string; + eyebrow: string; + description?: string; +}; + +export function PremisesPageHeader({ + premisesId, + premisesName, + address, + eyebrow, + description, +}: PremisesPageHeaderProps) { + return ( +
+
+

{eyebrow}

+

+ {premisesName} +

+

{address}

+ {description ? ( +

{description}

+ ) : null} +
+ +
+ ); +} diff --git a/components/profile/ProfileWizard.tsx b/components/profile/ProfileWizard.tsx index c390099..13f3c7a 100644 --- a/components/profile/ProfileWizard.tsx +++ b/components/profile/ProfileWizard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import type { BuildingAgeBand, FloodRiskZone, @@ -61,6 +61,8 @@ export function ProfileWizard({ }: ProfileWizardProps) { const [stepIndex, setStepIndex] = useState(0); const [form, setForm] = useState(initialProfile); + const formRef = useRef(form); + formRef.current = form; const [savedSections, setSavedSections] = useState(initialApplicableSections); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); @@ -70,12 +72,17 @@ export function ProfileWizard({ const currentStep = PROFILE_STEPS[stepIndex]; function updateForm(patch: Partial) { - setForm((current) => ({ ...current, ...patch })); + setForm((current) => { + const next = { ...current, ...patch }; + formRef.current = next; + return next; + }); setSaved(false); setError(null); } - async function saveProfile() { + async function persistProfile(options?: { markSaved?: boolean }) { + const currentForm = formRef.current; setIsSaving(true); setError(null); @@ -83,7 +90,7 @@ export function ProfileWizard({ const response = await fetch(`/api/premises/${premisesId}/profile`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(form), + body: JSON.stringify(currentForm), }); const result = await response.json(); @@ -92,23 +99,71 @@ export function ProfileWizard({ throw new Error(result.error ?? "Failed to save profile"); } + setForm(result.data.profile); + formRef.current = result.data.profile; setSavedSections(result.data.applicableSections); - setSaved(true); + if (options?.markSaved) { + setSaved(true); + } } catch (saveError) { setError( saveError instanceof Error ? saveError.message : "Failed to save profile", ); + throw saveError; } finally { setIsSaving(false); } } + async function saveProfile() { + await persistProfile({ markSaved: true }); + } + async function handleNext() { + const currentForm = formRef.current; + + if (!canProceed(stepIndex, currentForm)) { + setError("Please answer the required questions before continuing."); + return; + } + + setError(null); + if (stepIndex < PROFILE_STEPS.length - 1) { + if (currentForm.ownershipType && currentForm.buildingAgeBand) { + try { + await persistProfile(); + } catch { + return; + } + } + setStepIndex((index) => index + 1); return; } - await saveProfile(); + + try { + await saveProfile(); + } catch { + return; + } + } + + function handleContinuePointerDown( + event: React.PointerEvent, + ) { + if ( + event.pointerType !== "mouse" || + event.button !== 0 || + isSaving + ) { + return; + } + + // Run on pointer down so the first click still works after choosing a radio + // (otherwise blur/focus can swallow the first click on some browsers). + event.preventDefault(); + void handleNext(); } function handleBack() { @@ -343,8 +398,15 @@ export function ProfileWizard({ ) : null}