From 77f91ac13ff37d76604161ef777e070ac01fc61e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 19:33:40 +0000 Subject: [PATCH 1/6] Add audit template versioning and multi-year audit lifecycle Introduce version + revision on AuditTemplate (RELEASE vs PATCH) so question sets can evolve atomically or be corrected in place. Each audit is pinned to a template revision and compliance year, with a sectionsSnapshot frozen on completion so historical answers stay stable. Draft audits sync to the latest active template revision; completed audits are read-only. The audit start page shows template info and per-year history, and the wizard supports finishing an audit when all sections are complete. Co-authored-by: jonbur --- CONTEXT.md | 47 +-- app/(app)/premises/[id]/audit/page.tsx | 3 +- app/api/audit/[auditId]/route.ts | 28 +- app/api/health/route.ts | 9 +- app/api/premises/[id]/audit/route.ts | 75 ++++- app/api/premises/[id]/profile/route.ts | 27 +- components/audit/AuditStartPanel.tsx | 177 ++++++++++-- components/audit/AuditWizard.tsx | 65 ++++- lib/audit-templates.ts | 190 +++++++++++++ lib/audit.ts | 269 +++++++++++++++--- lib/sections.ts | 7 +- .../migration.sql | 33 +++ prisma/schema.prisma | 51 ++-- prisma/seed.ts | 19 +- types/audit-template-version.ts | 28 ++ types/audit.ts | 16 ++ 16 files changed, 904 insertions(+), 140 deletions(-) create mode 100644 lib/audit-templates.ts create mode 100644 prisma/migrations/20250701190000_audit_template_versioning/migration.sql create mode 100644 types/audit-template-version.ts diff --git a/CONTEXT.md b/CONTEXT.md index 929c0c3..8d20fe4 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 (typically one per year). +// 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 + auditYear Int // compliance year, e.g. 2025 + 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)/premises/[id]/audit/page.tsx b/app/(app)/premises/[id]/audit/page.tsx index 7603842..3eb11b7 100644 --- a/app/(app)/premises/[id]/audit/page.tsx +++ b/app/(app)/premises/[id]/audit/page.tsx @@ -28,7 +28,7 @@ export default async function PremisesAuditPage({ params }: PageProps) { premisesId: premises.id, status: AuditStatus.DRAFT, }, - select: { id: true }, + select: { id: true, auditYear: true }, }); return ( @@ -43,6 +43,7 @@ export default async function PremisesAuditPage({ params }: PageProps) { premisesName={premises.name} hasProfile={Boolean(premises.profile)} draftAuditId={draft?.id ?? null} + draftAuditYear={draft?.auditYear ?? null} /> 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]/audit/route.ts b/app/api/premises/[id]/audit/route.ts index 608ffff..ae42c8f 100644 --- a/app/api/premises/[id]/audit/route.ts +++ b/app/api/premises/[id]/audit/route.ts @@ -1,7 +1,16 @@ import { NextResponse } from "next/server"; import { AuditStatus } from "@prisma/client"; import { requireAuthContext } from "@/lib/auth"; -import { buildAuditOverview, startOrResumeAudit } from "@/lib/audit"; +import { + buildAuditOverview, + listPremisesAudits, + startNewAnnualAudit, + startOrResumeAudit, +} from "@/lib/audit"; +import { + currentAuditYear, + getActiveAuditTemplateSummary, +} from "@/lib/audit-templates"; import { getPremisesForOrganisation } from "@/lib/premises"; import { prisma } from "@/lib/prisma"; @@ -22,18 +31,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] = await Promise.all([ + prisma.audit.findFirst({ + where: { + premisesId: params.id, + status: AuditStatus.DRAFT, + }, + select: { id: true, startedAt: true, auditYear: true }, + }), + listPremisesAudits(params.id, auth.organisationId), + getActiveAuditTemplateSummary(), + ]); + + const currentYear = currentAuditYear(); + const canStartNewAudit = + !draft && !audits.some((audit) => audit.auditYear === currentYear); return NextResponse.json({ data: { hasProfile: Boolean(premises.profile), draftAudit: draft, + audits, + activeTemplate, + currentAuditYear: currentYear, + canStartNewAudit, }, error: null, }); @@ -48,9 +69,40 @@ 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 auditYear = + body && + typeof body === "object" && + typeof (body as { auditYear?: unknown }).auditYear === "number" + ? (body as { auditYear: number }).auditYear + : currentAuditYear(); + + const { audit, created } = await startNewAnnualAudit( + params.id, + auth.organisationId, + auth.userId, + auditYear, + ); + + return NextResponse.json({ + data: { + auditId: audit.id, + created, + overview: buildAuditOverview(audit), + }, + error: null, + }); + } + const { audit, created } = await startOrResumeAudit( params.id, auth.organisationId, @@ -72,7 +124,10 @@ 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("already exists") || + 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/components/audit/AuditStartPanel.tsx b/components/audit/AuditStartPanel.tsx index f7c41ad..42f02bd 100644 --- a/components/audit/AuditStartPanel.tsx +++ b/components/audit/AuditStartPanel.tsx @@ -1,34 +1,94 @@ "use client"; -import { useState } from "react"; +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 ActiveTemplate = { + label: string; + longLabel: string; + version: string; + revision: number; +}; type AuditStartPanelProps = { premisesId: string; premisesName: string; hasProfile: boolean; draftAuditId: string | null; + draftAuditYear?: number | null; }; +function formatDate(value: string): string { + return new Date(value).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + export function AuditStartPanel({ premisesId, premisesName, hasProfile, draftAuditId, + draftAuditYear, }: AuditStartPanelProps) { const router = useRouter(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [audits, setAudits] = useState([]); + const [activeTemplate, setActiveTemplate] = useState( + null, + ); + const [currentAuditYear, setCurrentAuditYear] = useState(null); + const [canStartNewAudit, setCanStartNewAudit] = useState(false); - async function startAudit() { + 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 audit history"); + } + + setAudits(result.data.audits ?? []); + setActiveTemplate(result.data.activeTemplate ?? null); + setCurrentAuditYear(result.data.currentAuditYear ?? null); + setCanStartNewAudit(Boolean(result.data.canStartNewAudit)); + } catch (loadError) { + setError( + loadError instanceof Error + ? loadError.message + : "Failed to load audit history", + ); + } + } + + loadAuditMeta(); + }, [hasProfile, premisesId]); + + async function startAudit(action: "resume" | "start" = "resume") { 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" + ? { action: "start", auditYear: currentAuditYear } + : { action: "resume" }, + ), }); const result = await response.json(); @@ -72,37 +132,98 @@ export function AuditStartPanel({ } 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. +

+
+

Annual safety audit

+

+ Work through the Scout Association premises audit section by section. + Each audit is tied to a template version and compliance year. You can + save progress and return later.

- ) : null} - {error ? ( -

{error}

- ) : null} + {activeTemplate ? ( +

+ Current template:{" "} + + {activeTemplate.longLabel} + +

+ ) : null} -
{draftAuditId ? ( - - Continue audit - - ) : ( - - )} +

+ You have a draft audit in progress + {draftAuditYear ? ` for ${draftAuditYear}` : ""}. +

+ ) : canStartNewAudit && currentAuditYear ? ( +

+ No audit started for {currentAuditYear} yet. +

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

{error}

+ ) : null} + +
+ {draftAuditId ? ( + + Continue audit + + ) : canStartNewAudit ? ( + + ) : ( + + )} +
+ + {audits.length > 0 ? ( +
+

Audit history

+
    + {audits.map((audit) => ( +
  • +
    +

    + {audit.auditYear} audit +

    +

    + Template {audit.templateLabel} ·{" "} + {audit.status === "COMPLETE" ? "Completed" : "Draft"} ·{" "} + {audit.progress.completedSections}/ + {audit.progress.totalSections} sections +

    +

    + Started {formatDate(audit.startedAt)} + {audit.completedAt + ? ` · Completed ${formatDate(audit.completedAt)}` + : ""} +

    +
    + + {audit.status === "COMPLETE" ? "View" : "Open"} + +
  • + ))} +
+
+ ) : null}
); } diff --git a/components/audit/AuditWizard.tsx b/components/audit/AuditWizard.tsx index 9366dd8..027bf42 100644 --- a/components/audit/AuditWizard.tsx +++ b/components/audit/AuditWizard.tsx @@ -125,8 +125,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 +348,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 +394,29 @@ export function AuditWizard({

- Template {overview.templateVersion} · Draft audit + {overview.auditYear} audit · 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 +445,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/lib/audit-templates.ts b/lib/audit-templates.ts new file mode 100644 index 0000000..53cddea --- /dev/null +++ b/lib/audit-templates.ts @@ -0,0 +1,190 @@ +import type { Audit, AuditTemplate, AuditTemplateChangeType } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; +import type { AuditTemplateSections } from "@/types/audit-template"; +import { + formatAuditTemplateVersion, + formatAuditTemplateVersionLong, +} from "@/types/audit-template-version"; + +export type AuditTemplateSummary = { + id: string; + version: string; + revision: number; + changeType: AuditTemplateChangeType; + description: string | null; + publishedAt: string; + isActive: boolean; + label: string; + longLabel: string; +}; + +export type AuditWithTemplateFields = { + status: Audit["status"]; + sectionsSnapshot?: unknown | null; + template: { + version: string; + revision: number; + changeType: AuditTemplateChangeType; + sections: unknown; + }; +}; + +function toTemplateSummary(template: AuditTemplate): AuditTemplateSummary { + return { + id: template.id, + version: template.version, + revision: template.revision, + changeType: template.changeType, + description: template.description, + publishedAt: template.publishedAt.toISOString(), + isActive: template.isActive, + label: formatAuditTemplateVersion(template), + longLabel: formatAuditTemplateVersionLong(template), + }; +} + +export function parseTemplateSections(sections: unknown): AuditTemplateSections { + return sections as AuditTemplateSections; +} + +export function getAuditTemplateSections( + audit: AuditWithTemplateFields, +): AuditTemplateSections { + if (audit.status === "COMPLETE" && audit.sectionsSnapshot) { + return parseTemplateSections(audit.sectionsSnapshot); + } + + return parseTemplateSections(audit.template.sections); +} + +export function collectAnswerableItemIds( + sections: AuditTemplateSections, +): Set { + const itemIds = new Set(); + + for (const section of sections) { + for (const item of section.items) { + if (item.responseType === "GROUP") { + for (const subQuestion of item.subQuestions) { + itemIds.add(subQuestion.id); + } + } else { + itemIds.add(item.id); + } + } + } + + return itemIds; +} + +export async function getActiveAuditTemplate(): Promise { + return prisma.auditTemplate.findFirst({ + where: { isActive: true }, + orderBy: [{ publishedAt: "desc" }, { revision: "desc" }], + }); +} + +export async function getActiveAuditTemplateSummary(): Promise { + const template = await getActiveAuditTemplate(); + return template ? toTemplateSummary(template) : null; +} + +export async function getLatestRevisionForVersion( + version: string, +): Promise { + return prisma.auditTemplate.findFirst({ + where: { version }, + orderBy: { revision: "desc" }, + }); +} + +export async function getActiveTemplateSections(): Promise { + const template = await getActiveAuditTemplate(); + + if (!template) { + const { auditTemplate202509 } = await import( + "@/prisma/data/audit-template-normalized" + ); + return auditTemplate202509; + } + + return parseTemplateSections(template.sections); +} + +export async function publishTemplatePatch(input: { + version: string; + sections: AuditTemplateSections; + description?: string; + inPlace?: boolean; +}): Promise { + const latest = await getLatestRevisionForVersion(input.version); + + if (!latest) { + throw new Error(`Template version ${input.version} not found`); + } + + if (input.inPlace) { + return prisma.auditTemplate.update({ + where: { id: latest.id }, + data: { + sections: input.sections, + changeType: "PATCH", + description: input.description ?? latest.description, + }, + }); + } + + const nextRevision = latest.revision + 1; + + return prisma.$transaction(async (tx) => { + await tx.auditTemplate.update({ + where: { id: latest.id }, + data: { supersededAt: new Date() }, + }); + + return tx.auditTemplate.create({ + data: { + version: input.version, + revision: nextRevision, + changeType: "PATCH", + description: input.description ?? null, + publishedAt: new Date(), + sections: input.sections, + isActive: latest.isActive, + }, + }); + }); +} + +export async function publishTemplateRelease(input: { + version: string; + publishedAt: Date; + sections: AuditTemplateSections; + description?: string; +}): Promise { + return prisma.$transaction(async (tx) => { + await tx.auditTemplate.updateMany({ + where: { isActive: true }, + data: { + isActive: false, + supersededAt: new Date(), + }, + }); + + return tx.auditTemplate.create({ + data: { + version: input.version, + revision: 1, + changeType: "RELEASE", + description: input.description ?? null, + publishedAt: input.publishedAt, + sections: input.sections, + isActive: true, + }, + }); + }); +} + +export function currentAuditYear(date = new Date()): number { + return date.getUTCFullYear(); +} diff --git a/lib/audit.ts b/lib/audit.ts index c8ea3da..c548b0e 100644 --- a/lib/audit.ts +++ b/lib/audit.ts @@ -3,6 +3,7 @@ import { ActionStatus, AuditSectionStatus, AuditStatus, + AuditTemplateChangeType, Priority, ResponseValue, type Action, @@ -13,6 +14,13 @@ import { } from "@prisma/client"; import { prisma } from "@/lib/prisma"; import { getPremisesForOrganisation } from "@/lib/premises"; +import { + collectAnswerableItemIds, + currentAuditYear, + getActiveAuditTemplate, + getAuditTemplateSections, + parseTemplateSections, +} from "@/lib/audit-templates"; import { filterApplicableItems, findAnswerableItemInAudit, @@ -33,7 +41,12 @@ import type { AuditLinkedAction } from "@/types/audit"; export type AuditWithRelations = Audit & { sections: AuditSection[]; responses: AuditResponse[]; - template: { version: string; sections: unknown }; + template: { + version: string; + revision: number; + changeType: AuditTemplateChangeType; + sections: unknown; + }; premises: { id: string; name: string; @@ -41,14 +54,32 @@ export type AuditWithRelations = Audit & { }; }; -export function parseTemplateSections(sections: unknown): AuditTemplateSections { - return sections as AuditTemplateSections; -} +export type AuditListItem = { + id: string; + auditYear: number; + status: AuditStatus; + templateVersion: string; + templateRevision: number; + templateLabel: string; + startedAt: string; + completedAt: string | null; + progress: { + completedSections: number; + totalSections: number; + }; +}; const auditWithRelationsInclude = { sections: { orderBy: { sectionId: "asc" as const } }, responses: true, - template: { select: { version: true, sections: true } }, + template: { + select: { + version: true, + revision: true, + changeType: true, + sections: true, + }, + }, premises: { select: { id: true, @@ -82,6 +113,42 @@ function applicableSectionsMatch( return current.every((sectionId, index) => sectionId === target[index]); } +function formatTemplateLabel(version: string, revision: number): string { + return revision <= 1 ? version : `${version} (rev ${revision})`; +} + +async function pruneOrphanedResponses( + auditId: string, + templateSections: AuditTemplateSections, + profile: PremisesProfile, + applicableSectionIds: string[], +): Promise { + const validItemIds = new Set(); + + for (const sectionId of applicableSectionIds) { + const section = getTemplateSection(templateSections, sectionId); + if (!section) { + continue; + } + + const applicableItems = getApplicableItemsForSection(section, profile); + for (const item of getAnswerableItems(applicableItems)) { + validItemIds.add(item.id); + } + } + + if (validItemIds.size === 0) { + return; + } + + await prisma.auditResponse.deleteMany({ + where: { + auditId, + itemId: { notIn: Array.from(validItemIds) }, + }, + }); +} + export async function syncDraftAudit( audit: AuditWithRelations, ): Promise { @@ -94,16 +161,13 @@ export async function syncDraftAudit( return audit; } - const activeTemplate = await prisma.auditTemplate.findFirst({ - where: { isActive: true }, - orderBy: { publishedAt: "desc" }, - }); - + const activeTemplate = await getActiveAuditTemplate(); if (!activeTemplate) { return audit; } - const applicableSections = computeApplicableSections(profile); + const activeSections = parseTemplateSections(activeTemplate.sections); + const applicableSections = computeApplicableSections(profile, activeSections); const currentSectionIds = new Set( audit.sections.map((section) => section.sectionId), ); @@ -139,7 +203,10 @@ export async function syncDraftAudit( if (needsTemplateUpdate) { await tx.audit.update({ where: { id: audit.id }, - data: { templateId: activeTemplate.id }, + data: { + templateId: activeTemplate.id, + templateRevision: activeTemplate.revision, + }, }); } @@ -163,6 +230,15 @@ export async function syncDraftAudit( } }); + if (needsTemplateUpdate || sectionsToRemove.length > 0) { + await pruneOrphanedResponses( + audit.id, + activeSections, + profile, + applicableSections, + ); + } + const refreshed = await prisma.audit.findUnique({ where: { id: audit.id }, include: auditWithRelationsInclude, @@ -202,6 +278,41 @@ export function getApplicableItemsForSection( return filterApplicableItems(section.items, profile); } +export async function listPremisesAudits( + premisesId: string, + organisationId: string, +): Promise { + const premises = await getPremisesForOrganisation(premisesId, organisationId); + + if (!premises) { + throw new Error("Premises not found"); + } + + const audits = await prisma.audit.findMany({ + where: { premisesId }, + include: auditWithRelationsInclude, + orderBy: [{ auditYear: "desc" }, { startedAt: "desc" }], + }); + + return audits.map((audit) => { + const overview = buildAuditOverview(audit as AuditWithRelations); + return { + id: audit.id, + auditYear: audit.auditYear, + status: audit.status, + templateVersion: audit.template.version, + templateRevision: audit.templateRevision, + templateLabel: formatTemplateLabel( + audit.template.version, + audit.templateRevision, + ), + startedAt: audit.startedAt.toISOString(), + completedAt: audit.completedAt?.toISOString() ?? null, + progress: overview.progress, + }; + }); +} + export async function startOrResumeAudit( premisesId: string, organisationId: string, @@ -226,18 +337,7 @@ export async function startOrResumeAudit( premisesId, status: AuditStatus.DRAFT, }, - include: { - sections: { orderBy: { sectionId: "asc" } }, - responses: true, - template: { select: { version: true, sections: true } }, - premises: { - select: { - id: true, - name: true, - profile: true, - }, - }, - }, + include: auditWithRelationsInclude, }); if (existingDraft) { @@ -249,40 +349,75 @@ export async function startOrResumeAudit( }; } - const template = await prisma.auditTemplate.findFirst({ - where: { isActive: true }, - orderBy: { publishedAt: "desc" }, + return startNewAnnualAudit(premisesId, organisationId, userId); +} + +export async function startNewAnnualAudit( + premisesId: string, + organisationId: string, + userId: string, + auditYear = currentAuditYear(), +): Promise<{ audit: AuditWithRelations; created: boolean }> { + const premises = await getPremisesForOrganisation(premisesId, organisationId); + + if (!premises) { + throw new Error("Premises not found"); + } + + if (!premises.profile) { + throw new Error("Complete the premises profile before starting an audit"); + } + + const existingDraft = await prisma.audit.findFirst({ + where: { premisesId, status: AuditStatus.DRAFT }, + select: { id: true }, }); + if (existingDraft) { + throw new Error("Finish or continue the draft audit before starting another"); + } + + const existingForYear = await prisma.audit.findFirst({ + where: { premisesId, auditYear }, + select: { id: true, status: true }, + }); + + if (existingForYear) { + throw new Error(`An audit for ${auditYear} already exists`); + } + + const template = await getActiveAuditTemplate(); + if (!template) { throw new Error("No active audit template found"); } + const templateSections = parseTemplateSections(template.sections); + const applicableSections = computeApplicableSections( + premises.profile, + templateSections, + ); + + if (applicableSections.length === 0) { + throw new Error("No audit sections apply to this premises profile"); + } + const audit = await prisma.audit.create({ data: { premisesId, templateId: template.id, + templateRevision: template.revision, + auditYear, status: AuditStatus.DRAFT, startedBy: userId, sections: { - create: premises.profile.applicableSections.map((sectionId) => ({ + create: applicableSections.map((sectionId) => ({ sectionId, status: AuditSectionStatus.NOT_STARTED, })), }, }, - include: { - sections: { orderBy: { sectionId: "asc" } }, - responses: true, - template: { select: { version: true, sections: true } }, - premises: { - select: { - id: true, - name: true, - profile: true, - }, - }, - }, + include: auditWithRelationsInclude, }); return { @@ -291,6 +426,41 @@ export async function startOrResumeAudit( }; } +export async function completeAudit( + auditId: string, + organisationId: string, +): Promise { + const audit = await fetchAuditWithRelations(auditId, organisationId); + + if (!audit) { + throw new Error("Audit not found"); + } + + if (audit.status !== AuditStatus.DRAFT) { + throw new Error("Only draft audits can be completed"); + } + + const overview = buildAuditOverview(audit); + if (overview.progress.completedSections < overview.progress.totalSections) { + throw new Error("Complete all sections before finishing the audit"); + } + + const templateSections = getAuditTemplateSections(audit); + + const completed = await prisma.audit.update({ + where: { id: auditId }, + data: { + status: AuditStatus.COMPLETE, + completedAt: new Date(), + sectionsSnapshot: templateSections, + templateRevision: audit.template.revision, + }, + include: auditWithRelationsInclude, + }); + + return completed as AuditWithRelations; +} + function isResponseComplete( response: AuditResponse, item?: AnswerableAuditItem, @@ -401,7 +571,7 @@ export async function saveAuditResponse( throw new Error("Premises profile is required"); } - const templateSections = parseTemplateSections(audit.template.sections); + const templateSections = getAuditTemplateSections(audit); const section = getTemplateSection(templateSections, sectionId); if (!section) { @@ -504,6 +674,11 @@ export async function createAuditAction( throw new Error("Audit not found"); } + const templateSections = getAuditTemplateSections(audit); + if (!collectAnswerableItemIds(templateSections).has(input.itemId)) { + throw new Error("Question not found in this audit template"); + } + return prisma.action.create({ data: { premisesId: audit.premisesId, @@ -621,7 +796,7 @@ export async function buildAuditSectionPayload( throw new Error("Premises profile is required"); } - const templateSections = parseTemplateSections(audit.template.sections); + const templateSections = getAuditTemplateSections(audit); const section = getTemplateSection(templateSections, sectionId); if (!section) { @@ -689,7 +864,7 @@ export async function buildAuditSectionPayload( } export function buildAuditOverview(audit: AuditWithRelations) { - const templateSections = parseTemplateSections(audit.template.sections); + const templateSections = getAuditTemplateSections(audit); const profile = audit.premises.profile; const sections = audit.sections @@ -736,7 +911,13 @@ export function buildAuditOverview(audit: AuditWithRelations) { return { id: audit.id, status: audit.status, + auditYear: audit.auditYear, templateVersion: audit.template.version, + templateRevision: audit.templateRevision, + templateLabel: formatTemplateLabel( + audit.template.version, + audit.templateRevision, + ), premises: audit.premises, sections, progress: { @@ -745,3 +926,5 @@ export function buildAuditOverview(audit: AuditWithRelations) { }, }; } + +export { parseTemplateSections } from "@/lib/audit-templates"; diff --git a/lib/sections.ts b/lib/sections.ts index 54f5d72..1b4f77b 100644 --- a/lib/sections.ts +++ b/lib/sections.ts @@ -1,5 +1,6 @@ -import type { AuditTemplateSection } from "@/types/audit-template"; +import type { AuditTemplateSection, AuditTemplateSections } from "@/types/audit-template"; import { auditTemplate202509 } from "@/prisma/data/audit-template-normalized"; +import { getActiveTemplateSections } from "@/lib/audit-templates"; import { matchesProfileFlag, type ProfileForSections, @@ -13,6 +14,10 @@ export const AUDIT_SECTION_IDS = auditTemplate202509.map( export type AuditSectionId = (typeof AUDIT_SECTION_IDS)[number]; +export async function getTemplateSectionsForProfile(): Promise { + return getActiveTemplateSections(); +} + export function isLargerPremises(profile: ProfileForSections): boolean { return ( profile.hasSleeping || diff --git a/prisma/migrations/20250701190000_audit_template_versioning/migration.sql b/prisma/migrations/20250701190000_audit_template_versioning/migration.sql new file mode 100644 index 0000000..f1316d5 --- /dev/null +++ b/prisma/migrations/20250701190000_audit_template_versioning/migration.sql @@ -0,0 +1,33 @@ +-- Audit template versioning: revision within a release, audit year, and completion snapshot. + +CREATE TYPE "AuditTemplateChangeType" AS ENUM ('RELEASE', 'PATCH'); + +DROP INDEX IF EXISTS "AuditTemplate_version_key"; + +ALTER TABLE "AuditTemplate" + ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 1, + ADD COLUMN "changeType" "AuditTemplateChangeType" NOT NULL DEFAULT 'RELEASE', + ADD COLUMN "description" TEXT, + ADD COLUMN "supersededAt" TIMESTAMP(3); + +CREATE UNIQUE INDEX "AuditTemplate_version_revision_key" ON "AuditTemplate"("version", "revision"); +CREATE INDEX "AuditTemplate_isActive_idx" ON "AuditTemplate"("isActive"); +CREATE INDEX "AuditTemplate_version_idx" ON "AuditTemplate"("version"); + +ALTER TABLE "Audit" + ADD COLUMN "templateRevision" INTEGER, + ADD COLUMN "auditYear" INTEGER, + ADD COLUMN "sectionsSnapshot" JSONB; + +UPDATE "Audit" AS a +SET + "templateRevision" = t."revision", + "auditYear" = EXTRACT(YEAR FROM a."startedAt")::INTEGER +FROM "AuditTemplate" AS t +WHERE a."templateId" = t."id"; + +ALTER TABLE "Audit" + ALTER COLUMN "templateRevision" SET NOT NULL, + ALTER COLUMN "auditYear" SET NOT NULL; + +CREATE INDEX "Audit_premisesId_auditYear_idx" ON "Audit"("premisesId", "auditYear"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6cd59e1..7093e7a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,28 +72,40 @@ model PremisesProfile { } model AuditTemplate { - id String @id @default(cuid()) - version String @unique - publishedAt DateTime - sections Json - isActive Boolean @default(false) - audits Audit[] + id String @id @default(cuid()) + version String + revision Int @default(1) + changeType AuditTemplateChangeType @default(RELEASE) + description String? + publishedAt DateTime + sections Json + isActive Boolean @default(false) + supersededAt DateTime? + audits Audit[] + + @@unique([version, revision]) + @@index([isActive]) + @@index([version]) } model Audit { - id String @id @default(cuid()) - premisesId String - premises Premises @relation(fields: [premisesId], references: [id], onDelete: Cascade) - templateId String - template AuditTemplate @relation(fields: [templateId], references: [id]) - status AuditStatus - startedBy String - startedAt DateTime @default(now()) - completedAt DateTime? - responses AuditResponse[] - sections AuditSection[] + id String @id @default(cuid()) + premisesId String + premises Premises @relation(fields: [premisesId], references: [id], onDelete: Cascade) + templateId String + template AuditTemplate @relation(fields: [templateId], references: [id]) + templateRevision Int + auditYear Int + sectionsSnapshot Json? + status AuditStatus + startedBy String + startedAt DateTime @default(now()) + completedAt DateTime? + responses AuditResponse[] + sections AuditSection[] @@index([premisesId]) + @@index([premisesId, auditYear]) @@index([templateId]) } @@ -248,6 +260,11 @@ enum FloodRiskZone { HIGH } +enum AuditTemplateChangeType { + RELEASE + PATCH +} + enum AuditStatus { DRAFT COMPLETE diff --git a/prisma/seed.ts b/prisma/seed.ts index 6487cff..8c15104 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -4,6 +4,7 @@ import { computeApplicableSections } from "../lib/sections"; import { auditTemplate202509, AUDIT_TEMPLATE_PUBLISHED_AT, + AUDIT_TEMPLATE_VERSION, } from "./data/audit-template-normalized"; const prisma = new PrismaClient(); @@ -15,21 +16,33 @@ async function main() { console.log("Seeding database..."); const template = await prisma.auditTemplate.upsert({ - where: { version: "2025-09" }, + where: { + version_revision: { + version: AUDIT_TEMPLATE_VERSION, + revision: 1, + }, + }, update: { sections: auditTemplate202509, publishedAt: AUDIT_TEMPLATE_PUBLISHED_AT, isActive: true, + changeType: "RELEASE", + description: "Scout Association premises audit — September 2025 release", }, create: { - version: "2025-09", + version: AUDIT_TEMPLATE_VERSION, + revision: 1, + changeType: "RELEASE", + description: "Scout Association premises audit — September 2025 release", publishedAt: AUDIT_TEMPLATE_PUBLISHED_AT, sections: auditTemplate202509, isActive: true, }, }); - console.log(`Audit template seeded: ${template.version} (${template.id})`); + console.log( + `Audit template seeded: ${template.version} rev ${template.revision} (${template.id})`, + ); console.log( ` Sections: ${auditTemplate202509.length}, Top-level items: ${auditTemplate202509.reduce((n, s) => n + s.items.length, 0)}, Answerable questions: ${auditTemplate202509.reduce((n, s) => n + s.items.reduce((count, item) => count + (item.responseType === "GROUP" ? item.subQuestions.length : 1), 0), 0)}`, ); diff --git a/types/audit-template-version.ts b/types/audit-template-version.ts new file mode 100644 index 0000000..f01ba42 --- /dev/null +++ b/types/audit-template-version.ts @@ -0,0 +1,28 @@ +import type { AuditTemplateChangeType } from "@prisma/client"; + +export type AuditTemplateVersionLabel = { + version: string; + revision: number; + changeType: AuditTemplateChangeType; +}; + +export function formatAuditTemplateVersion( + template: Pick, +): string { + if (template.revision <= 1) { + return template.version; + } + + return `${template.version} (rev ${template.revision})`; +} + +export function formatAuditTemplateVersionLong( + template: AuditTemplateVersionLabel, +): string { + const base = formatAuditTemplateVersion(template); + if (template.changeType === "PATCH") { + return `${base} · patch`; + } + + return base; +} diff --git a/types/audit.ts b/types/audit.ts index dd3c3fb..2acb0bb 100644 --- a/types/audit.ts +++ b/types/audit.ts @@ -22,7 +22,10 @@ export type AuditSectionSummary = { export type AuditOverview = { id: string; status: string; + auditYear: number; templateVersion: string; + templateRevision: number; + templateLabel: string; premises: { id: string; name: string; @@ -35,6 +38,19 @@ export type AuditOverview = { }; }; +export type AuditHistoryItem = { + id: string; + auditYear: number; + status: string; + templateLabel: string; + startedAt: string; + completedAt: string | null; + progress: { + completedSections: number; + totalSections: number; + }; +}; + export type AuditResponseRecord = { id: string; response: ResponseValue; From 80527076647fd4f667f7fb0f702b4aba8fb4af6b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 19:47:44 +0000 Subject: [PATCH 2/6] Replace audit year with audit date and remove annual limit Use auditDate (PostgreSQL DATE) instead of auditYear so audits are not tied to calendar years. Multiple audits can exist for any date; only one draft at a time is still enforced. The start panel lets users pick the audit date, defaulting to today. Co-authored-by: jonbur --- CONTEXT.md | 4 +- app/(app)/premises/[id]/audit/page.tsx | 8 ++- app/api/premises/[id]/audit/route.ts | 33 +++++----- components/audit/AuditStartPanel.tsx | 61 ++++++++++++------- components/audit/AuditWizard.tsx | 12 +++- lib/audit-templates.ts | 6 +- lib/audit.ts | 27 +++----- .../20250701200000_audit_date/migration.sql | 16 +++++ prisma/schema.prisma | 4 +- types/audit.ts | 4 +- 10 files changed, 110 insertions(+), 65 deletions(-) create mode 100644 prisma/migrations/20250701200000_audit_date/migration.sql diff --git a/CONTEXT.md b/CONTEXT.md index 8d20fe4..f43e25e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -117,7 +117,7 @@ model AuditTemplate { @@unique([version, revision]) } -// Each premises may have multiple audits over time (typically one per year). +// 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. @@ -128,7 +128,7 @@ model Audit { templateId String template AuditTemplate @relation(fields: [templateId], references: [id]) templateRevision Int // pinned at start; updated for drafts on template sync - auditYear Int // compliance year, e.g. 2025 + 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 diff --git a/app/(app)/premises/[id]/audit/page.tsx b/app/(app)/premises/[id]/audit/page.tsx index 3eb11b7..7f67857 100644 --- a/app/(app)/premises/[id]/audit/page.tsx +++ b/app/(app)/premises/[id]/audit/page.tsx @@ -28,7 +28,7 @@ export default async function PremisesAuditPage({ params }: PageProps) { premisesId: premises.id, status: AuditStatus.DRAFT, }, - select: { id: true, auditYear: true }, + select: { id: true, auditDate: true }, }); return ( @@ -43,7 +43,11 @@ export default async function PremisesAuditPage({ params }: PageProps) { premisesName={premises.name} hasProfile={Boolean(premises.profile)} draftAuditId={draft?.id ?? null} - draftAuditYear={draft?.auditYear ?? null} + draftAuditDate={ + draft?.auditDate + ? draft.auditDate.toISOString().slice(0, 10) + : null + } />
diff --git a/app/api/premises/[id]/audit/route.ts b/app/api/premises/[id]/audit/route.ts index ae42c8f..10ddf2b 100644 --- a/app/api/premises/[id]/audit/route.ts +++ b/app/api/premises/[id]/audit/route.ts @@ -4,14 +4,15 @@ import { requireAuthContext } from "@/lib/auth"; import { buildAuditOverview, listPremisesAudits, - startNewAnnualAudit, + startNewAudit, startOrResumeAudit, } from "@/lib/audit"; import { - currentAuditYear, + defaultAuditDate, getActiveAuditTemplateSummary, } 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 } }; @@ -37,15 +38,13 @@ export async function GET(_request: Request, { params }: RouteParams) { premisesId: params.id, status: AuditStatus.DRAFT, }, - select: { id: true, startedAt: true, auditYear: true }, + select: { id: true, startedAt: true, auditDate: true }, }), listPremisesAudits(params.id, auth.organisationId), getActiveAuditTemplateSummary(), ]); - const currentYear = currentAuditYear(); - const canStartNewAudit = - !draft && !audits.some((audit) => audit.auditYear === currentYear); + const canStartNewAudit = !draft; return NextResponse.json({ data: { @@ -53,7 +52,7 @@ export async function GET(_request: Request, { params }: RouteParams) { draftAudit: draft, audits, activeTemplate, - currentAuditYear: currentYear, + defaultAuditDate: defaultAuditDate().toISOString().slice(0, 10), canStartNewAudit, }, error: null, @@ -79,18 +78,25 @@ export async function POST(request: Request, { params }: RouteParams) { : "resume"; if (action === "start") { - const auditYear = + const auditDateInput = body && typeof body === "object" && - typeof (body as { auditYear?: unknown }).auditYear === "number" - ? (body as { auditYear: number }).auditYear - : currentAuditYear(); + typeof (body as { auditDate?: unknown }).auditDate === "string" + ? (body as { auditDate: string }).auditDate + : defaultAuditDate().toISOString().slice(0, 10); - const { audit, created } = await startNewAnnualAudit( + if (!isInputDate(auditDateInput)) { + return NextResponse.json( + { data: null, error: "Invalid audit date" }, + { status: 400 }, + ); + } + + const { audit, created } = await startNewAudit( params.id, auth.organisationId, auth.userId, - auditYear, + parseInputDate(auditDateInput)!, ); return NextResponse.json({ @@ -126,7 +132,6 @@ export async function POST(request: Request, { params }: RouteParams) { ? 404 : message.includes("profile") || message.includes("sections") || - message.includes("already exists") || message.includes("draft audit") ? 400 : 500; diff --git a/components/audit/AuditStartPanel.tsx b/components/audit/AuditStartPanel.tsx index 42f02bd..5f16fe1 100644 --- a/components/audit/AuditStartPanel.tsx +++ b/components/audit/AuditStartPanel.tsx @@ -18,11 +18,11 @@ type AuditStartPanelProps = { premisesName: string; hasProfile: boolean; draftAuditId: string | null; - draftAuditYear?: number | null; + draftAuditDate?: string | null; }; function formatDate(value: string): string { - return new Date(value).toLocaleDateString("en-GB", { + return new Date(`${value}T00:00:00`).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", @@ -34,7 +34,7 @@ export function AuditStartPanel({ premisesName, hasProfile, draftAuditId, - draftAuditYear, + draftAuditDate, }: AuditStartPanelProps) { const router = useRouter(); const [loading, setLoading] = useState(false); @@ -43,7 +43,8 @@ export function AuditStartPanel({ const [activeTemplate, setActiveTemplate] = useState( null, ); - const [currentAuditYear, setCurrentAuditYear] = useState(null); + const [defaultAuditDate, setDefaultAuditDate] = useState(""); + const [auditDate, setAuditDate] = useState(""); const [canStartNewAudit, setCanStartNewAudit] = useState(false); useEffect(() => { @@ -60,9 +61,11 @@ export function AuditStartPanel({ throw new Error(result.error ?? "Failed to load audit history"); } + const nextDefaultDate = result.data.defaultAuditDate ?? ""; setAudits(result.data.audits ?? []); setActiveTemplate(result.data.activeTemplate ?? null); - setCurrentAuditYear(result.data.currentAuditYear ?? null); + setDefaultAuditDate(nextDefaultDate); + setAuditDate(nextDefaultDate); setCanStartNewAudit(Boolean(result.data.canStartNewAudit)); } catch (loadError) { setError( @@ -86,7 +89,7 @@ export function AuditStartPanel({ headers: { "Content-Type": "application/json" }, body: JSON.stringify( action === "start" - ? { action: "start", auditYear: currentAuditYear } + ? { action: "start", auditDate } : { action: "resume" }, ), }); @@ -134,11 +137,11 @@ export function AuditStartPanel({ return (
-

Annual safety audit

+

Safety audit

Work through the Scout Association premises audit section by section. - Each audit is tied to a template version and compliance year. You can - save progress and return later. + Each audit is tied to a template version and audit date. You can save + progress and return later.

{activeTemplate ? ( @@ -153,12 +156,27 @@ export function AuditStartPanel({ {draftAuditId ? (

You have a draft audit in progress - {draftAuditYear ? ` for ${draftAuditYear}` : ""}. -

- ) : canStartNewAudit && currentAuditYear ? ( -

- No audit started for {currentAuditYear} yet. + {draftAuditDate ? ` dated ${formatDate(draftAuditDate)}` : ""}.

+ ) : canStartNewAudit ? ( +
+ + setAuditDate(event.target.value)} + className="mt-1 rounded-lg border border-slate-300 px-3 py-2 text-sm" + /> +

+ Usually the date of the inspection or review. Defaults to today. +

+
) : null} {error ? ( @@ -174,10 +192,11 @@ export function AuditStartPanel({ Continue audit ) : canStartNewAudit ? ( - ) : ( ) : null} From f674fa828b92accb7798adcae7c4e5ce15e868ff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 19:56:36 +0000 Subject: [PATCH 4/6] Fix profile toggle fields not persisting when continuing Toggle fields now update on pointer down so values are set before Continue is pressed. The wizard also auto-saves to the API on each step and keeps a form ref so the latest answers are always persisted. Co-authored-by: jonbur --- components/profile/ProfileWizard.tsx | 44 +++++++++++++++++++++++----- components/ui/form.tsx | 28 ++++++++++++++++-- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/components/profile/ProfileWizard.tsx b/components/profile/ProfileWizard.tsx index 1441eeb..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,19 +99,30 @@ 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() { - if (!canProceed(stepIndex, form)) { + const currentForm = formRef.current; + + if (!canProceed(stepIndex, currentForm)) { setError("Please answer the required questions before continuing."); return; } @@ -112,11 +130,23 @@ export function ProfileWizard({ 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( diff --git a/components/ui/form.tsx b/components/ui/form.tsx index ccd7cef..11dc758 100644 --- a/components/ui/form.tsx +++ b/components/ui/form.tsx @@ -88,8 +88,30 @@ export function ToggleField({ checked, onChange, }: ToggleFieldProps) { + function handleActivate() { + onChange(!checked); + } + return (