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
+
+
+ Templates
+
+ 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 (
+
+
+
+
+
+
+
+
+
+
+ {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)}
+
+
+
+
+
+ handleQuickStatusChange(
+ action,
+ event.target.value as ActionStatus,
+ )
+ }
+ className="rounded-lg border border-slate-300 px-3 py-1.5 text-sm"
+ >
+ {Object.entries(ACTION_STATUS_LABELS).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+ setEditingAction(action)}
+ className="text-sm font-medium text-emerald-700 hover:text-emerald-800"
+ >
+ Edit
+
+ handleDeleteAction(action)}
+ disabled={deletingActionId === action.id}
+ className="text-sm font-medium text-red-600 hover:text-red-700 disabled:opacity-50"
+ >
+ {deletingActionId === action.id ? "Deleting…" : "Delete"}
+
+
+
+
+ ))}
+
+ )}
+
+ {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 (
+
+ {label}
+ {children}
+
+ );
+}
+
+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 ? (
+
+
+ Status
+
+
+ setStatus(event.target.value as ActionStatus)
+ }
+ className="mt-1 w-full max-w-xs rounded-lg border border-slate-300 px-3 py-2 text-sm"
+ >
+ {Object.entries(ACTION_STATUS_LABELS).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+ ) : 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
-
- ) : (
-
- {loading ? "Starting…" : "Start 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 ? (
+
+ {completingAudit ? "Finishing…" : "Finish audit"}
+
+ ) : 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.
+
+
+
+
+
+ Audit template
+
+ setSelectedTemplateId(event.target.value)}
+ className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
+ >
+ {templates.length === 0 ? (
+ No templates available
+ ) : (
+ templates.map((template) => (
+
+ {template.longLabel}
+ {template.isActive ? " · active default" : ""}
+
+ ))
+ )}
+
+
+
+
+
+ Audit date
+
+ setAuditDate(event.target.value)}
+ className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
+ />
+
+
+
+
+ Need a new questionnaire?{" "}
+
+ Upload or manage templates
+
+ .
+
+
+ {error ? (
+
{error}
+ ) : null}
+
+
+ void startAudit()}
+ disabled={loading || !auditDate || !selectedTemplateId || !canStartNewAudit}
+ >
+ {loading ? "Starting…" : "Start audit"}
+
+
+
+ )}
+
+
+
+
Audit history
+
+ Status
+
+ setStatusFilter(event.target.value as typeof statusFilter)
+ }
+ className="rounded-lg border border-slate-300 px-2 py-1 text-sm"
+ >
+ All
+ Draft
+ Completed
+
+
+
+
+ {filteredAudits.length === 0 ? (
+
No audits match your filters.
+ ) : (
+
+
+
+
+ Date
+ Template
+ Status
+ Progress
+
+
+
+
+ {filteredAudits.map((audit) => (
+
+
+ {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 (
+
+ {links.map((link) => {
+ const href = link.href(premisesId);
+ const isActive = pathname === href || pathname.startsWith(`${href}/`);
+
+ return (
+
+ {link.label}
+
+ );
+ })}
+
+ );
+}
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}
void handleNext()}
+ onPointerDown={handleContinuePointerDown}
+ disabled={isSaving}
+ aria-disabled={!canProceed(stepIndex, form) || isSaving}
+ className={
+ !canProceed(stepIndex, form) && !isSaving
+ ? "opacity-50"
+ : undefined
+ }
>
{isSaving
? "Saving…"
diff --git a/components/templates/TemplateManager.tsx b/components/templates/TemplateManager.tsx
new file mode 100644
index 0000000..71552ea
--- /dev/null
+++ b/components/templates/TemplateManager.tsx
@@ -0,0 +1,356 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { Button } from "@/components/ui/form";
+
+type TemplateCatalogItem = {
+ id: string;
+ version: string;
+ revision: number;
+ label: string;
+ longLabel: string;
+ description: string | null;
+ publishedAt: string;
+ isActive: boolean;
+ changeType: string;
+ sectionCount: number;
+ auditCount: number;
+ supersededAt: string | null;
+};
+
+function formatDate(value: string): string {
+ return new Date(value).toLocaleDateString("en-GB", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ });
+}
+
+export function TemplateManager() {
+ const [templates, setTemplates] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [activatingId, setActivatingId] = useState(null);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ const [version, setVersion] = useState("");
+ const [publishedAt, setPublishedAt] = useState(
+ new Date().toISOString().slice(0, 10),
+ );
+ const [description, setDescription] = useState("");
+ const [setActive, setSetActive] = useState(true);
+ const [jsonText, setJsonText] = useState("");
+
+ async function loadTemplates() {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetch("/api/audit-templates");
+ const result = await response.json();
+
+ if (!response.ok) {
+ throw new Error(result.error ?? "Failed to load templates");
+ }
+
+ setTemplates(result.data.templates ?? []);
+ } catch (loadError) {
+ setError(
+ loadError instanceof Error ? loadError.message : "Failed to load templates",
+ );
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ loadTemplates();
+ }, []);
+
+ async function handleFileUpload(event: React.ChangeEvent) {
+ const file = event.target.files?.[0];
+ if (!file) {
+ return;
+ }
+
+ try {
+ const text = await file.text();
+ setJsonText(text);
+
+ const parsed = JSON.parse(text) as Record;
+ if (typeof parsed.version === "string" && !version) {
+ setVersion(parsed.version);
+ }
+ if (typeof parsed.publishedAt === "string") {
+ setPublishedAt(parsed.publishedAt.slice(0, 10));
+ }
+ if (typeof parsed.description === "string" && !description) {
+ setDescription(parsed.description);
+ }
+
+ setSuccess(`Loaded ${file.name}. Review the fields below, then publish.`);
+ setError(null);
+ } catch {
+ setError("Could not read template file. Upload valid JSON.");
+ }
+ }
+
+ async function publishTemplate() {
+ setSaving(true);
+ setError(null);
+ setSuccess(null);
+
+ try {
+ let sections: unknown;
+ const parsed = JSON.parse(jsonText) as Record;
+ sections = parsed.sections ?? parsed;
+
+ const response = await fetch("/api/audit-templates", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ version,
+ publishedAt,
+ description: description || null,
+ sections,
+ setActive,
+ }),
+ });
+ const result = await response.json();
+
+ if (!response.ok) {
+ throw new Error(result.error ?? "Failed to publish template");
+ }
+
+ setSuccess(`Published template ${result.data.template.longLabel}.`);
+ setJsonText("");
+ setVersion("");
+ setDescription("");
+ await loadTemplates();
+ } catch (publishError) {
+ setError(
+ publishError instanceof Error
+ ? publishError.message
+ : "Failed to publish template",
+ );
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function activateTemplate(templateId: string) {
+ setActivatingId(templateId);
+ setError(null);
+ setSuccess(null);
+
+ try {
+ const response = await fetch(
+ `/api/audit-templates/${templateId}/activate`,
+ { method: "POST" },
+ );
+ const result = await response.json();
+
+ if (!response.ok) {
+ throw new Error(result.error ?? "Failed to activate template");
+ }
+
+ setSuccess(`Set ${result.data.template.longLabel} as the active default.`);
+ await loadTemplates();
+ } catch (activateError) {
+ setError(
+ activateError instanceof Error
+ ? activateError.message
+ : "Failed to activate template",
+ );
+ } finally {
+ setActivatingId(null);
+ }
+ }
+
+ return (
+
+
+
+ Upload a new template release
+
+
+ Import a JSON questionnaire as a new template version. Use the normalized
+ sections array format (same structure as the seeded September 2025
+ template).
+
+
+
+
+ {error ? (
+
{error}
+ ) : null}
+ {success ? (
+
{success}
+ ) : null}
+
+
+ void publishTemplate()}
+ disabled={saving || !version || !jsonText.trim()}
+ >
+ {saving ? "Publishing…" : "Publish template release"}
+
+
+
+
+
+
Template catalog
+
+ All published template versions and revisions. The active template is the
+ default when starting an audit without choosing another.
+
+
+ {loading ? (
+
Loading templates…
+ ) : templates.length === 0 ? (
+
No templates published yet.
+ ) : (
+
+
+
+
+ Template
+ Type
+ Published
+ Sections
+ Audits
+
+
+
+
+ {templates.map((template) => (
+
+
+ {template.longLabel}
+ {template.description ? (
+ {template.description}
+ ) : null}
+
+
+ {template.changeType === "PATCH" ? "Patch" : "Release"}
+ {template.isActive ? (
+
+ Active
+
+ ) : null}
+
+
+ {formatDate(template.publishedAt)}
+
+
+ {template.sectionCount}
+
+ {template.auditCount}
+
+ {!template.isActive ? (
+ void activateTemplate(template.id)}
+ disabled={activatingId === template.id}
+ className="text-sm font-medium text-emerald-700 hover:text-emerald-800 disabled:opacity-50"
+ >
+ {activatingId === template.id
+ ? "Activating…"
+ : "Set active"}
+
+ ) : (
+ Default
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ To start an audit against a specific template, go to a premises{" "}
+
+ audits page
+
+ .
+
+
+ );
+}
diff --git a/components/ui/form.tsx b/components/ui/form.tsx
index 0488286..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 (
{
+ if (event.key === " " || event.key === "Enter") {
+ event.preventDefault();
+ handleActivate();
+ }
+ }}
+ onPointerDown={(event) => {
+ if (event.pointerType !== "mouse" || event.button !== 0) {
+ return;
+ }
+
+ // Apply on pointer down so the value is set before Continue is pressed.
+ event.preventDefault();
+ handleActivate();
+ }}
className={`flex cursor-pointer gap-3 rounded-lg border p-4 transition-colors ${
checked
? "border-emerald-600 bg-emerald-50"
@@ -99,8 +121,10 @@ export function ToggleField({
onChange(event.target.checked)}
- className="mt-1 h-4 w-4 shrink-0 rounded accent-emerald-600"
+ readOnly
+ tabIndex={-1}
+ aria-hidden
+ className="pointer-events-none mt-1 h-4 w-4 shrink-0 rounded accent-emerald-600"
/>
{label}
@@ -115,9 +139,12 @@ export function ToggleField({
type ButtonProps = {
children: ReactNode;
onClick?: () => void;
+ onPointerDown?: (event: React.PointerEvent) => void;
type?: "button" | "submit";
variant?: "primary" | "secondary";
disabled?: boolean;
+ className?: string;
+ "aria-disabled"?: boolean;
};
type DateFieldProps = {
@@ -165,9 +192,12 @@ export function DateField({
export function Button({
children,
onClick,
+ onPointerDown,
type = "button",
variant = "primary",
disabled = false,
+ className = "",
+ "aria-disabled": ariaDisabled,
}: ButtonProps) {
const base =
"inline-flex items-center justify-center rounded-lg px-4 py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50";
@@ -180,8 +210,10 @@ export function Button({
{children}
diff --git a/lib/actions.ts b/lib/actions.ts
new file mode 100644
index 0000000..9a88a72
--- /dev/null
+++ b/lib/actions.ts
@@ -0,0 +1,213 @@
+import {
+ ActionStatus,
+ type Action,
+ type ActionSourceType,
+ type ActionStatus as ActionStatusType,
+ type Priority,
+ type Prisma,
+} from "@prisma/client";
+import { getPremisesForOrganisation } from "@/lib/premises";
+import { prisma } from "@/lib/prisma";
+import { isInputDate, parseInputDate, toRecordedDateIso } from "@/lib/dates";
+import type { ActionListFilters, ActionListItem } from "@/types/action";
+
+export type UpdateActionInput = {
+ title: string;
+ description?: string;
+ priority: Priority;
+ dueDate?: string | null;
+ status?: ActionStatusType;
+};
+
+function startOfTodayUtc(): Date {
+ const today = new Date();
+ return new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()));
+}
+
+export function serializeActionListItem(action: Action): ActionListItem {
+ const today = startOfTodayUtc();
+ const isOverdue =
+ action.status !== ActionStatus.RESOLVED &&
+ Boolean(action.dueDate) &&
+ action.dueDate! < today;
+
+ return {
+ id: action.id,
+ title: action.title,
+ description: action.description,
+ priority: action.priority,
+ status: action.status,
+ dueDate: toRecordedDateIso(action.dueDate),
+ sourceType: action.sourceType,
+ sourceItemId: action.sourceItemId,
+ sourceRef: action.sourceRef,
+ createdAt: action.createdAt.toISOString(),
+ isOverdue,
+ };
+}
+
+function buildActionWhere(
+ premisesId: string,
+ filters: ActionListFilters,
+): Prisma.ActionWhereInput {
+ const where: Prisma.ActionWhereInput = { premisesId };
+ const today = startOfTodayUtc();
+
+ if (filters.status !== "ALL") {
+ where.status = filters.status;
+ }
+
+ if (filters.priority !== "ALL") {
+ where.priority = filters.priority;
+ }
+
+ const dueDateFilter: Prisma.DateTimeNullableFilter = {};
+ let dueDateIsNull = false;
+
+ if (filters.due === "overdue") {
+ dueDateFilter.lt = today;
+ if (filters.status === "ALL") {
+ where.status = { not: ActionStatus.RESOLVED };
+ }
+ } else if (filters.due === "upcoming") {
+ dueDateFilter.gte = today;
+ if (filters.status === "ALL") {
+ where.status = { not: ActionStatus.RESOLVED };
+ }
+ } else if (filters.due === "none") {
+ dueDateIsNull = true;
+ where.dueDate = null;
+ }
+
+ if (!dueDateIsNull) {
+ if (filters.dueBefore && isInputDate(filters.dueBefore)) {
+ dueDateFilter.lte = parseInputDate(filters.dueBefore)!;
+ }
+
+ if (filters.dueAfter && isInputDate(filters.dueAfter)) {
+ dueDateFilter.gte = parseInputDate(filters.dueAfter)!;
+ }
+
+ if (Object.keys(dueDateFilter).length > 0) {
+ where.dueDate = dueDateFilter;
+ }
+ }
+
+ return where;
+}
+
+export async function listPremisesActions(
+ premisesId: string,
+ organisationId: string,
+ filters: ActionListFilters,
+): Promise {
+ const premises = await getPremisesForOrganisation(premisesId, organisationId);
+
+ if (!premises) {
+ throw new Error("Premises not found");
+ }
+
+ const actions = await prisma.action.findMany({
+ where: buildActionWhere(premisesId, filters),
+ orderBy: [
+ { status: "asc" },
+ { dueDate: { sort: "asc", nulls: "last" } },
+ { createdAt: "desc" },
+ ],
+ });
+
+ return actions.map(serializeActionListItem);
+}
+
+export async function getActionForOrganisation(
+ actionId: string,
+ organisationId: string,
+): Promise {
+ return prisma.action.findFirst({
+ where: {
+ id: actionId,
+ premises: { organisationId },
+ },
+ });
+}
+
+export async function updateAction(
+ actionId: string,
+ organisationId: string,
+ input: UpdateActionInput,
+): Promise {
+ const existing = await getActionForOrganisation(actionId, organisationId);
+
+ if (!existing) {
+ throw new Error("Action not found");
+ }
+
+ const nextStatus = input.status ?? existing.status;
+ const resolvedAt =
+ nextStatus === ActionStatus.RESOLVED
+ ? existing.resolvedAt ?? new Date()
+ : null;
+
+ return prisma.action.update({
+ where: { id: actionId },
+ data: {
+ title: input.title,
+ description: input.description ?? null,
+ priority: input.priority,
+ dueDate: input.dueDate ? new Date(input.dueDate) : null,
+ status: nextStatus,
+ resolvedAt,
+ },
+ });
+}
+
+export async function deleteAction(
+ actionId: string,
+ organisationId: string,
+): Promise {
+ const existing = await getActionForOrganisation(actionId, organisationId);
+
+ if (!existing) {
+ throw new Error("Action not found");
+ }
+
+ await prisma.action.delete({
+ where: { id: actionId },
+ });
+}
+
+export function parseActionListFilters(
+ searchParams: Record,
+): ActionListFilters {
+ const get = (key: string) => {
+ const value = searchParams[key];
+ return typeof value === "string" ? value : "";
+ };
+
+ return {
+ status: (get("status") || "ALL") as ActionListFilters["status"],
+ priority: (get("priority") || "ALL") as ActionListFilters["priority"],
+ due: (get("due") || "all") as ActionListFilters["due"],
+ dueBefore: get("dueBefore"),
+ dueAfter: get("dueAfter"),
+ };
+}
+
+export function buildActionSourceLabel(
+ sourceType: ActionSourceType | null,
+): string {
+ if (!sourceType) {
+ return "Unknown";
+ }
+
+ switch (sourceType) {
+ case "AUDIT":
+ return "Audit";
+ case "RA":
+ return "Risk assessment";
+ case "MANUAL":
+ return "Manual";
+ default:
+ return sourceType;
+ }
+}
diff --git a/lib/audit-template-import.ts b/lib/audit-template-import.ts
new file mode 100644
index 0000000..7947849
--- /dev/null
+++ b/lib/audit-template-import.ts
@@ -0,0 +1,223 @@
+import type {
+ AuditTemplateItem,
+ AuditTemplateSection,
+ AuditTemplateSections,
+ AnswerableResponseType,
+} from "@/types/audit-template";
+
+type RawSubQuestion = {
+ id: string;
+ question: string;
+ responseType: AnswerableResponseType;
+ requiresDocument: boolean;
+ documentType?: string;
+ profileFlag?: string;
+};
+
+type RawItem = {
+ id: string;
+ question?: string;
+ label?: string;
+ guidance?: string | null;
+ responseType: AuditTemplateItem["responseType"];
+ requiresDocument: boolean;
+ profileFlag?: string;
+ documentType?: string;
+ subQuestions?: readonly RawSubQuestion[];
+};
+
+type RawSubsection = {
+ id: string;
+ title: string;
+ items: readonly RawItem[];
+};
+
+type RawSection = {
+ id: string;
+ number: number;
+ title: string;
+ scope?: "ALL" | "EXTENDED" | "all" | "extended";
+ profileFlag?: string;
+ items?: readonly RawItem[];
+ subsections?: readonly RawSubsection[];
+};
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
+
+function normalizeSubQuestion(item: RawSubQuestion) {
+ return {
+ id: item.id,
+ question: item.question,
+ responseType: item.responseType,
+ requiresDocument: item.requiresDocument,
+ documentType: item.documentType,
+ profileFlag: item.profileFlag,
+ };
+}
+
+function normalizeItem(item: RawItem): AuditTemplateItem {
+ if (item.responseType === "GROUP") {
+ return {
+ id: item.id,
+ label: item.label ?? item.question ?? "",
+ guidance: item.guidance ?? "",
+ responseType: "GROUP",
+ requiresDocument: false,
+ profileFlag: item.profileFlag,
+ subQuestions: (item.subQuestions ?? []).map(normalizeSubQuestion),
+ };
+ }
+
+ return {
+ id: item.id,
+ question: item.question ?? "",
+ guidance: item.guidance ?? "",
+ responseType: item.responseType,
+ requiresDocument: item.requiresDocument,
+ documentType: item.documentType,
+ profileFlag: item.profileFlag,
+ };
+}
+
+function normalizeSection(section: RawSection): AuditTemplateSection {
+ const items = section.subsections
+ ? section.subsections.flatMap((subsection) => subsection.items)
+ : (section.items ?? []);
+
+ const scope =
+ section.scope === "EXTENDED" || section.scope === "extended"
+ ? "extended"
+ : "all";
+
+ return {
+ id: section.id,
+ number: section.number,
+ title: section.title,
+ scope,
+ profileFlag: section.profileFlag,
+ items: items.map(normalizeItem),
+ };
+}
+
+export function normalizeUploadedSections(input: unknown): AuditTemplateSections {
+ if (!Array.isArray(input)) {
+ throw new Error("Template sections must be a JSON array");
+ }
+
+ return (input as RawSection[]).map(normalizeSection);
+}
+
+export function validateTemplateSections(
+ sections: AuditTemplateSections,
+): string | null {
+ if (sections.length === 0) {
+ return "Template must include at least one section";
+ }
+
+ const sectionIds = new Set();
+ const itemIds = new Set();
+
+ for (const section of sections) {
+ if (!section.id?.trim() || !section.title?.trim()) {
+ return "Each section needs an id and title";
+ }
+
+ if (sectionIds.has(section.id)) {
+ return `Duplicate section id: ${section.id}`;
+ }
+ sectionIds.add(section.id);
+
+ if (!Number.isFinite(section.number)) {
+ return `Section ${section.id} needs a numeric order`;
+ }
+
+ if (!section.items?.length) {
+ return `Section ${section.id} must include at least one item`;
+ }
+
+ for (const item of section.items) {
+ if (item.responseType === "GROUP") {
+ if (!item.id || !item.label) {
+ return "Group items need an id and label";
+ }
+ if (itemIds.has(item.id)) {
+ return `Duplicate item id: ${item.id}`;
+ }
+ itemIds.add(item.id);
+
+ if (!item.subQuestions.length) {
+ return `Group ${item.id} must include sub-questions`;
+ }
+
+ for (const subQuestion of item.subQuestions) {
+ if (!subQuestion.id || !subQuestion.question) {
+ return `Sub-questions in ${item.id} need an id and question`;
+ }
+ if (itemIds.has(subQuestion.id)) {
+ return `Duplicate item id: ${subQuestion.id}`;
+ }
+ itemIds.add(subQuestion.id);
+ }
+ } else {
+ if (!item.id || !item.question) {
+ return "Questions need an id and question text";
+ }
+ if (itemIds.has(item.id)) {
+ return `Duplicate item id: ${item.id}`;
+ }
+ itemIds.add(item.id);
+ }
+ }
+ }
+
+ return null;
+}
+
+export function parseUploadedTemplatePayload(body: unknown): {
+ version: string;
+ publishedAt: Date;
+ description: string | null;
+ sections: AuditTemplateSections;
+ setActive: boolean;
+} {
+ if (!isRecord(body)) {
+ throw new Error("Invalid request body");
+ }
+
+ const version = typeof body.version === "string" ? body.version.trim() : "";
+ if (!version) {
+ throw new Error("Template version is required (e.g. 2026-09)");
+ }
+
+ const publishedAtInput =
+ typeof body.publishedAt === "string" ? body.publishedAt : "";
+ const publishedAt = publishedAtInput
+ ? new Date(publishedAtInput)
+ : new Date();
+
+ if (Number.isNaN(publishedAt.getTime())) {
+ throw new Error("Invalid published date");
+ }
+
+ const sections = normalizeUploadedSections(body.sections);
+
+ const validationError = validateTemplateSections(sections);
+ if (validationError) {
+ throw new Error(validationError);
+ }
+
+ return {
+ version,
+ publishedAt,
+ description:
+ typeof body.description === "string" ? body.description.trim() : null,
+ sections,
+ setActive: body.setActive !== false,
+ };
+}
+
+export function countTemplateSections(sections: AuditTemplateSections): number {
+ return sections.length;
+}
diff --git a/lib/audit-templates.ts b/lib/audit-templates.ts
new file mode 100644
index 0000000..9350a43
--- /dev/null
+++ b/lib/audit-templates.ts
@@ -0,0 +1,318 @@
+import type { Audit, AuditTemplate, AuditTemplateChangeType } from "@prisma/client";
+import { prisma } from "@/lib/prisma";
+import { validateTemplateSections } from "@/lib/audit-template-import";
+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 type AuditTemplateCatalogItem = AuditTemplateSummary & {
+ sectionCount: number;
+ auditCount: number;
+ supersededAt: string | null;
+};
+
+function countSectionsInTemplate(sections: unknown): number {
+ return Array.isArray(sections) ? sections.length : 0;
+}
+
+export async function listAuditTemplateCatalog(): Promise {
+ const templates = await prisma.auditTemplate.findMany({
+ orderBy: [{ version: "desc" }, { revision: "desc" }],
+ include: {
+ _count: { select: { audits: true } },
+ },
+ });
+
+ return templates.map((template) => ({
+ ...toTemplateSummary(template),
+ sectionCount: countSectionsInTemplate(template.sections),
+ auditCount: template._count.audits,
+ supersededAt: template.supersededAt?.toISOString() ?? null,
+ }));
+}
+
+export async function listSelectableAuditTemplates(): Promise {
+ const templates = await prisma.auditTemplate.findMany({
+ orderBy: [{ version: "desc" }, { revision: "desc" }],
+ });
+
+ const latestByVersion = new Map();
+
+ for (const template of templates) {
+ if (!latestByVersion.has(template.version)) {
+ latestByVersion.set(template.version, template);
+ }
+ }
+
+ return Array.from(latestByVersion.values()).map(toTemplateSummary);
+}
+
+export async function getAuditTemplateById(
+ templateId: string,
+): Promise {
+ return prisma.auditTemplate.findUnique({
+ where: { id: templateId },
+ });
+}
+
+export async function activateAuditTemplate(
+ templateId: string,
+): Promise {
+ const template = await prisma.auditTemplate.findUnique({
+ where: { id: templateId },
+ });
+
+ if (!template) {
+ throw new Error("Template not found");
+ }
+
+ const updated = await prisma.$transaction(async (tx) => {
+ await tx.auditTemplate.updateMany({
+ where: { isActive: true },
+ data: {
+ isActive: false,
+ supersededAt: new Date(),
+ },
+ });
+
+ return tx.auditTemplate.update({
+ where: { id: templateId },
+ data: {
+ isActive: true,
+ supersededAt: null,
+ },
+ });
+ });
+
+ return toTemplateSummary(updated);
+}
+
+export async function importAuditTemplateRelease(input: {
+ version: string;
+ publishedAt: Date;
+ sections: AuditTemplateSections;
+ description?: string | null;
+ setActive?: boolean;
+}): Promise {
+ const validationError = validateTemplateSections(input.sections);
+ if (validationError) {
+ throw new Error(validationError);
+ }
+
+ const existing = await getLatestRevisionForVersion(input.version);
+ if (existing) {
+ throw new Error(
+ `Template version ${input.version} already exists. Publish a patch instead.`,
+ );
+ }
+
+ const template = input.setActive === false
+ ? await prisma.auditTemplate.create({
+ data: {
+ version: input.version,
+ revision: 1,
+ changeType: "RELEASE",
+ description: input.description ?? null,
+ publishedAt: input.publishedAt,
+ sections: input.sections,
+ isActive: false,
+ },
+ })
+ : (
+ await publishTemplateRelease({
+ version: input.version,
+ publishedAt: input.publishedAt,
+ sections: input.sections,
+ description: input.description ?? undefined,
+ })
+ );
+
+ return toTemplateSummary(template);
+}
+
+export function defaultAuditDate(date = new Date()): Date {
+ return new Date(
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
+ );
+}
diff --git a/lib/audit.ts b/lib/audit.ts
index c8ea3da..d571e08 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,14 @@ import {
} from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { getPremisesForOrganisation } from "@/lib/premises";
+import {
+ collectAnswerableItemIds,
+ defaultAuditDate,
+ getActiveAuditTemplate,
+ getAuditTemplateById,
+ getAuditTemplateSections,
+ parseTemplateSections,
+} from "@/lib/audit-templates";
import {
filterApplicableItems,
findAnswerableItemInAudit,
@@ -33,7 +42,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 +55,32 @@ export type AuditWithRelations = Audit & {
};
};
-export function parseTemplateSections(sections: unknown): AuditTemplateSections {
- return sections as AuditTemplateSections;
-}
+export type AuditListItem = {
+ id: string;
+ auditDate: string;
+ 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 +114,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 +162,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 +204,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 +231,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 +279,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: [{ auditDate: "desc" }, { startedAt: "desc" }],
+ });
+
+ return audits.map((audit) => {
+ const overview = buildAuditOverview(audit as AuditWithRelations);
+ return {
+ id: audit.id,
+ auditDate: toRecordedDateIso(audit.auditDate)!,
+ 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 +338,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 +350,74 @@ export async function startOrResumeAudit(
};
}
- const template = await prisma.auditTemplate.findFirst({
- where: { isActive: true },
- orderBy: { publishedAt: "desc" },
+ return startNewAudit(premisesId, organisationId, userId);
+}
+
+export async function startNewAudit(
+ premisesId: string,
+ organisationId: string,
+ userId: string,
+ options: {
+ auditDate?: Date;
+ templateId?: string;
+ } = {},
+): Promise<{ audit: AuditWithRelations; created: boolean }> {
+ const auditDate = options.auditDate ?? defaultAuditDate();
+ 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 template = options.templateId
+ ? await getAuditTemplateById(options.templateId)
+ : await getActiveAuditTemplate();
+
if (!template) {
- throw new Error("No active audit template found");
+ throw new Error(
+ options.templateId ? "Selected audit template not found" : "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,
+ auditDate,
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,
+ auditDate: toRecordedDateIso(audit.auditDate)!,
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/migrations/20250701200000_audit_date/migration.sql b/prisma/migrations/20250701200000_audit_date/migration.sql
new file mode 100644
index 0000000..9e459d3
--- /dev/null
+++ b/prisma/migrations/20250701200000_audit_date/migration.sql
@@ -0,0 +1,16 @@
+-- Replace auditYear with auditDate (no one-audit-per-year constraint).
+
+ALTER TABLE "Audit" ADD COLUMN "auditDate" DATE;
+
+UPDATE "Audit"
+SET "auditDate" = "startedAt"::date
+WHERE "auditDate" IS NULL;
+
+ALTER TABLE "Audit"
+ ALTER COLUMN "auditDate" SET NOT NULL;
+
+DROP INDEX IF EXISTS "Audit_premisesId_auditYear_idx";
+
+ALTER TABLE "Audit" DROP COLUMN "auditYear";
+
+CREATE INDEX "Audit_premisesId_auditDate_idx" ON "Audit"("premisesId", "auditDate");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 6cd59e1..aaa6a46 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
+ auditDate DateTime @db.Date
+ sectionsSnapshot Json?
+ status AuditStatus
+ startedBy String
+ startedAt DateTime @default(now())
+ completedAt DateTime?
+ responses AuditResponse[]
+ sections AuditSection[]
@@index([premisesId])
+ @@index([premisesId, auditDate])
@@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/action.ts b/types/action.ts
new file mode 100644
index 0000000..69fdc30
--- /dev/null
+++ b/types/action.ts
@@ -0,0 +1,49 @@
+import type { ActionSourceType, ActionStatus, Priority } from "@prisma/client";
+
+export type ActionListItem = {
+ id: string;
+ title: string;
+ description: string | null;
+ priority: Priority;
+ status: ActionStatus;
+ dueDate: string | null;
+ sourceType: ActionSourceType | null;
+ sourceItemId: string | null;
+ sourceRef: string | null;
+ createdAt: string;
+ isOverdue: boolean;
+};
+
+export type ActionListFilters = {
+ status: ActionStatus | "ALL";
+ priority: Priority | "ALL";
+ due: "all" | "overdue" | "upcoming" | "none";
+ dueBefore: string;
+ dueAfter: string;
+};
+
+export const DEFAULT_ACTION_FILTERS: ActionListFilters = {
+ status: "ALL",
+ priority: "ALL",
+ due: "all",
+ dueBefore: "",
+ dueAfter: "",
+};
+
+export const ACTION_STATUS_LABELS: Record = {
+ OPEN: "Open",
+ IN_PROGRESS: "In progress",
+ RESOLVED: "Resolved",
+};
+
+export const ACTION_PRIORITY_LABELS: Record = {
+ HIGH: "High",
+ MEDIUM: "Medium",
+ LOW: "Low",
+};
+
+export const ACTION_SOURCE_LABELS: Record = {
+ AUDIT: "Audit",
+ RA: "Risk assessment",
+ MANUAL: "Manual",
+};
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..8dad681 100644
--- a/types/audit.ts
+++ b/types/audit.ts
@@ -22,7 +22,10 @@ export type AuditSectionSummary = {
export type AuditOverview = {
id: string;
status: string;
+ auditDate: string;
templateVersion: string;
+ templateRevision: number;
+ templateLabel: string;
premises: {
id: string;
name: string;
@@ -35,6 +38,19 @@ export type AuditOverview = {
};
};
+export type AuditHistoryItem = {
+ id: string;
+ auditDate: string;
+ status: string;
+ templateLabel: string;
+ startedAt: string;
+ completedAt: string | null;
+ progress: {
+ completedSections: number;
+ totalSections: number;
+ };
+};
+
export type AuditResponseRecord = {
id: string;
response: ResponseValue;