Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Link from "next/link";

export default function AppLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className="min-h-screen bg-slate-50">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4 sm:px-6">
<Link href="/" className="text-lg font-semibold text-slate-900">
ScoutBase
</Link>
<span className="text-sm text-slate-500">Premises compliance</span>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-8 sm:px-6">{children}</main>
</div>
);
}
72 changes: 72 additions & 0 deletions app/(app)/premises/[id]/audit/[auditId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { AuditWizard } from "@/components/audit/AuditWizard";
import { requireAuthContext } from "@/lib/auth";
import { buildAuditOverview, getAuditForOrganisation } from "@/lib/audit";

type PageProps = {
params: { id: string; auditId: string };
searchParams: { section?: string };
};

export default async function AuditWizardPage({
params,
searchParams,
}: PageProps) {
let auth;
try {
auth = await requireAuthContext();
} catch {
redirect("/");
}

const audit = await getAuditForOrganisation(params.auditId, auth.organisationId);

if (!audit || audit.premises.id !== params.id) {
notFound();
}

const overview = buildAuditOverview(audit);
const initialSectionId =
searchParams.section &&
overview.sections.some((section) => section.id === searchParams.section)
? searchParams.section
: overview.sections[0]?.id;

if (!initialSectionId) {
return (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-6">
<h2 className="text-lg font-semibold text-amber-950">
This audit needs to be restarted
</h2>
<p className="mt-2 text-sm text-amber-900">
No audit sections apply to the current premises profile. Update your
profile or start a new audit.
</p>
<div className="mt-4 flex flex-wrap gap-3">
<Link
href={`/premises/${params.id}/profile`}
className="inline-flex rounded-lg bg-amber-800 px-4 py-2 text-sm font-medium text-white hover:bg-amber-900"
>
Review premises profile
</Link>
<Link
href={`/premises/${params.id}/audit`}
className="inline-flex rounded-lg border border-amber-300 px-4 py-2 text-sm font-medium text-amber-950 hover:bg-amber-100"
>
Back to audit
</Link>
</div>
</div>
);
}

return (
<AuditWizard
premisesId={params.id}
auditId={params.auditId}
initialOverview={overview}
initialSectionId={initialSectionId}
/>
);
}
50 changes: 50 additions & 0 deletions app/(app)/premises/[id]/audit/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { notFound, redirect } from "next/navigation";
import { AuditStatus } from "@prisma/client";
import { AuditStartPanel } from "@/components/audit/AuditStartPanel";
import { requireAuthContext } from "@/lib/auth";
import { getPremisesForOrganisation } from "@/lib/premises";
import { prisma } from "@/lib/prisma";

type PageProps = {
params: { id: string };
};

export default async function PremisesAuditPage({ params }: PageProps) {
let auth;
try {
auth = await requireAuthContext();
} catch {
redirect("/");
}

const premises = await getPremisesForOrganisation(params.id, auth.organisationId);

if (!premises) {
notFound();
}

const draft = await prisma.audit.findFirst({
where: {
premisesId: premises.id,
status: AuditStatus.DRAFT,
},
select: { id: true },
});

return (
<div>
<p className="text-sm font-medium text-emerald-700">Annual safety audit</p>
<h1 className="mt-1 text-2xl font-semibold text-slate-900">{premises.name}</h1>
<p className="mt-1 text-slate-600">{premises.address}</p>

<div className="mt-8">
<AuditStartPanel
premisesId={premises.id}
premisesName={premises.name}
hasProfile={Boolean(premises.profile)}
draftAuditId={draft?.id ?? null}
/>
</div>
</div>
);
}
87 changes: 87 additions & 0 deletions app/(app)/premises/[id]/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { requireAuthContext } from "@/lib/auth";
import { getPremisesForOrganisation } from "@/lib/premises";
import { EMPTY_PROFILE_FORM } from "@/lib/profile-labels";
import { ProfileWizard } from "@/components/profile/ProfileWizard";
import type { ProfileFormData } from "@/lib/profile-labels";
import type { BuildingAgeBand, FloodRiskZone, OwnershipType } from "@prisma/client";

type PageProps = {
params: { id: string };
};

function toFormData(profile: {
ownershipType: OwnershipType;
buildingAgeBand: BuildingAgeBand;
hasGas: boolean;
floodRiskZone: FloodRiskZone | null;
hasCateringKitchen: boolean;
hasGrounds: boolean;
hasSleeping: boolean;
hasVehicles: boolean;
hasPlantMachinery: boolean;
hasThirdPartyUsers: boolean;
}): ProfileFormData {
return {
ownershipType: profile.ownershipType,
buildingAgeBand: profile.buildingAgeBand,
hasGas: profile.hasGas,
floodRiskZone: profile.floodRiskZone,
hasCateringKitchen: profile.hasCateringKitchen,
hasGrounds: profile.hasGrounds,
hasSleeping: profile.hasSleeping,
hasVehicles: profile.hasVehicles,
hasPlantMachinery: profile.hasPlantMachinery,
hasThirdPartyUsers: profile.hasThirdPartyUsers,
};
}

export default async function PremisesProfilePage({ params }: PageProps) {
let auth;
try {
auth = await requireAuthContext();
} catch {
redirect("/");
}

const premises = await getPremisesForOrganisation(params.id, auth.organisationId);

if (!premises) {
notFound();
}

const initialProfile = premises.profile
? toFormData(premises.profile)
: EMPTY_PROFILE_FORM;

const initialApplicableSections = premises.profile?.applicableSections ?? [];

return (
<div>
<p className="text-sm font-medium text-emerald-700">Premises profile</p>
<h1 className="mt-1 text-2xl font-semibold text-slate-900">{premises.name}</h1>
<p className="mt-1 text-slate-600">{premises.address}</p>
<p className="mt-4 text-sm text-slate-600">
Answer a few questions about your premises. We&apos;ll tailor the annual
safety audit to what applies to your building — usually about five
minutes.
</p>
{premises.profile ? (
<Link
href={`/premises/${premises.id}/audit`}
className="mt-4 inline-flex text-sm font-medium text-emerald-700 hover:text-emerald-800"
>
Go to annual audit →
</Link>
) : null}
<div className="mt-8">
<ProfileWizard
premisesId={premises.id}
initialProfile={initialProfile}
initialApplicableSections={initialApplicableSections}
/>
</div>
</div>
);
}
108 changes: 108 additions & 0 deletions app/api/actions/[actionId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { NextResponse } from "next/server";
import { Priority } from "@prisma/client";
import { requireAuthContext } from "@/lib/auth";
import { serializeLinkedAction, updateAuditAction, deleteAuditAction } from "@/lib/audit";
import { prisma } from "@/lib/prisma";

type RouteParams = { params: { actionId: string } };

const VALID_PRIORITIES = new Set<string>(Object.values(Priority));

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 },
},
});

if (!action) {
return NextResponse.json(
{ data: null, error: "Action not found" },
{ status: 404 },
);
}

return NextResponse.json({
data: serializeLinkedAction(action),
error: null,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load action";
const status = message === "Unauthorized" ? 401 : 500;

return NextResponse.json({ data: null, error: message }, { status });
}
}

export async function PATCH(request: Request, { params }: RouteParams) {
try {
const auth = await requireAuthContext();
const body = await request.json();

const title = body?.title as string | undefined;
const description = body?.description as string | undefined;
const priority = body?.priority as string | undefined;
const dueDate = body?.dueDate as string | null | undefined;

if (!title || !priority) {
return NextResponse.json(
{ data: null, error: "title and priority are required" },
{ status: 400 },
);
}

if (!VALID_PRIORITIES.has(priority)) {
return NextResponse.json(
{ data: null, error: "Invalid priority value" },
{ status: 400 },
);
}

const action = await updateAuditAction(params.actionId, auth.organisationId, {
title,
description,
priority: priority as Priority,
dueDate,
});

return NextResponse.json({
data: serializeLinkedAction(action),
error: null,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to update action";
const status =
message === "Unauthorized"
? 401
: message === "Action not found"
? 404
: 500;

return NextResponse.json({ data: null, error: message }, { status });
}
}

export async function DELETE(_request: Request, { params }: RouteParams) {
try {
const auth = await requireAuthContext();
await deleteAuditAction(params.actionId, auth.organisationId);

return NextResponse.json({ data: { deleted: true }, error: null });
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to delete action";
const status =
message === "Unauthorized"
? 401
: message === "Action not found"
? 404
: 500;

return NextResponse.json({ data: null, error: message }, { status });
}
}
Loading