assessment-model/src/app/projects/page.tsx
Daniel Roth a7a1f8ee98 feat(ara-projects): projects list, empty state and create-project modal
Setup wizard step 1/5 (#409).

The list is a Server Component, so the visibility rule runs before anything
reaches the browser and a project the user may not see is never serialised
to it. The rule itself is not restated: `listVisibleProjects` loads each
project's facts and asks `canViewProject` from `@/lib/projects/authz`.
Writing the `domna_admin_access OR owning-org OR contractor-org`
disjunction a second time in SQL would have given it two definitions free
to drift, and the acceptance criteria (client sees its own, contractor sees
what it is assigned to, internal sees all subject to `domna_admin_access`)
fall out of the one definition instead of being re-derived.

The cost is filtering in the app rather than the database, over two round
trips and no N+1. That is the right trade while a project is a works
programme and they number in the tens; if the table grows, the fix is a
superset prefilter that still lets `canViewProject` decide, not a WHERE
clause that re-implements it. Flagged in the function's own docs.

Creation reuses the same guard rather than inventing a "can create" rule:
`prospectiveProjectFacts` builds the facts the project *would* have, and
`canManageProject` judges them. One consequence is worth knowing, and is
covered by a test — `domna_admin_access` gates the `internal` role, so a
Domna user creating for an organisation they are not a member of must grant
that access. Without it they would be creating a project they could not
then see, and the guard refuses rather than producing an orphan. The modal
offers only organisations the user may pick, but the route handler re-checks
the submitted one: a hidden option is not a permission.

`createProjectSchema` is shared by the form (as a zodResolver) and the route
handler (as the body parser), so the two cannot disagree about what a valid
draft is. Empty strings from untouched inputs normalise to undefined rather
than reaching the columns as "".

Also updates projects-shell.cy.js. Its "renders the per-project dashboard
shell" test passed only because the authz stub could never return false;
with the real lib wired in, /projects/<id> is a 404 without standing on
that project, so the test now asserts that instead.

TESTS: the vitest unit tests pass (50, no database connection). Cypress was
NOT run — this environment's DATABASE_URL points at production. The new
spec is therefore unverified, and its end-to-end half, which INSERTs, is
gated behind CYPRESS_ARA_PROJECTS_E2E_WRITES so it cannot fire by accident;
it also needs the #407 seed migration (0274) applied, which has not been run
against any database yet either.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:52:53 +00:00

194 lines
7 KiB
TypeScript

import Link from "next/link";
import { FolderOpenIcon } from "@heroicons/react/24/outline";
import { requireProjectsUser } from "./guards";
import { CreateProjectDialog } from "./components/CreateProjectDialog";
import {
listOrganisationOptions,
listProjectTypes,
listVisibleProjects,
} from "@/app/repositories/projects/projectsRepository";
import type { ProjectListItem } from "@/lib/projects/createProject";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/app/shadcn_components/ui/card";
export const metadata = {
title: "Projects | Ara",
};
/** The wizard's shape, shown on the empty state so setup is not a surprise. */
const SETUP_STEPS = [
{
title: "Project details",
body: "Name the project, choose the client organisation and the type of works.",
},
{
title: "Workstreams and stages",
body: "Pick the categories of work and the stage ladder each one follows.",
},
{
title: "Documents and permissions",
body: "Say what evidence each workstream needs, and who may upload it.",
},
{
title: "Import and validate work orders",
body: "Bring in the work orders and check them before they go live.",
},
];
function formatDateRange(project: ProjectListItem): string | null {
const format = (value: string) =>
new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
}).format(new Date(`${value}T00:00:00Z`));
if (project.startDate && project.endDate) {
return `${format(project.startDate)}${format(project.endDate)}`;
}
if (project.startDate) return `From ${format(project.startDate)}`;
if (project.endDate) return `Until ${format(project.endDate)}`;
return null;
}
/**
* Projects list — the entry point to Ara Projects (issue #409).
*
* A Server Component: the visibility rule is enforced before anything reaches
* the browser, so a project the user may not see is never serialised to it.
* Only the create-project modal is client-side, and it is handed its select
* options as props rather than fetching them — the server already knows which
* organisations this user may choose.
*/
export default async function ProjectsListPage() {
const user = await requireProjectsUser();
const [projects, organisations, projectTypes] = await Promise.all([
listVisibleProjects(user),
listOrganisationOptions(user),
listProjectTypes(),
]);
// A user with no organisation to create *for*, or an instance whose
// reference data has not been seeded, cannot complete the form — so the CTA
// is not offered. (The route handler refuses these cases regardless; this
// only avoids presenting a dead end.)
const canCreate = organisations.length > 0 && projectTypes.length > 0;
return (
<div className="max-w-4xl mx-auto px-6 py-10">
<div className="mb-8 flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
Ara Projects
</p>
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight">
Projects
</h1>
<p className="text-sm text-gray-500 mt-1">
Works programmes owned by your organisation.
</p>
</div>
{projects.length > 0 && canCreate && (
<CreateProjectDialog
organisations={organisations}
projectTypes={projectTypes}
/>
)}
</div>
{projects.length === 0 ? (
<div data-testid="projects-list-empty">
<Card className="text-center py-12">
<CardHeader>
<div className="mx-auto w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-2">
<FolderOpenIcon className="h-7 w-7 text-gray-400" />
</div>
<CardTitle className="text-2xl">Welcome to Ara Projects</CardTitle>
<CardDescription className="max-w-md mx-auto">
You don&apos;t have any active projects yet. Start by creating a
project to organise your workstreams, track progress and manage
contractors.
</CardDescription>
</CardHeader>
{canCreate && (
<CardContent className="flex justify-center">
<CreateProjectDialog
organisations={organisations}
projectTypes={projectTypes}
/>
</CardContent>
)}
</Card>
<div className="mt-10">
<h2 className="text-sm font-semibold text-gray-700 mb-4">
How setup works
</h2>
<ol className="grid gap-3 sm:grid-cols-2">
{SETUP_STEPS.map((step, index) => (
<li
key={step.title}
className="flex gap-3 rounded-xl border border-gray-200 p-4"
>
<span className="shrink-0 w-6 h-6 rounded-full bg-gray-100 text-gray-600 text-xs font-semibold flex items-center justify-center">
{index + 1}
</span>
<div>
<p className="text-sm font-semibold text-gray-800">
{step.title}
</p>
<p className="text-sm text-gray-500 mt-0.5">{step.body}</p>
</div>
</li>
))}
</ol>
</div>
</div>
) : (
<ul className="space-y-3" data-testid="projects-list">
{projects.map((project) => {
const dates = formatDateRange(project);
return (
<li key={project.id}>
<Link
href={`/projects/${project.id}`}
className="block rounded-xl border border-gray-200 p-5 hover:border-gray-300 hover:bg-gray-50 transition-colors"
data-testid="projects-list-item"
>
<div className="flex items-baseline justify-between gap-4">
<h2 className="text-base font-semibold text-gray-900">
{project.name}
</h2>
<span className="shrink-0 text-xs font-medium text-gray-500 bg-gray-100 rounded-full px-2.5 py-1">
{project.projectTypeName}
</span>
</div>
{project.organisationName && (
<p className="text-sm text-gray-500 mt-1">
{project.organisationName}
</p>
)}
{project.description && (
<p className="text-sm text-gray-600 mt-2 line-clamp-2">
{project.description}
</p>
)}
{dates && (
<p className="text-xs text-gray-400 mt-3">{dates}</p>
)}
</Link>
</li>
);
})}
</ul>
)}
</div>
);
}