feat(ara-projects): edit and show permissions per workstream (#413)

The switch was the wrong control. project_workstream_contractor stores both
flags per (workstream, organisation) pair, so one switch stood for n booleans:
it could report a per-workstream split but never author one, and clicking it
destroyed the split to stay decidable. The schema's central capability was
unreachable from the only screen that configures it.

Add/edit dialog is now a grid: one row per workstream the *project* covers —
not only the ones this contractor holds — with a tick to assign and a tick per
permission. Unassigned rows stay visible and greyed, so the shape of the
project and its uncovered workstreams are part of the decision. Un-ticking a
workstream clears its permissions, so re-ticking never silently restores a
grant. Column headers carry an all/none shortcut.

The main table no longer shows permissions at all: contractor, workstreams,
Actions. Clicking a row (or its disclosure button, which carries
aria-expanded/aria-controls for keyboards and screen readers) opens a sub-row
listing each assignment with its own two permissions, read-only. Several may
be open at once. Actions stay per contractor — Edit and Remove act on the
whole set.

Wire shape follows: POST/PATCH take assignments[{projectWorkstreamId,
updateStagesPermission, uploadDocumentsPermission}]. A workstream named twice
is a 400 — two answers to one question. ContractorGroup drops its
per-contractor summary and permissionState is gone; nothing needs the flags
reduced to one answer, and reducing them is how a contractor allowed on one
workstream comes to look allowed everywhere. Updates are grouped by flag pair,
so twenty workstreams still cost at most four UPDATEs.

ADR-0022 records this; ADR-0021's decision 1 is marked superseded, its other
two (removal never cascades; Continue leads to the import) stand.
This commit is contained in:
Daniel Roth 2026-07-23 15:34:06 +00:00
parent 4bec5b72d2
commit 6d662e81bd
14 changed files with 830 additions and 706 deletions

View file

@ -4,7 +4,12 @@ Date: 2026-07-23
## Status
Accepted
**Decision 1 superseded by [ADR-0022](./0022-contractor-permissions-are-edited-and-shown-per-workstream.md)**
(2026-07-23): permissions are now edited per workstream in a grid and shown per
workstream in an expandable sub-row, so no per-contractor `mixed` state exists
any more. Decisions 2 (removal never cascades through work orders) and 3
(Continue leads to the import) stand, and the Context below still explains why
the unit of a permission is the assignment.
Numbering note: two ADRs carry 0019 (the UK date format one, and the
work-order-import one from #414's branch). 0021 follows 0020.

View file

@ -0,0 +1,88 @@
# 22. Contractor permissions are edited and shown per workstream
Date: 2026-07-23
## Status
Accepted. Supersedes decision 1 of
[ADR-0021](./0021-contractor-permissions-are-per-assignment-shown-per-contractor.md);
its other two decisions stand.
## Context
ADR-0021 kept the wireframe's shape: one table row per contractor organisation,
with an "Update stages" and an "Upload docs" **switch** on that row. Because
`project_workstream_contractor` stores those flags per (workstream,
organisation) pair, one switch stood for *n* booleans, and a contractor whose
assignments disagreed was reported as `mixed`.
Building it exposed the flaw. A switch is a control that asserts one state and
sets one state; it cannot honestly represent *n*, so:
- the `mixed` state needed a chip, a tooltip and a bespoke accessible name
before it was even comprehensible — a lot of explanation for a control that
was still lying about its own shape;
- the only write it could express was "apply one pair of flags to everything",
so the UI could **read** a per-workstream split but never **author** one. The
schema's central capability was unreachable from the only screen that
configures it, and ADR-0021 had to note that no v1 surface produced divergent
flags;
- clicking a `mixed` switch destroyed information the user might not have
looked at, and "resolve upwards" was a rule invented to make an unsuitable
control decidable.
Meanwhile the actual question — *may this contractor move Windows along, and
may it upload evidence on Roofs?* — is a grid: workstreams down, permissions
across. And on the table, permissions are detail: who delivers what is the
scanning question, and every row spending two columns on flags nobody is
reading crowds it out.
## Decision
**Permissions are edited per workstream, in a grid.** The add/edit dialog shows
one row per workstream the *project* covers — not merely the ones this
contractor holds — with a tick to assign it and a tick per permission.
Unassigned rows stay visible and greyed, their permission boxes disabled: the
shape of the project is part of the decision, and a workstream nobody covers is
worth seeing at the moment someone is being assigned. Un-ticking a workstream
clears its permissions, so re-ticking it never silently restores a grant. Each
column header carries an all/none shortcut, because ticking twenty boxes by
hand is how mistakes get made.
**The main table hides permissions until asked.** A row shows the contractor,
the workstreams it delivers, and its Actions. Clicking the row — or its
disclosure button, which is what carries `aria-expanded`/`aria-controls` for
keyboard and screen-reader users — opens a sub-row listing each assignment with
its two permissions, read-only. Several rows may be open at once; comparing two
contractors is a normal thing to want. Actions stay per contractor, since Edit
and Remove act on the whole assignment set.
**The wire shape follows.** POST and PATCH take `assignments: [{
projectWorkstreamId, updateStagesPermission, uploadDocumentsPermission }]`
instead of one id list plus one pair of flags. A workstream may appear only
once per request — two entries for the same workstream are two answers to one
question, and picking one silently is worse than a 400. `ContractorGroup` no
longer carries a per-contractor summary of either permission, and
`permissionState` is gone: nothing needs the flags reduced to one answer, and
reducing them is precisely how a contractor allowed to update stages on one
workstream comes to look allowed everywhere.
## Consequences
- The screen can now express what the schema always stored, and what the authz
guards have always enforced per assignment (`canUpdateStage` reads the flag on
the work order's own assignment). UI, storage and enforcement finally agree on
the unit.
- Divergent permissions are no longer an anomaly to be explained but the normal
output of an ordinary edit. The `mixed` vocabulary, its chip and its tooltip
are deleted rather than reworded.
- Setting one permission across many workstreams costs one click per box, or
one on the column header. That is more work than the single switch it
replaces — accepted deliberately: the switch was cheap because it was coarse.
- The table's first read is now "who delivers what", with permissions one click
away. A future need to scan permissions across contractors (an audit view)
would want a different surface, not these columns back.
- Editing permissions on one workstream sends the contractor's whole assignment
set. The removal rules still run over exactly the workstreams a request drops,
so an edit that also un-ticks a workstream with work orders is still refused
(ADR-0021, decision 2).

View file

@ -104,6 +104,19 @@ function url(confirm = false, organisationId = APEX) {
}`;
}
/** One grid row: a workstream and the permissions it should carry. */
function ws(
projectWorkstreamId: string,
updateStagesPermission = false,
uploadDocumentsPermission = false,
) {
return {
projectWorkstreamId,
updateStagesPermission,
uploadDocumentsPermission,
};
}
function patchRequest(body: unknown, confirm = false) {
return new NextRequest(url(confirm), {
method: "PATCH",
@ -135,11 +148,7 @@ describe("PATCH", () => {
projectExists();
const res = await PATCH(
patchRequest({
projectWorkstreamIds: ["10"],
updateStagesPermission: false,
uploadDocumentsPermission: true,
}),
patchRequest({ assignments: [ws("10", false, true)] }),
params(),
);
@ -147,11 +156,13 @@ describe("PATCH", () => {
expect(mockWriteContractorAssignments).toHaveBeenCalledWith(
5n,
APEX,
{
projectWorkstreamIds: [10n],
updateStagesPermission: false,
uploadDocumentsPermission: true,
},
[
{
projectWorkstreamId: 10n,
updateStagesPermission: false,
uploadDocumentsPermission: true,
},
],
"replace",
);
});
@ -161,7 +172,7 @@ describe("PATCH", () => {
projectExists();
const res = await PATCH(
patchRequest({ projectWorkstreamIds: ["10", "11"] }),
patchRequest({ assignments: [ws("10"), ws("11")] }),
params(),
);
@ -175,7 +186,7 @@ describe("PATCH", () => {
mockCountOtherContractors.mockResolvedValue({ "11": 0 });
const res = await PATCH(
patchRequest({ projectWorkstreamIds: ["10"] }),
patchRequest({ assignments: [ws("10")] }),
params(),
);
@ -192,7 +203,7 @@ describe("PATCH", () => {
mockCountOtherContractors.mockResolvedValue({ "11": 0 });
const res = await PATCH(
patchRequest({ projectWorkstreamIds: ["10"] }, true),
patchRequest({ assignments: [ws("10")] }, true),
params(),
);
@ -209,7 +220,7 @@ describe("PATCH", () => {
]);
const res = await PATCH(
patchRequest({ projectWorkstreamIds: ["10"] }, true),
patchRequest({ assignments: [ws("10")] }, true),
params(),
);
@ -226,7 +237,7 @@ describe("PATCH", () => {
mockFindContractorAssignments.mockResolvedValue([]);
const res = await PATCH(
patchRequest({ projectWorkstreamIds: ["10"] }),
patchRequest({ assignments: [ws("10")] }),
params(),
);
@ -238,7 +249,7 @@ describe("PATCH", () => {
projectExists();
const res = await PATCH(
patchRequest({ projectWorkstreamIds: ["999"] }),
patchRequest({ assignments: [ws("999")] }),
params(),
);
@ -250,7 +261,7 @@ describe("PATCH", () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await PATCH(patchRequest({ projectWorkstreamIds: [] }), params());
const res = await PATCH(patchRequest({ assignments: [] }), params());
expect(res.status).toBe(400);
});
@ -260,7 +271,7 @@ describe("PATCH", () => {
projectExists();
const res = await PATCH(
patchRequest({ projectWorkstreamIds: ["10"] }),
patchRequest({ assignments: [ws("10")] }),
params("apex"),
);
@ -272,7 +283,7 @@ describe("PATCH", () => {
projectExists();
const res = await PATCH(
patchRequest({ projectWorkstreamIds: ["10"] }),
patchRequest({ assignments: [ws("10")] }),
params(),
);
@ -284,7 +295,7 @@ describe("PATCH", () => {
projectExists();
const res = await PATCH(
patchRequest({ projectWorkstreamIds: ["10"] }),
patchRequest({ assignments: [ws("10")] }),
params(),
);

View file

@ -58,8 +58,8 @@ async function refuseRemoval(
}
/**
* PATCH set exactly which workstreams this contractor delivers, and with
* what permissions.
* PATCH set exactly which workstreams this contractor delivers, and the
* permissions each of those carries.
*
* Workstreams dropped from the set are removed, so the removal rules apply to
* them. An empty set would be a removal of the whole contractor; that is what
@ -96,14 +96,14 @@ export async function PATCH(req: NextRequest, props: Params) {
(w) => w.projectWorkstreamId,
),
);
if (body.projectWorkstreamIds.some((id) => !owned.has(id))) {
if (body.assignments.some((a) => !owned.has(a.projectWorkstreamId))) {
return NextResponse.json(
{ error: "Workstream not found on this project" },
{ status: 404 },
);
}
const wanted = new Set(body.projectWorkstreamIds);
const wanted = new Set(body.assignments.map((a) => a.projectWorkstreamId));
const dropped = current.filter((a) => !wanted.has(a.projectWorkstreamId));
const refusal = await refuseRemoval(
auth.projectId,
@ -117,11 +117,11 @@ export async function PATCH(req: NextRequest, props: Params) {
const result = await writeContractorAssignments(
auth.projectId,
organisationId,
{
projectWorkstreamIds: body.projectWorkstreamIds.map(BigInt),
updateStagesPermission: body.updateStagesPermission,
uploadDocumentsPermission: body.uploadDocumentsPermission,
},
body.assignments.map((a) => ({
projectWorkstreamId: BigInt(a.projectWorkstreamId),
updateStagesPermission: a.updateStagesPermission,
uploadDocumentsPermission: a.uploadDocumentsPermission,
})),
"replace",
);
return NextResponse.json({ organisationId, ...result });

View file

@ -7,8 +7,6 @@ import {
decideRemoval,
groupByContractor,
listNames,
permissionState,
splitByPermission,
type ContractorAssignment,
type ContractorAssignmentRow,
} from "./assignments";
@ -46,23 +44,8 @@ function assignment(
};
}
describe("permissionState", () => {
it("reads an empty set as off — nothing grants the permission", () => {
expect(permissionState([])).toBe("off");
});
it("reads all-true as on and all-false as off", () => {
expect(permissionState([true, true])).toBe("on");
expect(permissionState([false, false])).toBe("off");
});
it("reads a disagreement as mixed rather than rounding it", () => {
expect(permissionState([true, false])).toBe("mixed");
});
});
describe("groupByContractor", () => {
it("collapses per-workstream rows into one row per organisation", () => {
it("gathers per-workstream rows under one row per organisation", () => {
const groups = groupByContractor([
row({ id: "1", projectWorkstreamId: "10", workstreamName: "Windows" }),
row({ id: "2", projectWorkstreamId: "11", workstreamName: "Roofs" }),
@ -99,14 +82,38 @@ describe("groupByContractor", () => {
]);
});
it("reports a permission that disagrees across assignments as mixed", () => {
it("keeps each assignment's own permissions rather than reducing them", () => {
const groups = groupByContractor([
row({ id: "1", updateStagesPermission: true }),
row({ id: "2", projectWorkstreamId: "11", updateStagesPermission: false }),
row({
id: "1",
workstreamName: "Windows",
updateStagesPermission: true,
uploadDocumentsPermission: false,
}),
row({
id: "2",
projectWorkstreamId: "11",
workstreamName: "Roofs",
updateStagesPermission: false,
uploadDocumentsPermission: true,
}),
]);
expect(groups[0].updateStages).toBe("mixed");
expect(groups[0].uploadDocuments).toBe("on");
expect(groups[0].assignments).toEqual([
expect.objectContaining({
workstreamName: "Roofs",
updateStagesPermission: false,
uploadDocumentsPermission: true,
}),
expect.objectContaining({
workstreamName: "Windows",
updateStagesPermission: true,
uploadDocumentsPermission: false,
}),
]);
// No per-contractor summary exists to disagree with the detail.
expect(groups[0]).not.toHaveProperty("updateStages");
expect(groups[0]).not.toHaveProperty("uploadDocuments");
});
it("totals work orders across the contractor's assignments", () => {
@ -123,75 +130,6 @@ describe("groupByContractor", () => {
});
});
describe("splitByPermission", () => {
const assignments = [
assignment({
id: "1",
workstreamName: "Windows",
updateStagesPermission: true,
uploadDocumentsPermission: false,
}),
assignment({
id: "2",
projectWorkstreamId: "11",
workstreamName: "Doors",
updateStagesPermission: false,
uploadDocumentsPermission: false,
}),
assignment({
id: "3",
projectWorkstreamId: "12",
workstreamName: "Asbestos",
updateStagesPermission: true,
uploadDocumentsPermission: false,
}),
];
it("names the workstreams that have the permission and those that don't", () => {
expect(splitByPermission(assignments, "updateStagesPermission")).toEqual({
on: ["Asbestos", "Windows"],
off: ["Doors"],
});
});
it("reads each permission independently", () => {
expect(splitByPermission(assignments, "uploadDocumentsPermission")).toEqual({
on: [],
off: ["Asbestos", "Doors", "Windows"],
});
});
it("orders both lists by workstream name, matching the chips", () => {
const { on } = splitByPermission(
[
assignment({ id: "1", workstreamName: "Windows" }),
assignment({ id: "2", projectWorkstreamId: "11", workstreamName: "Doors" }),
],
"updateStagesPermission",
);
expect(on).toEqual(["Doors", "Windows"]);
});
it("does not mutate the assignments it was given", () => {
const original = [
assignment({ id: "1", workstreamName: "Windows" }),
assignment({ id: "2", projectWorkstreamId: "11", workstreamName: "Doors" }),
];
splitByPermission(original, "updateStagesPermission");
expect(original.map((a) => a.workstreamName)).toEqual(["Windows", "Doors"]);
});
it("handles a contractor with no assignments", () => {
expect(splitByPermission([], "updateStagesPermission")).toEqual({
on: [],
off: [],
});
});
});
describe("listNames", () => {
it("joins with commas and a final 'and'", () => {
expect(listNames([])).toBe("");

View file

@ -8,12 +8,11 @@
* Two things live here:
*
* 1. **Grouping.** `project_workstream_contractor` is one row per
* (Project Workstream, organisation) pair, but the permissions table
* (wireframe `06-contractors-permissions.html`) has one row per
* *contractor organisation*, listing the workstreams it delivers. The two
* permission flags belong to the assignment, not to the organisation, so a
* contractor whose assignments disagree is reported as `"mixed"` rather
* than being silently rounded to on or off.
* (Project Workstream, organisation) pair. The table has one row per
* *contractor organisation*, listing the workstreams it delivers, and
* expands to show the permissions each of those assignments carries so
* grouping rearranges the rows without ever collapsing their permissions
* into a single per-contractor answer (ADR-0022).
*
* 2. **Removal.** Work orders reference their assignment by a NOT NULL FK
* (`work_order.project_workstream_contractor_id`), so an assignment with
@ -26,9 +25,6 @@
* import screen afterwards.
*/
/** How a permission reads across all of one contractor's assignments. */
export type PermissionState = "on" | "off" | "mixed";
/** One `project_workstream_contractor` row, flattened and serialised. */
export interface ContractorAssignment {
/** `project_workstream_contractor.id`. */
@ -55,28 +51,21 @@ export interface ContractorGroup {
organisationName: string;
/** This contractor's assignments, ordered by workstream name. */
assignments: ContractorAssignment[];
updateStages: PermissionState;
uploadDocuments: PermissionState;
/** Work orders across every assignment — non-zero means it cannot be removed. */
workOrders: number;
}
/**
* How one permission reads across a set of assignments.
*
* An empty set reads `"off"`: no assignment grants it, so nothing is permitted.
*/
export function permissionState(values: boolean[]): PermissionState {
if (values.length === 0) return "off";
if (values.every(Boolean)) return "on";
if (values.every((v) => !v)) return "off";
return "mixed";
}
/**
* Collapse per-workstream assignment rows into one row per contractor
* Gather per-workstream assignment rows under one row per contractor
* organisation, ordered by organisation name then workstream name so the table
* is stable between refetches.
*
* The permissions stay on the assignments. There is deliberately no
* per-contractor summary of them: the table's collapsed row shows who delivers
* what, and the expanded sub-row shows each assignment's own permissions
* (ADR-0022). Nothing needs the two flags reduced to one answer, and reducing
* them is how a contractor allowed to update stages on one workstream ends up
* looking allowed everywhere.
*/
export function groupByContractor(
rows: ContractorAssignmentRow[],
@ -90,8 +79,6 @@ export function groupByContractor(
organisationId: row.organisationId,
organisationName: row.organisationName,
assignments: [],
updateStages: "off",
uploadDocuments: "off",
workOrders: 0,
};
groups.set(row.organisationId, group);
@ -113,12 +100,6 @@ export function groupByContractor(
);
return {
...group,
updateStages: permissionState(
group.assignments.map((a) => a.updateStagesPermission),
),
uploadDocuments: permissionState(
group.assignments.map((a) => a.uploadDocumentsPermission),
),
workOrders: group.assignments.reduce((n, a) => n + a.workOrders, 0),
};
})
@ -130,26 +111,6 @@ export type PermissionKey =
| "updateStagesPermission"
| "uploadDocumentsPermission";
/**
* Split a contractor's assignments by whether they carry one permission
* the detail behind a `"mixed"` switch, so the UI can name the workstreams
* that have it and the ones that don't instead of just saying they differ.
*
* Both lists come back ordered by workstream name, matching the chips.
*/
export function splitByPermission(
assignments: ContractorAssignment[],
permission: PermissionKey,
): { on: string[]; off: string[] } {
const byName = [...assignments].sort((a, b) =>
a.workstreamName.localeCompare(b.workstreamName),
);
return {
on: byName.filter((a) => a[permission]).map((a) => a.workstreamName),
off: byName.filter((a) => !a[permission]).map((a) => a.workstreamName),
};
}
/** `"Windows, Roofs and Doors"`. */
export function listNames(names: string[]): string {
if (names.length === 0) return "";

View file

@ -267,13 +267,24 @@ export async function countOtherContractors(
);
}
/** What one contractor's assignments should look like after a write. */
/**
* One workstream and the permissions it should carry the unit the grid
* edits. Permissions travel per workstream, never as one pair for the whole
* contractor (ADR-0022).
*/
export interface DesiredAssignment {
projectWorkstreamIds: bigint[];
projectWorkstreamId: bigint;
updateStagesPermission: boolean;
uploadDocumentsPermission: boolean;
}
/** `"1-0"` — the flag pair, so rows sharing one can be updated together. */
function flagKey(desired: DesiredAssignment): string {
return `${desired.updateStagesPermission ? 1 : 0}-${
desired.uploadDocumentsPermission ? 1 : 0
}`;
}
/**
* Write one contractor's assignments on a project.
*
@ -287,13 +298,17 @@ export interface DesiredAssignment {
* a transaction the narrowest guard available without a migration, matching
* `selectWorkstream` in the workstream step.
*
* Updates are grouped by flag pair rather than issued per row: there are only
* four possible pairs, so a contractor on twenty workstreams still costs at
* most four UPDATEs.
*
* `assigned_at` is left to its column default, so a re-permissioned assignment
* keeps the timestamp it was first made at.
*/
export async function writeContractorAssignments(
projectId: bigint,
organisationId: string,
desired: DesiredAssignment,
desired: DesiredAssignment[],
mode: "merge" | "replace",
): Promise<{ created: number; updated: number; removed: number }> {
return db.transaction(async (tx) => {
@ -320,37 +335,53 @@ export async function writeContractorAssignments(
),
);
const wanted = new Set(desired.projectWorkstreamIds.map(String));
const wanted = new Map(
desired.map((row) => [row.projectWorkstreamId.toString(), row]),
);
const held = new Map(
existing.map((row) => [row.projectWorkstreamId.toString(), row.id]),
);
const toCreate = desired.projectWorkstreamIds.filter(
(id) => !held.has(id.toString()),
const toCreate = desired.filter(
(row) => !held.has(row.projectWorkstreamId.toString()),
);
const toUpdate = existing
.filter((row) => wanted.has(row.projectWorkstreamId.toString()))
.map((row) => row.id);
// Existing rows the request still wants, bucketed by the flag pair they
// should end up with.
const updateBuckets = new Map<
string,
{ flags: DesiredAssignment; ids: bigint[] }
>();
let updated = 0;
for (const row of existing) {
const target = wanted.get(row.projectWorkstreamId.toString());
if (!target) continue;
const key = flagKey(target);
const bucket = updateBuckets.get(key) ?? { flags: target, ids: [] };
bucket.ids.push(row.id);
updateBuckets.set(key, bucket);
updated += 1;
}
if (toCreate.length > 0) {
await tx.insert(projectWorkstreamContractor).values(
toCreate.map((projectWorkstreamId) => ({
projectWorkstreamId,
toCreate.map((row) => ({
projectWorkstreamId: row.projectWorkstreamId,
organisationId,
updateStagesPermission: desired.updateStagesPermission,
uploadDocumentsPermission: desired.uploadDocumentsPermission,
updateStagesPermission: row.updateStagesPermission,
uploadDocumentsPermission: row.uploadDocumentsPermission,
})),
);
}
if (toUpdate.length > 0) {
for (const { flags, ids } of updateBuckets.values()) {
await tx
.update(projectWorkstreamContractor)
.set({
updateStagesPermission: desired.updateStagesPermission,
uploadDocumentsPermission: desired.uploadDocumentsPermission,
updateStagesPermission: flags.updateStagesPermission,
uploadDocumentsPermission: flags.uploadDocumentsPermission,
})
.where(inArray(projectWorkstreamContractor.id, toUpdate));
.where(inArray(projectWorkstreamContractor.id, ids));
}
let removed = 0;
@ -367,7 +398,7 @@ export async function writeContractorAssignments(
}
}
return { created: toCreate.length, updated: toUpdate.length, removed };
return { created: toCreate.length, updated, removed };
});
}

View file

@ -82,11 +82,22 @@ function getRequest() {
return new NextRequest("http://test/api/projects/5/contractors");
}
/** One grid row: a workstream and the permissions it should carry. */
function ws(
projectWorkstreamId: string,
updateStagesPermission = false,
uploadDocumentsPermission = false,
) {
return {
projectWorkstreamId,
updateStagesPermission,
uploadDocumentsPermission,
};
}
const VALID_BODY = {
organisationId: APEX,
projectWorkstreamIds: ["10", "11"],
updateStagesPermission: true,
uploadDocumentsPermission: false,
assignments: [ws("10", true, false), ws("11", false, true)],
};
beforeEach(() => {
@ -159,11 +170,18 @@ describe("POST /api/projects/[projectId]/contractors", () => {
expect(mockWriteContractorAssignments).toHaveBeenCalledWith(
5n,
APEX,
{
projectWorkstreamIds: [10n, 11n],
updateStagesPermission: true,
uploadDocumentsPermission: false,
},
[
{
projectWorkstreamId: 10n,
updateStagesPermission: true,
uploadDocumentsPermission: false,
},
{
projectWorkstreamId: 11n,
updateStagesPermission: false,
uploadDocumentsPermission: true,
},
],
"merge",
);
});
@ -188,17 +206,23 @@ describe("POST /api/projects/[projectId]/contractors", () => {
projectExists();
await POST(
postRequest({ organisationId: APEX, projectWorkstreamIds: ["10"] }),
postRequest({
organisationId: APEX,
assignments: [{ projectWorkstreamId: "10" }],
}),
params,
);
expect(mockWriteContractorAssignments).toHaveBeenCalledWith(
5n,
APEX,
expect.objectContaining({
updateStagesPermission: false,
uploadDocumentsPermission: false,
}),
[
{
projectWorkstreamId: 10n,
updateStagesPermission: false,
uploadDocumentsPermission: false,
},
],
"merge",
);
});
@ -218,7 +242,7 @@ describe("POST /api/projects/[projectId]/contractors", () => {
projectExists();
const res = await POST(
postRequest({ organisationId: APEX, projectWorkstreamIds: [] }),
postRequest({ organisationId: APEX, assignments: [] }),
params,
);
@ -227,12 +251,55 @@ describe("POST /api/projects/[projectId]/contractors", () => {
expect(mockWriteContractorAssignments).not.toHaveBeenCalled();
});
it("rejects a workstream named twice — two answers to one question", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await POST(
postRequest({
organisationId: APEX,
assignments: [ws("10", true, true), ws("10", false, false)],
}),
params,
);
expect(res.status).toBe(400);
expect(mockWriteContractorAssignments).not.toHaveBeenCalled();
});
it("carries different permissions per workstream through untouched", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
await POST(
postRequest({
organisationId: APEX,
assignments: [ws("10", true, false), ws("11", false, false)],
}),
params,
);
const [, , assignments] = mockWriteContractorAssignments.mock.calls[0];
expect(assignments).toEqual([
{
projectWorkstreamId: 10n,
updateStagesPermission: true,
uploadDocumentsPermission: false,
},
{
projectWorkstreamId: 11n,
updateStagesPermission: false,
uploadDocumentsPermission: false,
},
]);
});
it("rejects an organisationId that is not a uuid", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await POST(
postRequest({ organisationId: "apex", projectWorkstreamIds: ["10"] }),
postRequest({ organisationId: "apex", assignments: [ws("10")] }),
params,
);
@ -258,7 +325,7 @@ describe("POST /api/projects/[projectId]/contractors", () => {
projectExists();
const res = await POST(
postRequest({ ...VALID_BODY, projectWorkstreamIds: ["10", "999"] }),
postRequest({ ...VALID_BODY, assignments: [ws("10"), ws("999")] }),
params,
);

View file

@ -38,7 +38,8 @@ export async function GET(_req: NextRequest, props: Params) {
}
/**
* POST assign a contractor to one or more of the project's workstreams.
* POST assign a contractor to one or more of the project's workstreams, each
* with its own permissions.
*
* Additive: workstreams the body does not name are left alone, so adding a
* second contractor never displaces the first (the schema allows several per
@ -73,7 +74,7 @@ export async function POST(req: NextRequest, props: Params) {
(w) => w.projectWorkstreamId,
),
);
if (body.projectWorkstreamIds.some((id) => !owned.has(id))) {
if (body.assignments.some((a) => !owned.has(a.projectWorkstreamId))) {
return NextResponse.json(
{ error: "Workstream not found on this project" },
{ status: 404 },
@ -84,11 +85,11 @@ export async function POST(req: NextRequest, props: Params) {
const result = await writeContractorAssignments(
auth.projectId,
body.organisationId,
{
projectWorkstreamIds: body.projectWorkstreamIds.map(BigInt),
updateStagesPermission: body.updateStagesPermission,
uploadDocumentsPermission: body.uploadDocumentsPermission,
},
body.assignments.map((a) => ({
projectWorkstreamId: BigInt(a.projectWorkstreamId),
updateStagesPermission: a.updateStagesPermission,
uploadDocumentsPermission: a.uploadDocumentsPermission,
})),
"merge",
);
return NextResponse.json(

View file

@ -8,24 +8,45 @@
import { z } from "zod";
/**
* The assignment a write asks for.
* One (workstream, permissions) triple the unit the grid edits and the unit
* `project_workstream_contractor` stores.
*
* `project_workstream.id` values arrive as strings they are bigint in the
* database and do not survive JSON. The permission flags default to false,
* matching the column defaults: an assignment grants nothing unless asked.
* `project_workstream.id` arrives as a string: it is bigint in the database and
* does not survive JSON. Both flags default to false, matching the column
* defaults an assignment grants nothing unless asked.
*/
export const assignmentBodySchema = z.object({
projectWorkstreamIds: z
.array(z.string().regex(/^\d+$/, "workstream ids must be numeric"))
.min(1, "Choose at least one workstream"),
export const assignmentSchema = z.object({
projectWorkstreamId: z
.string()
.regex(/^\d+$/, "workstream ids must be numeric"),
updateStagesPermission: z.boolean().default(false),
uploadDocumentsPermission: z.boolean().default(false),
});
/**
* The assignment set a write asks for permissions per workstream, never one
* pair applied to all of them (ADR-0022).
*
* A workstream may appear only once: two entries for the same workstream carry
* two answers to the same question, and picking one silently is worse than
* refusing.
*/
export const assignmentBodySchema = z.object({
assignments: z
.array(assignmentSchema)
.min(1, "Choose at least one workstream")
.refine(
(rows) =>
new Set(rows.map((r) => r.projectWorkstreamId)).size === rows.length,
{ message: "Each workstream may appear only once" },
),
});
/** Adding a contractor also names which organisation is being assigned. */
export const addContractorBodySchema = assignmentBodySchema.extend({
organisationId: z.string().uuid("organisationId must be a uuid"),
});
export type AssignmentInput = z.infer<typeof assignmentSchema>;
export type AssignmentBody = z.infer<typeof assignmentBodySchema>;
export type AddContractorBody = z.infer<typeof addContractorBodySchema>;

View file

@ -14,15 +14,15 @@ import {
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
import {
listNames,
splitByPermission,
type ContractorGroup,
import type {
ContractorGroup,
PermissionKey,
} from "@/app/api/projects/[projectId]/contractors/assignments";
import type {
OrganisationOption,
ProjectWorkstreamOption,
} from "@/app/api/projects/[projectId]/contractors/queries";
import { PERMISSION_COLUMNS } from "./PermissionCell";
/**
* Add or edit one contractor (issue #413).
@ -32,6 +32,15 @@ import type {
* belongs to the "invite company to Ara" flow, not here. That is why there is
* no "create organisation" affordance and no free-text name field.
*
* Below it sits **the grid**: one row per workstream the project covers, with
* a tick to assign it and a tick per permission (ADR-0022). Workstreams this
* contractor does not deliver stay in the grid, greyed, rather than being
* hidden the shape of the project is part of the decision, and a workstream
* nobody covers is worth seeing while assigning someone. Because each row
* carries its own permissions, "Apex may move Windows along but only upload on
* Roofs" is expressible here, which is exactly what the schema has always
* stored.
*
* The dialog is mounted only while open and keyed by the contractor it edits,
* so its `useState` initialisers *are* the "load the current values" step
* no effect synchronises props into state.
@ -51,6 +60,20 @@ interface SaveOutcome {
reason?: string;
}
/** One row of the grid: a workstream, whether it is assigned, and its flags. */
interface GridRow {
projectWorkstreamId: string;
name: string;
/** Contractors already on this workstream — 0 is what blocks import. */
contractorCount: number;
assigned: boolean;
updateStagesPermission: boolean;
uploadDocumentsPermission: boolean;
/** Work orders under *this contractor's* assignment, if it has one. */
workOrders: number;
}
export function ContractorFormDialog({
projectId,
mode,
@ -70,19 +93,34 @@ export function ContractorFormDialog({
const [organisation, setOrganisation] = useState<{
id: string;
name: string;
alreadyAssigned: boolean;
} | null>(
editing
? { id: editing.organisationId, name: editing.organisationName }
? {
id: editing.organisationId,
name: editing.organisationName,
alreadyAssigned: true,
}
: null,
);
const [selected, setSelected] = useState<string[]>(
editing ? editing.assignments.map((a) => a.projectWorkstreamId) : [],
);
const [updateStages, setUpdateStages] = useState(
editing ? editing.updateStages === "on" : false,
);
const [uploadDocuments, setUploadDocuments] = useState(
editing ? editing.uploadDocuments === "on" : false,
// One entry per project workstream — the grid's rows. Unassigned workstreams
// are present but not `assigned`, so they render greyed rather than missing:
// the user sees everything the project covers and what is still uncovered.
const [rows, setRows] = useState<GridRow[]>(() =>
workstreams.map((workstream) => {
const held = editing?.assignments.find(
(a) => a.projectWorkstreamId === workstream.projectWorkstreamId,
);
return {
projectWorkstreamId: workstream.projectWorkstreamId,
name: workstream.name,
contractorCount: workstream.contractorCount,
assigned: Boolean(held),
updateStagesPermission: held?.updateStagesPermission ?? false,
uploadDocumentsPermission: held?.uploadDocumentsPermission ?? false,
workOrders: held?.workOrders ?? 0,
};
}),
);
const [warning, setWarning] = useState<SaveOutcome | null>(null);
@ -107,9 +145,13 @@ export function ContractorFormDialog({
if (!organisation) throw new Error("Choose an organisation first");
const payload = {
projectWorkstreamIds: selected,
updateStagesPermission: updateStages,
uploadDocumentsPermission: uploadDocuments,
assignments: rows
.filter((row) => row.assigned)
.map((row) => ({
projectWorkstreamId: row.projectWorkstreamId,
updateStagesPermission: row.updateStagesPermission,
uploadDocumentsPermission: row.uploadDocumentsPermission,
})),
};
const res = editing
@ -152,46 +194,52 @@ export function ContractorFormDialog({
onError: (err: Error) => setWarning({ kind: "blocked", reason: err.message }),
});
function toggleWorkstream(id: string) {
function updateRow(id: string, change: Partial<GridRow>) {
setWarning(null);
setSelected((current) =>
current.includes(id)
? current.filter((value) => value !== id)
: [...current, id],
setRows((current) =>
current.map((row) =>
row.projectWorkstreamId === id ? { ...row, ...change } : row,
),
);
}
const canSave =
Boolean(organisation) && selected.length > 0 && !save.isLoading;
/**
* Un-assigning a workstream drops its permissions too. Keeping them set on a
* row nobody delivers would re-grant them silently if it were re-ticked.
*/
function toggleAssigned(row: GridRow, assigned: boolean) {
updateRow(
row.projectWorkstreamId,
assigned
? { assigned: true }
: {
assigned: false,
updateStagesPermission: false,
uploadDocumentsPermission: false,
},
);
}
// Which permissions this contractor currently holds inconsistently, and how.
// Named rather than merely flagged: the user is about to overwrite the split,
// so they should see what it is before they do.
const mixedPermissions = editing
? (
[
{
label: "Update stages of workstream(s)",
state: editing.updateStages,
key: "updateStagesPermission",
},
{
label: "Upload docs",
state: editing.uploadDocuments,
key: "uploadDocumentsPermission",
},
] as const
)
.filter((permission) => permission.state === "mixed")
.map(({ label, key }) => ({
label,
...splitByPermission(editing.assignments, key),
}))
: [];
/** Set one permission across every assigned row — the column header's job. */
function setColumn(permission: PermissionKey, value: boolean) {
setWarning(null);
setRows((current) =>
current.map((row) =>
row.assigned ? { ...row, [permission]: value } : row,
),
);
}
const assignedRows = rows.filter((row) => row.assigned);
const canSave =
Boolean(organisation) && assignedRows.length > 0 && !save.isLoading;
const columnState = (permission: PermissionKey) =>
assignedRows.length > 0 && assignedRows.every((row) => row[permission]);
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>
{editing ? `Edit ${editing.organisationName}` : "Add contractor"}
@ -212,18 +260,30 @@ export function ContractorFormDialog({
Organisation
</label>
{organisation ? (
<div className="flex items-center justify-between rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2">
<span className="text-sm font-semibold text-indigo-900">
{organisation.name}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setOrganisation(null)}
>
Change
</Button>
</div>
<>
<div className="flex items-center justify-between rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2">
<span className="text-sm font-semibold text-indigo-900">
{organisation.name}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setOrganisation(null)}
>
Change
</Button>
</div>
{organisation.alreadyAssigned && (
<p
className="mt-1.5 text-xs text-amber-700"
data-testid="organisation-already-assigned"
>
Already delivers work on this project. What you tick below is
<em> added</em> to what it already has to change existing
permissions, close this and use Edit on its row.
</p>
)}
</>
) : (
<>
<div className="relative">
@ -260,7 +320,11 @@ export function ContractorFormDialog({
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-gray-50"
onClick={() =>
setOrganisation({ id: option.id, name: option.name })
setOrganisation({
id: option.id,
name: option.name,
alreadyAssigned: option.alreadyAssigned,
})
}
>
<span className="font-medium text-gray-800">
@ -282,91 +346,114 @@ export function ContractorFormDialog({
<fieldset>
<legend className="mb-1.5 text-sm font-medium text-gray-700">
Workstreams
Workstreams and permissions
</legend>
{workstreams.length === 0 ? (
{rows.length === 0 ? (
<p className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-3 text-sm text-gray-600">
This project has no workstreams yet, so there is nothing to assign
a contractor to.
</p>
) : (
<ul className="space-y-1" data-testid="workstream-multi-select">
{workstreams.map((option) => (
<li key={option.projectWorkstreamId}>
<label className="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-gray-50">
<Checkbox
checked={selected.includes(option.projectWorkstreamId)}
onCheckedChange={() =>
toggleWorkstream(option.projectWorkstreamId)
}
/>
<span className="text-sm text-gray-800">{option.name}</span>
{option.contractorCount === 0 && (
<span className="text-xs text-amber-600">
no contractor yet
</span>
)}
</label>
</li>
))}
</ul>
)}
</fieldset>
<fieldset>
<legend className="mb-1.5 text-sm font-medium text-gray-700">
Permissions
</legend>
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg px-2 py-1.5 hover:bg-gray-50">
<Checkbox
className="mt-0.5"
checked={updateStages}
onCheckedChange={(value) => setUpdateStages(value === true)}
/>
<span className="text-sm text-gray-800">
Update stages of workstream(s)
<span className="block text-xs text-gray-500">
Move its work orders along the stage ladder, on every workstream
ticked above.
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg px-2 py-1.5 hover:bg-gray-50">
<Checkbox
className="mt-0.5"
checked={uploadDocuments}
onCheckedChange={(value) => setUploadDocuments(value === true)}
/>
<span className="text-sm text-gray-800">
Upload documents
<span className="block text-xs text-gray-500">
Attach evidence against its work orders.
</span>
</span>
</label>
{editing && mixedPermissions.length > 0 && (
<div
className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800"
data-testid="contractor-form-mixed-notice"
>
<p className="font-semibold">
{editing.organisationName} holds these differently on different
workstreams today:
</p>
<ul className="mt-1 space-y-0.5">
{mixedPermissions.map(({ label, on, off }) => (
<li key={label}>
<span className="font-semibold">{label}</span> on for{" "}
{listNames(on)}, off for {listNames(off)}
</li>
))}
</ul>
<p className="mt-1">
Saving applies the boxes above to every workstream ticked, which
clears the split.
</p>
<div className="overflow-hidden rounded-lg border border-gray-200">
<table
className="w-full border-collapse text-left"
data-testid="assignment-grid"
>
<thead>
<tr className="border-b border-gray-200 bg-gray-50 text-xs text-gray-600">
<th className="px-3 py-2 font-semibold">Workstream</th>
{PERMISSION_COLUMNS.map((column) => (
<th
key={column.key}
className="w-24 px-2 py-2 text-center font-semibold"
>
<span className="block">{column.header}</span>
<button
type="button"
className="mt-0.5 text-[10px] font-medium text-indigo-600 underline disabled:text-gray-300 disabled:no-underline"
disabled={assignedRows.length === 0}
onClick={() =>
setColumn(column.key, !columnState(column.key))
}
data-testid={`grid-column-toggle-${column.key}`}
>
{columnState(column.key) ? "none" : "all"}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.projectWorkstreamId}
className={[
"border-b border-gray-100 last:border-b-0",
row.assigned ? "" : "bg-gray-50/60",
].join(" ")}
data-testid={`grid-row-${row.projectWorkstreamId}`}
>
<td className="px-3 py-2">
<label className="flex cursor-pointer items-center gap-2.5">
<Checkbox
checked={row.assigned}
onCheckedChange={(value) =>
toggleAssigned(row, value === true)
}
aria-label={`Assign ${organisation?.name ?? "this contractor"} to ${row.name}`}
/>
<span
className={[
"text-sm",
row.assigned ? "text-gray-800" : "text-gray-400",
].join(" ")}
>
{row.name}
</span>
{!row.assigned && row.contractorCount === 0 && (
<span className="text-[10px] font-medium uppercase tracking-wide text-amber-600">
uncovered
</span>
)}
{row.workOrders > 0 && (
<span className="text-[10px] text-gray-400">
{row.workOrders}{" "}
{row.workOrders === 1 ? "order" : "orders"}
</span>
)}
</label>
</td>
{PERMISSION_COLUMNS.map((column) => (
<td key={column.key} className="px-2 py-2 text-center">
<Checkbox
checked={row[column.key]}
// Greyed until the workstream is assigned: a
// permission on a workstream nobody delivers has
// nothing to apply to.
disabled={!row.assigned}
className={row.assigned ? "" : "opacity-40"}
onCheckedChange={(value) =>
updateRow(row.projectWorkstreamId, {
[column.key]: value === true,
})
}
aria-label={`${column.header} on ${row.name}`}
data-testid={`grid-${column.key}-${row.projectWorkstreamId}`}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="mt-1.5 text-xs text-gray-500">
<span className="font-medium">Update stages</span> moves work orders
along the stage ladder; <span className="font-medium">Upload docs</span>{" "}
attaches evidence against them. Both are set per workstream, so a
contractor can be trusted with one workstream and not another.
</p>
</fieldset>
{warning && (

View file

@ -1,18 +1,23 @@
"use client";
import { useState } from "react";
import { Fragment, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, ArrowRight, Info, Pencil, Trash2, UserPlus } from "lucide-react";
import {
ArrowLeft,
ArrowRight,
ChevronRight,
Info,
Pencil,
Trash2,
UserPlus,
} from "lucide-react";
import { Button } from "@/app/shadcn_components/ui/button";
import { ConfirmDialog } from "@/app/components/ConfirmDialog";
import {
splitByPermission,
type ContractorGroup,
} from "@/app/api/projects/[projectId]/contractors/assignments";
import type { ContractorGroup } from "@/app/api/projects/[projectId]/contractors/assignments";
import type { ProjectWorkstreamOption } from "@/app/api/projects/[projectId]/contractors/queries";
import { PermissionToggle } from "./PermissionToggle";
import { PERMISSION_COLUMNS, PermissionState } from "./PermissionCell";
import {
ContractorFormDialog,
type ContractorFormMode,
@ -85,6 +90,9 @@ export function ContractorPermissionsTable({
const [notice, setNotice] = useState<Notice | null>(null);
const [form, setForm] = useState<ContractorFormMode | null>(null);
// Organisation ids whose permission detail is open. Several may be at once —
// comparing two contractors is a normal thing to want.
const [expanded, setExpanded] = useState<string[]>([]);
const [pendingRemove, setPendingRemove] = useState<{
contractor: ContractorGroup;
reason: string;
@ -112,42 +120,6 @@ export function ContractorPermissionsTable({
router.refresh();
};
const setPermission = useMutation({
mutationFn: async ({
contractor,
updateStages,
uploadDocuments,
}: {
contractor: ContractorGroup;
updateStages: boolean;
uploadDocuments: boolean;
}) => {
const res = await fetch(
`/api/projects/${projectId}/contractors/${contractor.organisationId}`,
{
method: "PATCH",
headers: { "content-type": "application/json" },
// The same workstreams, so no assignment is dropped and the removal
// rules have nothing to say — only the two flags change.
body: JSON.stringify({
projectWorkstreamIds: contractor.assignments.map(
(a) => a.projectWorkstreamId,
),
updateStagesPermission: updateStages,
uploadDocumentsPermission: uploadDocuments,
}),
},
);
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error ?? "Couldn't save the permission");
},
onSuccess: () => {
setNotice(null);
refresh();
},
onError: (err: Error) => setNotice({ tone: "error", text: err.message }),
});
const remove = useMutation({
mutationFn: async ({
contractor,
@ -193,11 +165,16 @@ export function ContractorPermissionsTable({
},
});
const busyOrganisationId = setPermission.isLoading
? setPermission.variables?.contractor.organisationId
: remove.isLoading
? remove.variables?.contractor.organisationId
: undefined;
const busyOrganisationId = remove.isLoading
? remove.variables?.contractor.organisationId
: undefined;
const toggleExpanded = (organisationId: string) =>
setExpanded((current) =>
current.includes(organisationId)
? current.filter((id) => id !== organisationId)
: [...current, organisationId],
);
const { contractors, workstreams, canManage } = data;
const unassigned = workstreams.filter((w) => w.contractorCount === 0);
@ -288,18 +265,15 @@ export function ContractorPermissionsTable({
<table className="w-full border-collapse text-left">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="w-10 px-2 py-3">
<span className="sr-only">Show permissions</span>
</th>
<th className="px-4 py-3 text-sm font-semibold text-gray-700">
Contractor
</th>
<th className="px-4 py-3 text-sm font-semibold text-gray-700">
Assigned workstreams
</th>
<th className="w-44 px-4 py-3 text-center text-sm font-semibold text-gray-700">
Update stages of workstream(s)
</th>
<th className="w-32 px-4 py-3 text-center text-sm font-semibold text-gray-700">
Upload docs
</th>
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-700">
Actions
</th>
@ -307,131 +281,182 @@ export function ContractorPermissionsTable({
</thead>
<tbody data-testid="contractors-table">
{contractors.map((contractor) => {
const busy =
busyOrganisationId === contractor.organisationId;
const busy = busyOrganisationId === contractor.organisationId;
const frozen =
!canManage ||
(Boolean(busyOrganisationId) && !busy);
// Which workstreams hold each permission — the detail behind a
// "mixed" switch, so it can say how it is mixed.
const stagesSplit = splitByPermission(
contractor.assignments,
"updateStagesPermission",
);
const documentsSplit = splitByPermission(
contractor.assignments,
"uploadDocumentsPermission",
);
!canManage || (Boolean(busyOrganisationId) && !busy);
const isOpen = expanded.includes(contractor.organisationId);
const detailId = `contractor-permissions-${contractor.organisationId}`;
return (
<tr
key={contractor.organisationId}
className="border-b border-gray-100 last:border-b-0 hover:bg-gray-50/60"
data-testid={`contractor-row-${contractor.organisationId}`}
>
<td className="px-4 py-4">
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-indigo-50 text-sm font-bold text-indigo-700">
{contractor.organisationName.charAt(0).toUpperCase()}
</span>
<span className="text-sm font-semibold text-gray-900">
{contractor.organisationName}
</span>
</div>
{contractor.workOrders > 0 && (
<span className="mt-1 block pl-11 text-xs text-gray-400">
{contractor.workOrders}{" "}
{contractor.workOrders === 1
? "work order"
: "work orders"}{" "}
issued
</span>
)}
</td>
<td className="px-4 py-4">
<div className="flex flex-wrap gap-1.5">
{contractor.assignments.map((assignment) => (
<span
key={assignment.id}
className="rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700"
>
{assignment.workstreamName}
<Fragment key={contractor.organisationId}>
<tr
className="cursor-pointer border-b border-gray-100 hover:bg-gray-50/60"
onClick={() => toggleExpanded(contractor.organisationId)}
data-testid={`contractor-row-${contractor.organisationId}`}
>
<td className="px-2 py-4 align-top">
<button
type="button"
aria-expanded={isOpen}
aria-controls={detailId}
aria-label={`${isOpen ? "Hide" : "Show"} what ${contractor.organisationName} may do on each workstream`}
// The row is clickable for convenience; this button is
// what makes the disclosure real for keyboards and
// screen readers.
onClick={(e) => {
e.stopPropagation();
toggleExpanded(contractor.organisationId);
}}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
data-testid={`expand-contractor-${contractor.organisationId}`}
>
<ChevronRight
className={[
"h-4 w-4 transition-transform",
isOpen ? "rotate-90" : "",
].join(" ")}
/>
</button>
</td>
<td className="px-4 py-4">
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-indigo-50 text-sm font-bold text-indigo-700">
{contractor.organisationName.charAt(0).toUpperCase()}
</span>
))}
</div>
</td>
<td className="px-4 py-4 text-center">
<PermissionToggle
state={contractor.updateStages}
label="Update stages of workstream(s)"
contractorName={contractor.organisationName}
testId="permission-toggle-update-stages"
workstreamsOn={stagesSplit.on}
workstreamsOff={stagesSplit.off}
disabled={frozen}
pending={busy && setPermission.isLoading}
onChange={(next) =>
setPermission.mutate({
contractor,
updateStages: next,
uploadDocuments:
contractor.uploadDocuments === "on",
})
}
/>
</td>
<td className="px-4 py-4 text-center">
<PermissionToggle
state={contractor.uploadDocuments}
label="Upload docs"
contractorName={contractor.organisationName}
testId="permission-toggle-upload-docs"
workstreamsOn={documentsSplit.on}
workstreamsOff={documentsSplit.off}
disabled={frozen}
pending={busy && setPermission.isLoading}
onChange={(next) =>
setPermission.mutate({
contractor,
updateStages: contractor.updateStages === "on",
uploadDocuments: next,
})
}
/>
</td>
<td className="px-4 py-4">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
disabled={frozen}
onClick={() => {
setNotice(null);
setForm({ kind: "edit", contractor });
}}
data-testid={`edit-contractor-${contractor.organisationId}`}
>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
disabled={frozen}
onClick={() => {
setNotice(null);
remove.mutate({ contractor, confirmed: false });
}}
data-testid={`remove-contractor-${contractor.organisationId}`}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Remove
</Button>
</div>
</td>
</tr>
<span className="text-sm font-semibold text-gray-900">
{contractor.organisationName}
</span>
</div>
{contractor.workOrders > 0 && (
<span className="mt-1 block pl-11 text-xs text-gray-400">
{contractor.workOrders}{" "}
{contractor.workOrders === 1
? "work order"
: "work orders"}{" "}
issued
</span>
)}
</td>
<td className="px-4 py-4">
<div className="flex flex-wrap gap-1.5">
{contractor.assignments.map((assignment) => (
<span
key={assignment.id}
className="rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700"
>
{assignment.workstreamName}
</span>
))}
</div>
</td>
<td className="px-4 py-4">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
disabled={frozen}
onClick={(e) => {
e.stopPropagation();
setNotice(null);
setForm({ kind: "edit", contractor });
}}
data-testid={`edit-contractor-${contractor.organisationId}`}
>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
disabled={frozen}
onClick={(e) => {
e.stopPropagation();
setNotice(null);
remove.mutate({ contractor, confirmed: false });
}}
data-testid={`remove-contractor-${contractor.organisationId}`}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Remove
</Button>
</div>
</td>
</tr>
{isOpen && (
<tr
id={detailId}
className="border-b border-gray-100 bg-gray-50/70"
data-testid={`contractor-permissions-${contractor.organisationId}`}
>
<td />
<td colSpan={3} className="px-4 pb-4 pt-1">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
What {contractor.organisationName} may do
</p>
<table className="w-full border-collapse overflow-hidden rounded-lg border border-gray-200 bg-white">
<thead>
<tr className="border-b border-gray-200 text-xs text-gray-600">
<th className="px-3 py-2 text-left font-semibold">
Workstream
</th>
{PERMISSION_COLUMNS.map((column) => (
<th
key={column.key}
className="w-32 px-3 py-2 text-left font-semibold"
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{contractor.assignments.map((assignment) => (
<tr
key={assignment.id}
className="border-b border-gray-100 last:border-b-0"
data-testid={`assignment-row-${assignment.id}`}
>
<td className="px-3 py-2">
<span className="text-sm font-medium text-gray-800">
{assignment.workstreamName}
</span>
{assignment.workOrders > 0 && (
<span className="ml-2 text-xs text-gray-400">
{assignment.workOrders}{" "}
{assignment.workOrders === 1
? "work order"
: "work orders"}
</span>
)}
</td>
{PERMISSION_COLUMNS.map((column) => (
<td key={column.key} className="px-3 py-2">
<PermissionState
granted={assignment[column.key]}
label={column.header}
workstreamName={
assignment.workstreamName
}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
{canManage && (
<p className="mt-2 text-xs text-gray-500">
Set per workstream {" "}
<span className="font-medium">Edit</span> to change
them.
</p>
)}
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>

View file

@ -0,0 +1,59 @@
"use client";
import { Check, Minus } from "lucide-react";
import type { PermissionKey } from "@/app/api/projects/[projectId]/contractors/assignments";
/**
* How one assignment's permissions are named and shown (issue #413).
*
* Both the add/edit grid and the table's expanded detail describe the same two
* columns, in the same order, with the same words so they live here once
* rather than being retyped either side of the dialog.
*/
/** The two permission columns, in the order both grids show them. */
export const PERMISSION_COLUMNS: Array<{
key: PermissionKey;
header: string;
}> = [
{ key: "updateStagesPermission", header: "Update stages" },
{ key: "uploadDocumentsPermission", header: "Upload docs" },
];
/**
* One permission on one workstream, read-only what the table's sub-row shows.
*
* Deliberately not a control: the table reports the state, and the grid in the
* Edit dialog is the single place it is changed (ADR-0022). Colour is never the
* only signal the icon and the word carry it too.
*/
export function PermissionState({
granted,
label,
workstreamName,
}: {
granted: boolean;
/** The permission's name, e.g. "Update stages". */
label: string;
workstreamName: string;
}) {
return (
<span
className={[
"inline-flex items-center gap-1.5 text-xs font-medium",
granted ? "text-emerald-700" : "text-gray-400",
].join(" ")}
data-testid={`permission-${granted ? "granted" : "withheld"}`}
>
{granted ? (
<Check className="h-3.5 w-3.5" />
) : (
<Minus className="h-3.5 w-3.5" />
)}
<span className="sr-only">
{label} on {workstreamName}:{" "}
</span>
{granted ? "Allowed" : "Not allowed"}
</span>
);
}

View file

@ -1,170 +0,0 @@
"use client";
import { Check, Info, Minus } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
import {
listNames,
type PermissionState,
} from "@/app/api/projects/[projectId]/contractors/assignments";
/**
* One permission switch in the permissions table (issue #413).
*
* The two flags live on the *assignment*, not on the organisation, so a
* contractor delivering three workstreams holds three copies of each. When
* they disagree the switch reads **Mixed** rather than picking a side
* (ADR-0021).
*
* "Mixed" on its own raises more questions than it answers mixed how, and
* what happens if I click it? so it is explained three times over, at
* increasing depth:
*
* 1. The chip carries the count: `Mixed · 2 of 3`. No interaction needed.
* 2. Its tooltip names which workstreams have the permission and which do
* not, and states what clicking the switch will do.
* 3. The switch's own accessible name says the same thing in a sentence, so
* a screen-reader user never depends on the hover.
*
* `role="switch"` only admits `aria-checked` true or false (ARIA 1.2 unlike
* a checkbox, a switch has no mixed value), which is the other reason the
* mixed case has to be announced through the name.
*/
export function PermissionToggle({
state,
label,
contractorName,
testId,
workstreamsOn,
workstreamsOff,
disabled,
pending,
onChange,
}: {
state: PermissionState;
/** What the permission is, e.g. "Update stages of workstream(s)". */
label: string;
contractorName: string;
/** Stable hook for tests — not derived from `label`, which is copy. */
testId: string;
/** Workstream names holding this permission, ordered as the chips are. */
workstreamsOn: string[];
/** Workstream names without it. */
workstreamsOff: string[];
disabled: boolean;
pending: boolean;
onChange: (next: boolean) => void;
}) {
const on = state === "on";
const mixed = state === "mixed";
const total = workstreamsOn.length + workstreamsOff.length;
const plural = total === 1 ? "workstream" : "workstreams";
const accessibleName = mixed
? `${label} for ${contractorName} — on for ${listNames(workstreamsOn)}, off for ${listNames(
workstreamsOff,
)}. Activating turns it on for all ${total} ${plural}.`
: `${label} for ${contractorName}`;
return (
<span className="inline-flex flex-col items-center gap-1">
<button
type="button"
role="switch"
aria-checked={on}
aria-label={accessibleName}
data-testid={testId}
data-state={state}
disabled={disabled || pending}
// A mixed switch resolves upwards: the user's next click means
// "grant this everywhere", never "revoke the ones that had it".
onClick={() => onChange(mixed ? true : !on)}
className={[
"relative h-6 w-11 rounded-full transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2",
disabled || pending ? "cursor-not-allowed opacity-60" : "cursor-pointer",
on ? "bg-indigo-600" : mixed ? "bg-amber-400" : "bg-gray-200",
].join(" ")}
>
<span
className={[
"absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-all",
on ? "left-[22px]" : mixed ? "left-3" : "left-0.5",
].join(" ")}
/>
</button>
{mixed && (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
// Focusable, so the breakdown is reachable without a pointer.
className="inline-flex items-center gap-1 rounded-full px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-600 hover:text-amber-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
aria-label={`What “mixed” means here: ${accessibleName}`}
data-testid={`${testId}-mixed`}
>
<Info className="h-3 w-3" />
Mixed · {workstreamsOn.length} of {total}
</button>
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={8}
className="max-w-xs overflow-hidden border-gray-200 p-0 shadow-xl"
>
<div className="border-b border-gray-100 bg-gray-50 px-3 py-2">
<p className="text-xs font-bold uppercase tracking-wide text-gray-700">
{label}
</p>
<p className="mt-0.5 text-[11px] text-gray-500">
Set per workstream, and {contractorName}&rsquo;s differ.
</p>
</div>
<ul className="space-y-1.5 px-3 py-2.5">
{workstreamsOn.map((name) => (
<li
key={name}
className="flex items-start gap-1.5 text-[11px] text-gray-700"
>
<Check className="mt-px h-3 w-3 shrink-0 text-emerald-600" />
<span>
<span className="font-semibold">{name}</span> allowed
</span>
</li>
))}
{workstreamsOff.map((name) => (
<li
key={name}
className="flex items-start gap-1.5 text-[11px] text-gray-500"
>
<Minus className="mt-px h-3 w-3 shrink-0 text-gray-400" />
<span>
<span className="font-semibold">{name}</span> not allowed
</span>
</li>
))}
</ul>
<p className="border-t border-gray-100 bg-gray-50 px-3 py-2 text-[11px] text-gray-600">
Turning the switch on grants it on{" "}
<span className="font-semibold">
all {total} {plural}
</span>
; turning it off revokes it from all {total}. This screen always
sets one value across everything a contractor delivers, so
saving here clears the split.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>
);
}