Add project work areas to Kanban and Plan
This commit is contained in:
parent
8452c9d5e7
commit
20639264bd
43 changed files with 729 additions and 47 deletions
|
|
@ -13,6 +13,7 @@
|
|||
"seed": "tsx scripts/seed-demo.ts",
|
||||
"setup-remote": "tsx scripts/setup-remote.ts",
|
||||
"setup-plan-schema": "tsx scripts/apply-plan-schema.ts",
|
||||
"setup-work-area-schema": "tsx scripts/apply-work-area-schema.ts",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
20
scripts/apply-work-area-schema.ts
Normal file
20
scripts/apply-work-area-schema.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import PocketBase from "pocketbase";
|
||||
|
||||
import { ensureWorkAreaSchema } from "./work-area-schema";
|
||||
|
||||
async function main() {
|
||||
const url = process.env.NEXT_PUBLIC_PB_URL;
|
||||
const email = process.env.PB_SUPERUSER_EMAIL;
|
||||
const password = process.env.PB_SUPERUSER_PASSWORD;
|
||||
if (!url || !email || !password) throw new Error("PocketBase URL and superuser credentials are required");
|
||||
const pb = new PocketBase(url);
|
||||
pb.autoCancellation(false);
|
||||
await pb.collection("_superusers").authWithPassword(email, password);
|
||||
const changed = await ensureWorkAreaSchema(pb);
|
||||
console.log(changed.length ? `Added ${changed.join(", ")}` : "Work area schema already exists");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
import PocketBase from "pocketbase";
|
||||
import { ensureProjectPlanDataField } from "./plan-schema";
|
||||
import { ensureWorkAreaSchema } from "./work-area-schema";
|
||||
|
||||
const PB_URL =
|
||||
process.env.NEXT_PUBLIC_PB_URL ??
|
||||
|
|
@ -1558,6 +1559,7 @@ async function main(): Promise<void> {
|
|||
console.log((await ensureProjectPlanDataField(pb))
|
||||
? " + projects field: plan_data"
|
||||
: " = projects.plan_data already present");
|
||||
for (const change of await ensureWorkAreaSchema(pb)) console.log(` + ${change}`);
|
||||
|
||||
// Add the per-commit summary fields used by Phase 3b's AI one-liner job.
|
||||
await ensureCommitSummaryFields(pb);
|
||||
|
|
|
|||
41
scripts/work-area-schema.ts
Normal file
41
scripts/work-area-schema.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type PocketBase from "pocketbase";
|
||||
|
||||
/** Targeted, repeatable schema addition for task areas. Existing card rows are untouched. */
|
||||
export async function ensureWorkAreaSchema(pb: PocketBase): Promise<string[]> {
|
||||
const changed: string[] = [];
|
||||
const projects = await pb.collections.getOne("projects");
|
||||
let areas = await pb.collections.getOne("work_areas").catch(() => null);
|
||||
if (!areas) {
|
||||
areas = await pb.collections.create({
|
||||
name: "work_areas",
|
||||
type: "base",
|
||||
listRule: '@request.auth.id != "" && (@request.auth.role = "owner" || project.client = @request.auth.client)',
|
||||
viewRule: '@request.auth.id != "" && (@request.auth.role = "owner" || project.client = @request.auth.client)',
|
||||
createRule: '@request.auth.role = "owner"',
|
||||
updateRule: '@request.auth.role = "owner"',
|
||||
deleteRule: '@request.auth.role = "owner"',
|
||||
fields: [
|
||||
{ name: "project", type: "relation", required: true, collectionId: projects.id, maxSelect: 1, cascadeDelete: true },
|
||||
{ name: "name", type: "text", required: true, max: 80, presentable: true },
|
||||
{ name: "color", type: "text", required: true, max: 20 },
|
||||
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: ["CREATE UNIQUE INDEX idx_work_areas_project_name ON work_areas (project, lower(name))"],
|
||||
});
|
||||
changed.push("work_areas collection");
|
||||
}
|
||||
if (!areas) throw new Error("Could not create work_areas");
|
||||
|
||||
const cards = await pb.collections.getOne("cards");
|
||||
if (!cards.fields.some((field: { name: string }) => field.name === "work_area")) {
|
||||
await pb.collections.update(cards.id, {
|
||||
fields: [...(cards.fields as unknown[]), {
|
||||
name: "work_area", type: "relation", required: false,
|
||||
collectionId: areas.id, maxSelect: 1, cascadeDelete: false,
|
||||
}],
|
||||
});
|
||||
changed.push("cards.work_area field");
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import { logActivity } from "@/app/actions/activity";
|
|||
import { positionBetween } from "@/lib/ordering/fractional";
|
||||
import { nextCardNumber } from "@/lib/cards/number";
|
||||
import { relinkProject } from "@/lib/linking/engine";
|
||||
import { resolveWorkArea } from "@/lib/work-areas/server";
|
||||
import type { WorkAreaChoice } from "@/lib/work-areas/core";
|
||||
import type { CardRecord, ColumnRecord } from "@/lib/pb/types";
|
||||
|
||||
/**
|
||||
|
|
@ -41,12 +43,14 @@ export async function createCard(input: {
|
|||
title: string;
|
||||
clientSlug: string;
|
||||
projectSlug: string;
|
||||
}): Promise<{ id: string }> {
|
||||
} & WorkAreaChoice): Promise<{ id: string }> {
|
||||
const session = await requireOwner();
|
||||
const pb = await getServerPb();
|
||||
|
||||
const title = input.title.trim().slice(0, 200);
|
||||
if (!title) throw new Error("A title is required");
|
||||
if (!input.areaId && !input.newAreaName?.trim()) throw new Error("Choose an area");
|
||||
const workArea = await resolveWorkArea(pb, input.projectId, input);
|
||||
|
||||
const last = await pb
|
||||
.collection("cards")
|
||||
|
|
@ -58,6 +62,7 @@ export async function createCard(input: {
|
|||
const card = await pb.collection("cards").create<CardRecord>({
|
||||
project: input.projectId,
|
||||
column: input.columnId,
|
||||
work_area: workArea,
|
||||
title,
|
||||
description: "",
|
||||
position: positionBetween(last ? last.position : null, null),
|
||||
|
|
@ -238,4 +243,4 @@ export async function unarchiveCard(input: {
|
|||
|
||||
revalidatePath(boardPath(input.clientSlug, input.projectSlug));
|
||||
revalidatePath(`${boardPath(input.clientSlug, input.projectSlug)}/archive`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
57
src/app/api/cards/[id]/area/route.ts
Normal file
57
src/app/api/cards/[id]/area/route.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { requireSession } from "@/lib/auth/session";
|
||||
import { canReadClient } from "@/lib/auth/access";
|
||||
import { getAdminPb } from "@/lib/pb/admin";
|
||||
import { jsonError, jsonOk } from "@/lib/http/json";
|
||||
import { listWorkAreas, resolveWorkArea } from "@/lib/work-areas/server";
|
||||
import type { CardRecord, ProjectRecord } from "@/lib/pb/types";
|
||||
|
||||
/** The only editable card field here is its project-scoped area relation. */
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const session = await requireSession();
|
||||
const { id } = await context.params;
|
||||
const body: unknown = await request.json().catch(() => null);
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return jsonError("Invalid request body", 400);
|
||||
const choice = body as Record<string, unknown>;
|
||||
if ((choice.areaId !== undefined && typeof choice.areaId !== "string") ||
|
||||
(choice.newAreaName !== undefined && typeof choice.newAreaName !== "string")) {
|
||||
return jsonError("Invalid area", 400);
|
||||
}
|
||||
|
||||
const pb = await getAdminPb();
|
||||
const card = await pb.collection("cards").getOne<CardRecord>(id).catch(() => null);
|
||||
if (!card) return jsonError("Card not found", 404);
|
||||
const project = await pb.collection("projects").getOne<ProjectRecord>(card.project).catch(() => null);
|
||||
if (!project || !canReadClient(session, project.client)) return jsonError("Card not found", 404);
|
||||
|
||||
const workArea = await resolveWorkArea(pb, project.id, {
|
||||
areaId: choice.areaId as string | undefined,
|
||||
newAreaName: choice.newAreaName as string | undefined,
|
||||
}).catch(() => null);
|
||||
if (workArea === null) return jsonError("Area not found in this project", 400);
|
||||
|
||||
if (card.work_area !== workArea) {
|
||||
await pb.collection("cards").update(id, { work_area: workArea });
|
||||
const areas = await listWorkAreas(pb, project.id);
|
||||
await pb.collection("activity").create({
|
||||
project: project.id,
|
||||
card: id,
|
||||
actor: session.userId,
|
||||
action: "card_updated",
|
||||
field: "work_area",
|
||||
old_value: areas.find((area) => area.id === card.work_area)?.name ?? "",
|
||||
new_value: areas.find((area) => area.id === workArea)?.name ?? "",
|
||||
});
|
||||
return jsonOk({ area: areas.find((area) => area.id === workArea) ?? null });
|
||||
}
|
||||
|
||||
const areas = await listWorkAreas(pb, project.id);
|
||||
return jsonOk({ area: areas.find((area) => area.id === workArea) ?? null });
|
||||
} catch (error) {
|
||||
console.error("[card-area] failed", error);
|
||||
return jsonError("Could not save area", 500);
|
||||
}
|
||||
}
|
||||
37
src/app/api/cards/area-route.test.ts
Normal file
37
src/app/api/cards/area-route.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PATCH } from "@/app/api/cards/[id]/area/route";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { getAdminPb } from "@/lib/pb/admin";
|
||||
import { resolveWorkArea } from "@/lib/work-areas/server";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ requireSession: vi.fn() }));
|
||||
vi.mock("@/lib/pb/admin", () => ({ getAdminPb: vi.fn() }));
|
||||
vi.mock("@/lib/work-areas/server", () => ({ resolveWorkArea: vi.fn(), listWorkAreas: vi.fn() }));
|
||||
|
||||
describe("card area route", () => {
|
||||
const update = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireSession).mockResolvedValue({ userId: "client-user", role: "client_manager", clientId: "client-a" });
|
||||
vi.mocked(getAdminPb).mockResolvedValue({
|
||||
collection: (name: string) => ({
|
||||
getOne: async () => name === "cards"
|
||||
? { id: "card-1", project: "project-b", work_area: "" }
|
||||
: { id: "project-b", client: "client-b" },
|
||||
update,
|
||||
}),
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("cannot assign a card belonging to another client, even with a known card id", async () => {
|
||||
const response = await PATCH(new Request("http://localhost/api/cards/card-1/area", {
|
||||
method: "PATCH", body: JSON.stringify({ newAreaName: "Delivery Notes" }),
|
||||
}), { params: Promise.resolve({ id: "card-1" }) });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(resolveWorkArea).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import { pickClientCardCreate } from "@/lib/cards/client-patch";
|
|||
import { nextCardNumber } from "@/lib/cards/number";
|
||||
import { positionBetween } from "@/lib/ordering/fractional";
|
||||
import { jsonError, jsonOk } from "@/lib/http/json";
|
||||
import { resolveWorkArea } from "@/lib/work-areas/server";
|
||||
import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types";
|
||||
|
||||
/**
|
||||
|
|
@ -67,6 +68,8 @@ export async function POST(request: Request): Promise<Response> {
|
|||
if (!input) {
|
||||
return jsonError("A title is required", 400);
|
||||
}
|
||||
const workArea = await resolveWorkArea(pb, projectId, input).catch(() => null);
|
||||
if (workArea === null) return jsonError("Area not found in this project", 400);
|
||||
|
||||
const inbox = await pb
|
||||
.collection("columns")
|
||||
|
|
@ -98,6 +101,7 @@ export async function POST(request: Request): Promise<Response> {
|
|||
const card = await pb.collection("cards").create<CardRecord>({
|
||||
project: projectId,
|
||||
column: inbox.id,
|
||||
work_area: workArea,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
position: positionBetween(last ? last.position : null, null),
|
||||
|
|
@ -127,4 +131,4 @@ export async function POST(request: Request): Promise<Response> {
|
|||
console.error("[client-create] unexpected error", error);
|
||||
return jsonError("Something went wrong", 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { nextCardNumber } from "@/lib/cards/number";
|
|||
import { parseBrief } from "@/lib/intake/brief";
|
||||
import { sanitiseTurns } from "@/lib/intake/turn";
|
||||
import { jsonError, jsonOk } from "@/lib/http/json";
|
||||
import { resolveWorkArea } from "@/lib/work-areas/server";
|
||||
import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types";
|
||||
|
||||
/**
|
||||
|
|
@ -61,7 +62,6 @@ export async function POST(request: Request): Promise<Response> {
|
|||
if (!canWriteClientFields(session, project.client)) {
|
||||
return jsonError("Project not found", 404);
|
||||
}
|
||||
|
||||
// Re-parsed, not trusted. The browser could send anything here.
|
||||
const turns = sanitiseTurns(record.transcript as never);
|
||||
const brief = parseBrief(
|
||||
|
|
@ -83,6 +83,12 @@ export async function POST(request: Request): Promise<Response> {
|
|||
return jsonError("This project cannot accept new cards", 409);
|
||||
}
|
||||
|
||||
const workArea = await resolveWorkArea(pb, projectId, {
|
||||
areaId: record.areaId as string | undefined,
|
||||
newAreaName: record.newAreaName as string | undefined,
|
||||
}).catch(() => null);
|
||||
if (workArea === null) return jsonError("Area not found in this project", 400);
|
||||
|
||||
const last = await pb
|
||||
.collection("cards")
|
||||
.getFirstListItem<CardRecord>(`column = "${inbox.id}"`, { sort: "-position" })
|
||||
|
|
@ -97,6 +103,7 @@ export async function POST(request: Request): Promise<Response> {
|
|||
project: projectId,
|
||||
// Every field below is chosen here, not taken from the request.
|
||||
column: inbox.id,
|
||||
work_area: workArea,
|
||||
title: brief.title,
|
||||
description: brief.summary,
|
||||
position: positionBetween(last ? last.position : null, null),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from "@/lib/intake-links";
|
||||
import { resolveProjectForClient } from "@/lib/intake-links/client-projects";
|
||||
import { jsonError, jsonOk } from "@/lib/http/json";
|
||||
import { resolveWorkArea } from "@/lib/work-areas/server";
|
||||
import type { CardRecord, ColumnRecord } from "@/lib/pb/types";
|
||||
|
||||
/**
|
||||
|
|
@ -109,6 +110,12 @@ export async function POST(request: Request): Promise<Response> {
|
|||
return jsonError("This project cannot accept new cards", 409);
|
||||
}
|
||||
|
||||
const workArea = await resolveWorkArea(pb, resolvedProjectId, {
|
||||
areaId: record.areaId as string | undefined,
|
||||
newAreaName: record.newAreaName as string | undefined,
|
||||
}).catch(() => null);
|
||||
if (workArea === null) return jsonError("Area not found in this project", 400);
|
||||
|
||||
const last = await pb
|
||||
.collection("cards")
|
||||
.getFirstListItem<CardRecord>(`column = "${inbox.id}"`, {
|
||||
|
|
@ -125,6 +132,7 @@ export async function POST(request: Request): Promise<Response> {
|
|||
project: resolvedProjectId,
|
||||
// Every field below is chosen here, not taken from the request.
|
||||
column: inbox.id,
|
||||
work_area: workArea,
|
||||
title: brief.title,
|
||||
description: brief.summary,
|
||||
position: positionBetween(last ? last.position : null, null),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { getAdminPb } from "@/lib/pb/admin";
|
|||
import { IntakePublic } from "@/components/intake/intake-public";
|
||||
import { resolveIntakeLink } from "@/lib/intake-links";
|
||||
import { resolveProjectForClient } from "@/lib/intake-links/client-projects";
|
||||
import type { ProjectRecord } from "@/lib/pb/types";
|
||||
import type { ProjectRecord, WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
||||
/**
|
||||
* The wizard for a Phase 9 client-bound link.
|
||||
|
|
@ -53,6 +53,9 @@ export default async function IntakeWizardPage({
|
|||
const project = await pb
|
||||
.collection("projects")
|
||||
.getOne<ProjectRecord>(resolvedProjectId);
|
||||
const areas = await pb.collection("work_areas").getFullList<WorkAreaRecord>({
|
||||
filter: pb.filter("project = {:project}", { project: project.id }), sort: "created,name",
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-xl space-y-4 p-6">
|
||||
|
|
@ -65,6 +68,7 @@ export default async function IntakeWizardPage({
|
|||
projectId={project.id}
|
||||
projectName={project.name}
|
||||
token={token}
|
||||
areas={areas}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { CommentThread } from "@/components/card/comment-thread";
|
|||
import { ActivityTimeline } from "@/components/card/activity-timeline";
|
||||
import { Pill } from "@/components/ui/pill";
|
||||
import { CardSubmittedBy } from "@/components/card/card-submitted-by";
|
||||
import { CardAreaSection } from "@/components/work-areas/card-area-section";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { loadCardDetail } from "@/lib/data/card";
|
||||
|
||||
|
|
@ -70,6 +71,8 @@ export default async function CardPage({
|
|||
projectSlug={projectSlug}
|
||||
/>
|
||||
|
||||
<CardAreaSection cardId={detail.card.id} areaId={detail.card.work_area || ""} areas={detail.areas} />
|
||||
|
||||
<CardBrief
|
||||
brief={detail.card.brief}
|
||||
transcript={detail.card.transcript}
|
||||
|
|
@ -101,4 +104,4 @@ export default async function CardPage({
|
|||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export default async function BoardPage({
|
|||
projectSlug={projectSlug}
|
||||
columns={board.columns}
|
||||
initialCards={board.cards}
|
||||
areas={board.areas}
|
||||
// Pass the AI availability through so the client manager's Inbox shows
|
||||
// the wizard when configured, the plain box when not. aiConfigured
|
||||
// returns only whether a key exists — never the key itself.
|
||||
|
|
@ -65,4 +66,4 @@ export default async function BoardPage({
|
|||
/>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@
|
|||
.plan-timeline-table { min-width: 100%; }
|
||||
.plan-timeline-header, .plan-timeline-row { display: flex; }
|
||||
.plan-timeline-header { height: 67px; border-bottom: 1px solid var(--border-strong); background: var(--surface); }
|
||||
.plan-area-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-height: 35px; padding: 5px 16px; border-bottom: 1px solid var(--border-subtle); background: var(--surface); }
|
||||
.plan-area-legend > span:first-child { margin-right: 3px; color: var(--text-muted); font-size: 9px; font-weight: 800; letter-spacing: .11em; }
|
||||
.plan-sticky-label { position: sticky; left: 0; z-index: 4; flex: none; width: 300px; border-right: 1px solid var(--border-subtle); background: var(--panel); }
|
||||
.plan-label-heading { display: flex; align-items: flex-end; justify-content: space-between; padding: 0 17px 16px; background: var(--surface); color: var(--text-muted); font-size: 10px; font-weight: 800; letter-spacing: .1em; }
|
||||
.plan-label-heading span { font-size: 9px; font-weight: 600; letter-spacing: 0; }
|
||||
|
|
@ -101,6 +103,9 @@
|
|||
.plan-card-title { min-width: 0; overflow-wrap: anywhere; font-size: 12px; font-weight: 700; line-height: 1.35; }
|
||||
.plan-card-subline { display: flex; flex-wrap: wrap; gap: 4px 8px; margin: 5px 0 0 15px; color: var(--text-muted); font-size: 10px; }
|
||||
.plan-card-subline span + span::before { content: "·"; margin-right: 8px; color: var(--border-strong); }
|
||||
.plan-card-subline .plan-row-area { display: inline-flex; align-items: center; gap: 4px; max-width: 190px; overflow: hidden; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.plan-card-subline .plan-row-area i { width: 7px; height: 7px; flex: none; border-radius: 2px; }
|
||||
.plan-card-subline .plan-row-area + span::before { content: ""; margin: 0; }
|
||||
.plan-card-subline .plan-needs-change { color: var(--accent); font-weight: 800; }
|
||||
.plan-row-edit { position: absolute; right: 11px; top: 50%; display: grid; width: 24px; height: 24px; place-items: center; border-radius: 6px; color: var(--text-muted); opacity: .55; transform: translateY(-50%); }
|
||||
.plan-row-edit:hover { background: var(--surface-raised); color: var(--brand); opacity: 1; }
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import { useRouter } from "next/navigation";
|
|||
import { Plus } from "lucide-react";
|
||||
|
||||
import { createCard } from "@/app/actions/cards";
|
||||
import type { Role } from "@/lib/pb/types";
|
||||
import { WorkAreaPicker } from "@/components/work-areas/work-area-picker";
|
||||
import { cleanWorkAreaName, type WorkAreaChoice } from "@/lib/work-areas/core";
|
||||
import type { Role, WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
||||
export function AddCard({
|
||||
role,
|
||||
|
|
@ -13,16 +15,19 @@ export function AddCard({
|
|||
columnId,
|
||||
clientSlug,
|
||||
projectSlug,
|
||||
areas,
|
||||
}: {
|
||||
role: Role;
|
||||
projectId: string;
|
||||
columnId: string;
|
||||
clientSlug: string;
|
||||
projectSlug: string;
|
||||
areas: WorkAreaRecord[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [areaChoice, setAreaChoice] = useState<WorkAreaChoice>({});
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -32,22 +37,31 @@ export function AddCard({
|
|||
async function submit(event: { preventDefault: () => void }) {
|
||||
event.preventDefault();
|
||||
const value = title.trim();
|
||||
if (!value) return;
|
||||
if (!value || (!areaChoice.areaId && !cleanWorkAreaName(areaChoice.newAreaName))) {
|
||||
setError("Add a title and choose an area");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
if (role === "owner") {
|
||||
setTitle("");
|
||||
setOpen(false);
|
||||
startTransition(() =>
|
||||
createCard({
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await createCard({
|
||||
projectId,
|
||||
columnId,
|
||||
title: value,
|
||||
clientSlug,
|
||||
projectSlug,
|
||||
}).then(() => undefined)
|
||||
);
|
||||
...areaChoice,
|
||||
});
|
||||
setTitle("");
|
||||
setAreaChoice({});
|
||||
setOpen(false);
|
||||
} catch {
|
||||
setError("Could not add that. Please try again.");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +70,7 @@ export function AddCard({
|
|||
const response = await fetch("/api/client/cards", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ project: projectId, title: value }),
|
||||
body: JSON.stringify({ project: projectId, title: value, ...areaChoice }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -65,6 +79,7 @@ export function AddCard({
|
|||
}
|
||||
|
||||
setTitle("");
|
||||
setAreaChoice({});
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
|
@ -105,6 +120,10 @@ export function AddCard({
|
|||
className="w-full resize-none bg-transparent text-[13px] leading-[1.45] text-text-primary outline-none placeholder:text-text-muted"
|
||||
/>
|
||||
|
||||
<div className="py-2">
|
||||
<WorkAreaPicker areas={areas} value={areaChoice} onChange={setAreaChoice} disabled={pending} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="px-0.5 pb-1 text-[11px] text-accent">
|
||||
{error}
|
||||
|
|
@ -114,7 +133,7 @@ export function AddCard({
|
|||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending || !title.trim()}
|
||||
disabled={pending || !title.trim() || (!areaChoice.areaId && !cleanWorkAreaName(areaChoice.newAreaName))}
|
||||
className="rounded-md bg-brand px-2.5 py-1 text-xs font-medium text-brand-fg disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
|
|
@ -129,4 +148,4 @@ export function AddCard({
|
|||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,18 +24,22 @@ import { BoardColumn } from "@/components/board/board-column";
|
|||
import { CardTile } from "@/components/board/card-tile";
|
||||
import { ClientCardControls } from "@/components/board/client-card-controls";
|
||||
import { RequestFeature } from "@/components/intake/request-feature";
|
||||
import { AreaFilter } from "@/components/work-areas/area-filter";
|
||||
import { workAreaForCard } from "@/lib/work-areas/core";
|
||||
import { useRefreshOnFocus } from "@/components/board/use-refresh-on-focus";
|
||||
import { computeDropPosition } from "@/lib/board/move";
|
||||
import type { CardRecord, ColumnRecord, Role } from "@/lib/pb/types";
|
||||
import type { CardRecord, ColumnRecord, Role, WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
||||
function SortableCard({
|
||||
card,
|
||||
area,
|
||||
role,
|
||||
clientSlug,
|
||||
projectSlug,
|
||||
onPatched,
|
||||
}: {
|
||||
card: CardRecord;
|
||||
area: WorkAreaRecord | null;
|
||||
role: Role;
|
||||
clientSlug: string;
|
||||
projectSlug: string;
|
||||
|
|
@ -49,6 +53,7 @@ function SortableCard({
|
|||
<CardTile
|
||||
ref={setNodeRef}
|
||||
card={card}
|
||||
area={area}
|
||||
dragging={isDragging}
|
||||
href={`/p/${clientSlug}/${projectSlug}/c/${card.id}`}
|
||||
style={{ transform: CSS.Translate.toString(transform), transition }}
|
||||
|
|
@ -93,6 +98,7 @@ export function Board({
|
|||
projectSlug,
|
||||
columns,
|
||||
initialCards,
|
||||
areas,
|
||||
assistantAvailable,
|
||||
}: {
|
||||
role: Role;
|
||||
|
|
@ -101,10 +107,12 @@ export function Board({
|
|||
projectSlug: string;
|
||||
columns: ColumnRecord[];
|
||||
initialCards: CardRecord[];
|
||||
areas: WorkAreaRecord[];
|
||||
/** True when the AI provider is configured — controls the wizard vs plain box. */
|
||||
assistantAvailable: boolean;
|
||||
}) {
|
||||
const [cards, setCards] = useState<CardRecord[]>(initialCards);
|
||||
const [areaFilter, setAreaFilter] = useState("all");
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
|
|
@ -161,9 +169,14 @@ export function Board({
|
|||
const [stale, setStale] = useState(true);
|
||||
useEffect(() => {
|
||||
// Adopt the server snapshot on first mount, before any drag has touched
|
||||
// local state. After that, the user's edits are authoritative until the
|
||||
// component unmounts.
|
||||
// local state. After a drag, keep moved cards authoritative but still
|
||||
// admit newly created cards from a refreshed server snapshot.
|
||||
if (stale) setCards(initialCards);
|
||||
else setCards((current) => {
|
||||
const known = new Set(current.map((card) => card.id));
|
||||
const added = initialCards.filter((card) => !known.has(card.id));
|
||||
return added.length ? [...current, ...added] : current;
|
||||
});
|
||||
}, [initialCards, stale]);
|
||||
|
||||
function onDragStart(event: DragStartEvent) {
|
||||
|
|
@ -251,9 +264,11 @@ export function Board({
|
|||
}
|
||||
|
||||
const content = (
|
||||
<div className="flex h-full gap-3 overflow-x-auto p-6">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<AreaFilter areas={areas} cards={cards} value={areaFilter} onChange={setAreaFilter} />
|
||||
<div className="flex min-h-0 flex-1 gap-3 overflow-x-auto p-6">
|
||||
{columns.map((column) => {
|
||||
const list = byColumn.get(column.id) ?? [];
|
||||
const list = (byColumn.get(column.id) ?? []).filter((card) => areaFilter === "all" || (areaFilter === "unassigned" ? !workAreaForCard(card, areas) : card.work_area === areaFilter));
|
||||
return (
|
||||
<Droppable key={column.id} column={column} count={list.length}>
|
||||
<SortableContext
|
||||
|
|
@ -264,6 +279,7 @@ export function Board({
|
|||
<SortableCard
|
||||
key={card.id}
|
||||
card={card}
|
||||
area={workAreaForCard(card, areas)}
|
||||
role={role}
|
||||
clientSlug={clientSlug}
|
||||
projectSlug={projectSlug}
|
||||
|
|
@ -278,6 +294,7 @@ export function Board({
|
|||
columnId={column.id}
|
||||
clientSlug={clientSlug}
|
||||
projectSlug={projectSlug}
|
||||
areas={areas}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -288,11 +305,13 @@ export function Board({
|
|||
clientSlug={clientSlug}
|
||||
projectSlug={projectSlug}
|
||||
assistantAvailable={assistantAvailable}
|
||||
areas={areas}
|
||||
/>
|
||||
)}
|
||||
</Droppable>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -319,9 +338,9 @@ export function Board({
|
|||
>
|
||||
{content}
|
||||
<DragOverlay>
|
||||
{activeCard && <CardTile card={activeCard} dragging />}
|
||||
{activeCard && <CardTile card={activeCard} area={workAreaForCard(activeCard, areas)} dragging />}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import { Inbox, CalendarDays, Maximize2 } from "lucide-react";
|
|||
|
||||
import { Pill } from "@/components/ui/pill";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { WorkAreaBadge } from "@/components/work-areas/work-area-badge";
|
||||
import { classifyDueDate, formatDueDate } from "@/lib/board/dates";
|
||||
import type { CardRecord, Priority } from "@/lib/pb/types";
|
||||
import type { CardRecord, Priority, WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
||||
const PRIORITY_TONE: Record<Priority, "critical" | "high" | "medium" | "low"> = {
|
||||
critical: "critical",
|
||||
|
|
@ -27,12 +28,13 @@ export const CardTile = forwardRef<
|
|||
HTMLDivElement,
|
||||
{
|
||||
card: CardRecord;
|
||||
area?: Pick<WorkAreaRecord, "name" | "color"> | null;
|
||||
dragging?: boolean;
|
||||
href?: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
>(function CardTile({ card, dragging, href, className, children, ...props }, ref) {
|
||||
>(function CardTile({ card, area, dragging, href, className, children, style, ...props }, ref) {
|
||||
const due = classifyDueDate(card.target_date);
|
||||
const dueLabel = formatDueDate(card.target_date);
|
||||
|
||||
|
|
@ -45,6 +47,7 @@ export const CardTile = forwardRef<
|
|||
dragging && "shadow-[var(--shadow-drag)] opacity-90",
|
||||
className
|
||||
)}
|
||||
style={{ ...(area ? { borderLeftColor: area.color, borderLeftWidth: 3 } : {}), ...style }}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
|
|
@ -66,6 +69,7 @@ export const CardTile = forwardRef<
|
|||
</div>
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
|
||||
{area && <WorkAreaBadge area={area} compact />}
|
||||
<Pill tone={PRIORITY_TONE[card.priority]}>
|
||||
{PRIORITY_LABEL[card.priority]}
|
||||
</Pill>
|
||||
|
|
@ -90,4 +94,4 @@ export const CardTile = forwardRef<
|
|||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import { useState } from "react";
|
|||
import { Send } from "lucide-react";
|
||||
|
||||
import { Pill } from "@/components/ui/pill";
|
||||
import { WorkAreaPicker } from "@/components/work-areas/work-area-picker";
|
||||
import { cleanWorkAreaName, type WorkAreaChoice } from "@/lib/work-areas/core";
|
||||
import type { WorkAreaRecord } from "@/lib/pb/types";
|
||||
import type { Brief } from "@/lib/intake/brief";
|
||||
import type { SizeBand } from "@/lib/intake/size";
|
||||
import type { Turn } from "@/lib/intake/turn";
|
||||
|
|
@ -29,15 +32,24 @@ export function BriefPreviewPublic({
|
|||
brief,
|
||||
turns,
|
||||
token,
|
||||
projectId,
|
||||
areas,
|
||||
}: {
|
||||
brief: Brief & { sizeBand: SizeBand };
|
||||
turns: Turn[];
|
||||
token: string;
|
||||
projectId: string;
|
||||
areas: WorkAreaRecord[];
|
||||
}) {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [areaChoice, setAreaChoice] = useState<WorkAreaChoice>({});
|
||||
|
||||
async function submit() {
|
||||
if (!areaChoice.areaId && !cleanWorkAreaName(areaChoice.newAreaName)) {
|
||||
setError("Choose an area for this request.");
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
setError(null);
|
||||
|
||||
|
|
@ -46,9 +58,11 @@ export function BriefPreviewPublic({
|
|||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
projectId,
|
||||
brief,
|
||||
transcript: turns,
|
||||
sizeBand: brief.sizeBand,
|
||||
...areaChoice,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -112,10 +126,12 @@ export function BriefPreviewPublic({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<WorkAreaPicker areas={areas} value={areaChoice} onChange={setAreaChoice} disabled={sending} />
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-border-subtle pt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={sending}
|
||||
disabled={sending || (!areaChoice.areaId && !cleanWorkAreaName(areaChoice.newAreaName))}
|
||||
onClick={submit}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import { useRouter } from "next/navigation";
|
|||
import { Send } from "lucide-react";
|
||||
|
||||
import { Pill } from "@/components/ui/pill";
|
||||
import { WorkAreaPicker } from "@/components/work-areas/work-area-picker";
|
||||
import { cleanWorkAreaName, type WorkAreaChoice } from "@/lib/work-areas/core";
|
||||
import type { WorkAreaRecord } from "@/lib/pb/types";
|
||||
import type { Brief } from "@/lib/intake/brief";
|
||||
import type { SizeBand } from "@/lib/intake/size";
|
||||
import type { Turn } from "@/lib/intake/turn";
|
||||
|
|
@ -28,18 +31,25 @@ export function BriefPreview({
|
|||
projectId,
|
||||
clientSlug,
|
||||
projectSlug,
|
||||
areas,
|
||||
}: {
|
||||
brief: Brief & { sizeBand: SizeBand };
|
||||
turns: Turn[];
|
||||
projectId: string;
|
||||
clientSlug: string;
|
||||
projectSlug: string;
|
||||
areas: WorkAreaRecord[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [areaChoice, setAreaChoice] = useState<WorkAreaChoice>({});
|
||||
|
||||
async function submit() {
|
||||
if (!areaChoice.areaId && !cleanWorkAreaName(areaChoice.newAreaName)) {
|
||||
setError("Choose an area for this request.");
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
setError(null);
|
||||
|
||||
|
|
@ -51,6 +61,7 @@ export function BriefPreview({
|
|||
brief,
|
||||
transcript: turns,
|
||||
sizeBand: brief.sizeBand,
|
||||
...areaChoice,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -114,10 +125,12 @@ export function BriefPreview({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<WorkAreaPicker areas={areas} value={areaChoice} onChange={setAreaChoice} disabled={sending} />
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-border-subtle pt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={sending}
|
||||
disabled={sending || (!areaChoice.areaId && !cleanWorkAreaName(areaChoice.newAreaName))}
|
||||
onClick={submit}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Sparkles, ArrowRight } from "lucide-react";
|
|||
import { BriefPreviewPublic } from "@/components/intake/brief-preview-public";
|
||||
import { MAX_QUESTIONS, type Turn } from "@/lib/intake/turn";
|
||||
import type { InterviewStep } from "@/lib/intake/interview";
|
||||
import type { WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
||||
/**
|
||||
* The public route's interview flow.
|
||||
|
|
@ -20,10 +21,12 @@ export function IntakePublic({
|
|||
projectId,
|
||||
projectName,
|
||||
token,
|
||||
areas,
|
||||
}: {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
token: string;
|
||||
areas: WorkAreaRecord[];
|
||||
}) {
|
||||
const [turns, setTurns] = useState<Turn[]>([]);
|
||||
const [question, setQuestion] = useState<string | null>(null);
|
||||
|
|
@ -59,6 +62,8 @@ export function IntakePublic({
|
|||
brief={step.brief}
|
||||
turns={turns}
|
||||
token={token}
|
||||
projectId={projectId}
|
||||
areas={areas}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,18 +7,21 @@ import { intakeStepAction } from "@/app/actions/intake";
|
|||
import { BriefPreview } from "@/components/intake/brief-preview";
|
||||
import { MAX_QUESTIONS, type Turn } from "@/lib/intake/turn";
|
||||
import type { InterviewStep } from "@/lib/intake/interview";
|
||||
import type { WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
||||
export function IntakeWizard({
|
||||
projectId,
|
||||
clientSlug,
|
||||
projectSlug,
|
||||
onFallback,
|
||||
areas,
|
||||
}: {
|
||||
projectId: string;
|
||||
clientSlug: string;
|
||||
projectSlug: string;
|
||||
/** Shown when the assistant is unavailable — the plain request box. */
|
||||
onFallback: () => void;
|
||||
areas: WorkAreaRecord[];
|
||||
}) {
|
||||
const [turns, setTurns] = useState<Turn[]>([]);
|
||||
const [question, setQuestion] = useState<string | null>(null);
|
||||
|
|
@ -47,6 +50,7 @@ export function IntakeWizard({
|
|||
projectId={projectId}
|
||||
clientSlug={clientSlug}
|
||||
projectSlug={projectSlug}
|
||||
areas={areas}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Sparkles } from "lucide-react";
|
|||
|
||||
import { AddCard } from "@/components/board/add-card";
|
||||
import { IntakeWizard } from "@/components/intake/intake-wizard";
|
||||
import type { WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
||||
/**
|
||||
* The owner's two ways to add work.
|
||||
|
|
@ -27,12 +28,14 @@ export function OwnerAdd({
|
|||
clientSlug,
|
||||
projectSlug,
|
||||
assistantAvailable,
|
||||
areas,
|
||||
}: {
|
||||
projectId: string;
|
||||
columnId: string;
|
||||
clientSlug: string;
|
||||
projectSlug: string;
|
||||
assistantAvailable: boolean;
|
||||
areas: WorkAreaRecord[];
|
||||
}) {
|
||||
const [planning, setPlanning] = useState(false);
|
||||
|
||||
|
|
@ -44,6 +47,7 @@ export function OwnerAdd({
|
|||
clientSlug={clientSlug}
|
||||
projectSlug={projectSlug}
|
||||
onFallback={() => setPlanning(false)}
|
||||
areas={areas}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -64,6 +68,7 @@ export function OwnerAdd({
|
|||
columnId={columnId}
|
||||
clientSlug={clientSlug}
|
||||
projectSlug={projectSlug}
|
||||
areas={areas}
|
||||
/>
|
||||
|
||||
{assistantAvailable && (
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState } from "react";
|
|||
|
||||
import { IntakeWizard } from "@/components/intake/intake-wizard";
|
||||
import { AddCard } from "@/components/board/add-card";
|
||||
import type { WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
||||
/**
|
||||
* The client's way in: the wizard when the assistant is available, the plain
|
||||
|
|
@ -18,12 +19,14 @@ export function RequestFeature({
|
|||
clientSlug,
|
||||
projectSlug,
|
||||
assistantAvailable,
|
||||
areas,
|
||||
}: {
|
||||
projectId: string;
|
||||
columnId: string;
|
||||
clientSlug: string;
|
||||
projectSlug: string;
|
||||
assistantAvailable: boolean;
|
||||
areas: WorkAreaRecord[];
|
||||
}) {
|
||||
// Flips if the interview reports the provider unavailable mid-flow.
|
||||
const [fallback, setFallback] = useState(!assistantAvailable);
|
||||
|
|
@ -36,6 +39,7 @@ export function RequestFeature({
|
|||
columnId={columnId}
|
||||
clientSlug={clientSlug}
|
||||
projectSlug={projectSlug}
|
||||
areas={areas}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -46,6 +50,7 @@ export function RequestFeature({
|
|||
clientSlug={clientSlug}
|
||||
projectSlug={projectSlug}
|
||||
onFallback={() => setFallback(true)}
|
||||
areas={areas}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
} from "@/app/actions/project-plan";
|
||||
import { DateRangeDialog } from "@/components/planning/date-range-dialog";
|
||||
import { WorkItemDialog } from "@/components/planning/work-item-dialog";
|
||||
import { WorkAreaBadge } from "@/components/work-areas/work-area-badge";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import {
|
||||
calendarAxis,
|
||||
|
|
@ -423,6 +424,14 @@ function ProjectTimeline({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{project.areas.some((area) => project.cards.some((card) => card.area?.id === area.id)) && (
|
||||
<div className="plan-area-legend" style={{ width: 300 + axis.width }} aria-label="Areas in this project">
|
||||
<span>AREAS</span>
|
||||
{project.areas.filter((area) => project.cards.some((card) => card.area?.id === area.id)).map((area) => (
|
||||
<WorkAreaBadge key={area.id} area={area} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sortPlanningCards(project.cards, sortOrder).map((card) => (
|
||||
<TimelineRow
|
||||
key={card.id}
|
||||
|
|
@ -494,13 +503,14 @@ function TimelineRow({
|
|||
|
||||
return (
|
||||
<div className="plan-timeline-row" onDoubleClick={canEdit ? openDateEditorOnDoubleClick : undefined}>
|
||||
<div className="plan-sticky-label plan-card-label">
|
||||
<div className="plan-sticky-label plan-card-label" style={card.area ? { borderLeft: `3px solid ${card.area.color}`, paddingLeft: 14 } : undefined}>
|
||||
<button type="button" className="plan-card-details-button" onClick={(event) => openDetailsAfterClick(event.detail)} aria-label={`View details for ${card.title}`}>
|
||||
<span className="plan-card-name">
|
||||
<span className={`plan-priority-dot is-${card.priority}`} />
|
||||
<span className="plan-card-title">{card.title}</span>
|
||||
</span>
|
||||
<span className="plan-card-subline">
|
||||
{card.area && <span className="plan-row-area"><i style={{ backgroundColor: card.area.color }} />{card.area.name}</span>}
|
||||
<span>{card.columnName}</span>
|
||||
{card.number > 0 && <span>#{card.number}</span>}
|
||||
{progress !== null && <span>{progress}% complete</span>}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Link from "next/link";
|
|||
import { ArrowUpRight, CalendarDays, X } from "lucide-react";
|
||||
|
||||
import type { PlanningCard } from "@/lib/planning/load";
|
||||
import { WorkAreaBadge } from "@/components/work-areas/work-area-badge";
|
||||
|
||||
const priorityLabel: Record<PlanningCard["priority"], string> = {
|
||||
critical: "Critical priority",
|
||||
|
|
@ -79,6 +80,7 @@ export function WorkItemDialog({
|
|||
<div className="plan-item-body">
|
||||
<h2>{card.title}</h2>
|
||||
<div className="plan-item-tags">
|
||||
{card.area && <WorkAreaBadge area={card.area} />}
|
||||
<span className={`plan-item-priority is-${card.priority}`}><i />{priorityLabel[card.priority]}</span>
|
||||
<span>{card.columnName}</span>
|
||||
</div>
|
||||
|
|
|
|||
37
src/components/work-areas/area-filter.tsx
Normal file
37
src/components/work-areas/area-filter.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
|
||||
import { workAreaForCard } from "@/lib/work-areas/core";
|
||||
import type { CardRecord, WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
||||
export function AreaFilter({ areas, cards, value, onChange }: {
|
||||
areas: WorkAreaRecord[];
|
||||
cards: CardRecord[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const uncategorised = cards.filter((card) => !workAreaForCard(card, areas)).length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 border-b border-border-subtle bg-panel px-6 py-3" aria-label="Filter by area">
|
||||
<span className="mr-2 text-[10px] font-bold uppercase tracking-[.13em] text-text-muted">Areas</span>
|
||||
<button type="button" onClick={() => onChange("all")} aria-pressed={value === "all"} className={`rounded-md px-2 py-1 text-xs font-medium ${value === "all" ? "bg-brand-soft text-brand" : "text-text-secondary hover:bg-surface"}`}>
|
||||
All <span className="ml-1 tabular-nums opacity-60">{cards.length}</span>
|
||||
</button>
|
||||
{areas.map((area) => {
|
||||
const count = cards.filter((card) => card.work_area === area.id).length;
|
||||
return (
|
||||
<button key={area.id} type="button" onClick={() => onChange(area.id)} aria-pressed={value === area.id} className={`inline-flex max-w-44 items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium ${value === area.id ? "border-border-strong bg-surface-raised text-text-primary" : "border-transparent text-text-secondary hover:bg-surface"}`}>
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-[2px]" style={{ backgroundColor: area.color }} aria-hidden="true" />
|
||||
<span className="truncate">{area.name}</span>
|
||||
<span className="tabular-nums opacity-55">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{uncategorised > 0 && (
|
||||
<button type="button" onClick={() => onChange("unassigned")} aria-pressed={value === "unassigned"} className={`rounded-md px-2 py-1 text-xs ${value === "unassigned" ? "bg-surface-raised text-text-primary" : "text-text-muted hover:bg-surface"}`}>
|
||||
Uncategorised <span className="ml-1 tabular-nums opacity-55">{uncategorised}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
src/components/work-areas/card-area-section.tsx
Normal file
52
src/components/work-areas/card-area-section.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { WorkAreaBadge } from "@/components/work-areas/work-area-badge";
|
||||
import { WorkAreaPicker } from "@/components/work-areas/work-area-picker";
|
||||
import { cleanWorkAreaName, type WorkAreaChoice } from "@/lib/work-areas/core";
|
||||
import type { WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
||||
export function CardAreaSection({ cardId, areaId, areas }: {
|
||||
cardId: string;
|
||||
areaId: string;
|
||||
areas: WorkAreaRecord[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [choice, setChoice] = useState<WorkAreaChoice>({ areaId });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const current = areas.find((area) => area.id === areaId);
|
||||
const canSave = choice.newAreaName !== undefined
|
||||
? Boolean(cleanWorkAreaName(choice.newAreaName))
|
||||
: (choice.areaId ?? "") !== areaId;
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch(`/api/cards/${cardId}/area`, {
|
||||
method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(choice),
|
||||
});
|
||||
if (!response.ok) throw new Error("Could not save the area");
|
||||
const result = await response.json();
|
||||
setChoice({ areaId: result.area?.id ?? "" });
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Could not save the area. Please try again.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <section className="space-y-3 rounded-[var(--radius-tile)] border border-border-subtle bg-panel p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">Area</h2>
|
||||
{current && <WorkAreaBadge area={current} />}
|
||||
</div>
|
||||
<WorkAreaPicker areas={areas} value={choice} onChange={setChoice} disabled={saving} />
|
||||
<button type="button" onClick={save} disabled={saving || !canSave} className="rounded-lg bg-brand px-3 py-1.5 text-xs font-semibold text-brand-fg disabled:opacity-45">{saving ? "Saving…" : "Save area"}</button>
|
||||
{error && <p role="alert" className="text-xs text-accent">{error}</p>}
|
||||
</section>;
|
||||
}
|
||||
16
src/components/work-areas/work-area-badge.tsx
Normal file
16
src/components/work-areas/work-area-badge.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
||||
export function WorkAreaBadge({ area, compact = false }: {
|
||||
area: Pick<WorkAreaRecord, "name" | "color">;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex min-w-0 items-center gap-1.5 rounded-md border border-border-subtle bg-surface px-1.5 py-0.5 text-[10px] font-semibold text-text-secondary${compact ? " max-w-full" : ""}`}
|
||||
title={area.name}
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 rounded-[2px]" style={{ backgroundColor: area.color }} aria-hidden="true" />
|
||||
<span className="truncate">{area.name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
58
src/components/work-areas/work-area-picker.tsx
Normal file
58
src/components/work-areas/work-area-picker.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
|
||||
import type { WorkAreaChoice } from "@/lib/work-areas/core";
|
||||
import type { WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
||||
export function WorkAreaPicker({
|
||||
areas,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
areas: Pick<WorkAreaRecord, "id" | "name" | "color">[];
|
||||
value: WorkAreaChoice;
|
||||
onChange: (value: WorkAreaChoice) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [creating, setCreating] = useState(areas.length === 0 || Boolean(value.newAreaName));
|
||||
useEffect(() => {
|
||||
if (value.areaId) setCreating(false);
|
||||
}, [value.areaId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor={id} className="block text-[11px] font-semibold text-text-secondary">Area</label>
|
||||
<select
|
||||
id={id}
|
||||
value={creating ? "new" : value.areaId || ""}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const selected = event.target.value;
|
||||
setCreating(selected === "new");
|
||||
onChange(selected === "new" ? { newAreaName: "" } : { areaId: selected });
|
||||
}}
|
||||
className="h-9 w-full rounded-lg border border-border-strong bg-panel px-2.5 text-xs text-text-primary focus:border-brand focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="">Choose an area</option>
|
||||
{areas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}
|
||||
<option value="new">+ Create a new area</option>
|
||||
</select>
|
||||
{creating && (
|
||||
<input
|
||||
type="text"
|
||||
maxLength={80}
|
||||
value={value.newAreaName || ""}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange({ newAreaName: event.target.value })}
|
||||
placeholder="e.g. Delivery Notes"
|
||||
aria-label="New area name"
|
||||
className="h-9 w-full rounded-lg border border-border-strong bg-panel px-2.5 text-xs text-text-primary focus:border-brand focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
)}
|
||||
<p className="text-[10px] leading-relaxed text-text-muted">Related cards share an area color. Each card keeps its own status and dates.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -62,6 +62,13 @@ describe("describeActivity", () => {
|
|||
expect(describeActivity(row({ action: "card_archived" }))).toBe("archived this card");
|
||||
});
|
||||
|
||||
it("names areas when a card is grouped or ungrouped", () => {
|
||||
expect(describeActivity(row({ action: "card_updated", field: "work_area", new_value: "Delivery Notes" })))
|
||||
.toBe("added this card to Delivery Notes");
|
||||
expect(describeActivity(row({ action: "card_updated", field: "work_area", old_value: "Delivery Notes" })))
|
||||
.toBe("removed this card from Delivery Notes");
|
||||
});
|
||||
|
||||
it("names the destination when a card moves", () => {
|
||||
expect(describeActivity(row({ action: "card_moved", new_value: "In Progress" })))
|
||||
.toBe("moved this card to In Progress");
|
||||
|
|
@ -164,4 +171,4 @@ describe("describeActivity", () => {
|
|||
const result = describeActivity(row({ action: unknown }));
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -97,6 +97,11 @@ export function describeActivity(row: ActivityRecord): string {
|
|||
|
||||
case "card_updated":
|
||||
if (row.field === "title") return "renamed this card";
|
||||
if (row.field === "work_area") {
|
||||
if (!row.new_value) return `removed this card from ${row.old_value}`;
|
||||
if (!row.old_value) return `added this card to ${row.new_value}`;
|
||||
return `moved this card from ${row.old_value} to ${row.new_value}`;
|
||||
}
|
||||
if (row.field) return `updated the ${row.field}`;
|
||||
return "updated this card";
|
||||
|
||||
|
|
@ -105,4 +110,4 @@ export function describeActivity(row: ActivityRecord): string {
|
|||
// a history the client reads is worse than a vague one.
|
||||
return "made a change";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ describe("pickClientCardPatch", () => {
|
|||
});
|
||||
|
||||
describe("pickClientCardCreate", () => {
|
||||
it("accepts title and description only", () => {
|
||||
it("accepts title, description, and a project-scoped area choice only", () => {
|
||||
const { input, dropped } = pickClientCardCreate({
|
||||
title: "Add a POS system",
|
||||
description: "We need card payments at the counter.",
|
||||
|
|
@ -67,6 +67,8 @@ describe("pickClientCardCreate", () => {
|
|||
expect(input).toEqual({
|
||||
title: "Add a POS system",
|
||||
description: "We need card payments at the counter.",
|
||||
areaId: "",
|
||||
newAreaName: "",
|
||||
});
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
|
@ -78,10 +80,18 @@ describe("pickClientCardCreate", () => {
|
|||
accepted: true,
|
||||
priority: "critical",
|
||||
});
|
||||
expect(input).toEqual({ title: "x", description: "" });
|
||||
expect(input).toEqual({ title: "x", description: "", areaId: "", newAreaName: "" });
|
||||
expect(dropped.sort()).toEqual(["accepted", "column", "priority"].sort());
|
||||
});
|
||||
|
||||
it("accepts an area choice while refusing a client-supplied color", () => {
|
||||
const { input, dropped } = pickClientCardCreate({
|
||||
project: "project-1", title: "Improve search", newAreaName: "Delivery Notes", color: "#000000",
|
||||
});
|
||||
expect(input?.newAreaName).toBe("Delivery Notes");
|
||||
expect(dropped).toEqual(["color"]);
|
||||
});
|
||||
|
||||
it("rejects a missing or blank title", () => {
|
||||
expect(pickClientCardCreate({ description: "no title" }).input).toBeNull();
|
||||
expect(pickClientCardCreate({ title: " " }).input).toBeNull();
|
||||
|
|
@ -91,4 +101,4 @@ describe("pickClientCardCreate", () => {
|
|||
const { input } = pickClientCardCreate({ title: ` ${"a".repeat(250)} ` });
|
||||
expect(input!.title).toHaveLength(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export interface ClientCardPatch {
|
|||
export interface ClientCardCreate {
|
||||
title: string;
|
||||
description: string;
|
||||
areaId: string;
|
||||
newAreaName: string;
|
||||
}
|
||||
|
||||
function asRecord(body: unknown): Record<string, unknown> | null {
|
||||
|
|
@ -99,6 +101,8 @@ export function pickClientCardCreate(body: unknown): {
|
|||
const dropped: string[] = [];
|
||||
let title = "";
|
||||
let description = "";
|
||||
let areaId = "";
|
||||
let newAreaName = "";
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (key === "title") {
|
||||
|
|
@ -113,6 +117,18 @@ export function pickClientCardCreate(body: unknown): {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (key === "areaId") {
|
||||
if (typeof value === "string") areaId = value;
|
||||
else dropped.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "newAreaName") {
|
||||
if (typeof value === "string") newAreaName = value;
|
||||
else dropped.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// `project` is read from the body by the route handler before calling
|
||||
// this, so it is expected here and is not a violation.
|
||||
if (key === "project") continue;
|
||||
|
|
@ -121,5 +137,5 @@ export function pickClientCardCreate(body: unknown): {
|
|||
}
|
||||
|
||||
if (!title) return { input: null, dropped };
|
||||
return { input: { title, description }, dropped };
|
||||
}
|
||||
return { input: { title, description, areaId, newAreaName }, dropped };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
ClientRecord,
|
||||
ColumnRecord,
|
||||
ProjectRecord,
|
||||
WorkAreaRecord,
|
||||
} from "@/lib/pb/types";
|
||||
|
||||
export interface BoardData {
|
||||
|
|
@ -13,6 +14,7 @@ export interface BoardData {
|
|||
project: ProjectRecord;
|
||||
columns: ColumnRecord[];
|
||||
cards: CardRecord[];
|
||||
areas: WorkAreaRecord[];
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
|
|
@ -60,12 +62,16 @@ export async function loadBoard(
|
|||
sort: "position",
|
||||
});
|
||||
|
||||
const cards = await pb.collection("cards").getFullList<CardRecord>({
|
||||
filter: `project = "${project.id}" && archived = false`,
|
||||
sort: "position",
|
||||
});
|
||||
const [cards, areas] = await Promise.all([
|
||||
pb.collection("cards").getFullList<CardRecord>({
|
||||
filter: `project = "${project.id}" && archived = false`, sort: "position",
|
||||
}),
|
||||
pb.collection("work_areas").getFullList<WorkAreaRecord>({
|
||||
filter: `project = "${project.id}"`, sort: "created,name",
|
||||
}),
|
||||
]);
|
||||
|
||||
return { client, project, columns, cards };
|
||||
return { client, project, columns, cards, areas };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
ProjectRecord,
|
||||
SubtaskRecord,
|
||||
UserRecord,
|
||||
WorkAreaRecord,
|
||||
} from "@/lib/pb/types";
|
||||
|
||||
export interface CardDetail {
|
||||
|
|
@ -19,6 +20,7 @@ export interface CardDetail {
|
|||
project: ProjectRecord;
|
||||
client: ClientRecord;
|
||||
columns: ColumnRecord[];
|
||||
areas: WorkAreaRecord[];
|
||||
subtasks: SubtaskRecord[];
|
||||
comments: CommentRecord[];
|
||||
activity: ActivityRecord[];
|
||||
|
|
@ -70,6 +72,10 @@ export async function loadCardDetail(
|
|||
sort: "position",
|
||||
});
|
||||
|
||||
const areas = await pb.collection("work_areas").getFullList<WorkAreaRecord>({
|
||||
filter: `project = "${project.id}"`, sort: "created,name",
|
||||
});
|
||||
|
||||
const column = columns.find((c) => c.id === card.column);
|
||||
if (!column) return null;
|
||||
|
||||
|
|
@ -115,6 +121,7 @@ export async function loadCardDetail(
|
|||
project,
|
||||
client,
|
||||
columns,
|
||||
areas,
|
||||
subtasks,
|
||||
comments,
|
||||
activity,
|
||||
|
|
@ -124,4 +131,4 @@ export async function loadCardDetail(
|
|||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,9 +78,17 @@ export interface ColumnRecord extends BaseRecord {
|
|||
is_done: boolean;
|
||||
}
|
||||
|
||||
export interface WorkAreaRecord extends BaseRecord {
|
||||
project: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface CardRecord extends BaseRecord {
|
||||
project: string;
|
||||
column: string;
|
||||
/** Optional project-scoped grouping, independent of the Kanban column. */
|
||||
work_area: string;
|
||||
title: string;
|
||||
description: string;
|
||||
position: number;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildPlanningBoard } from "@/lib/planning/load";
|
||||
import type { CardRecord, ClientRecord, ColumnRecord, ProjectRecord, SubtaskRecord } from "@/lib/pb/types";
|
||||
import type { CardRecord, ClientRecord, ColumnRecord, ProjectRecord, SubtaskRecord, WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
||||
const client = { id: "client", name: "Acme", slug: "acme", brand_color: "#168eaa" } as ClientRecord;
|
||||
const project = { id: "project", client: "client", name: "Website", slug: "website", status: "active" } as ProjectRecord;
|
||||
|
|
@ -36,10 +36,26 @@ describe("planning board loader", () => {
|
|||
card("six", "done", 6),
|
||||
],
|
||||
subtasks: [] as SubtaskRecord[],
|
||||
areas: [],
|
||||
});
|
||||
expect(board).toHaveLength(1);
|
||||
expect(board[0].cards.map((item) => item.id)).toEqual(["one", "two", "three", "four"]);
|
||||
expect(board[0].cards.every((item) => item.draftRange === null)).toBe(true);
|
||||
expect(board[0].cards[0].description).toBe("The full client request with details.");
|
||||
});
|
||||
|
||||
it("shows the project's area on each linked card without importing another project's area", () => {
|
||||
const delivery = { id: "delivery", project: "project", name: "Delivery Notes", color: "#3189aa" } as WorkAreaRecord;
|
||||
const foreign = { id: "foreign", project: "other", name: "Private", color: "#ad668a" } as WorkAreaRecord;
|
||||
const board = buildPlanningBoard({
|
||||
clients: [client], projects: [project], columns: [column("todo", "To Do")],
|
||||
cards: [
|
||||
{ ...card("one", "todo", 1), work_area: delivery.id },
|
||||
{ ...card("two", "todo", 2), work_area: foreign.id },
|
||||
],
|
||||
subtasks: [], areas: [delivery, foreign],
|
||||
});
|
||||
expect(board[0].areas).toEqual([delivery]);
|
||||
expect(board[0].cards.map((item) => item.area?.name ?? null)).toEqual(["Delivery Notes", null]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
ColumnRecord,
|
||||
ProjectRecord,
|
||||
SubtaskRecord,
|
||||
WorkAreaRecord,
|
||||
} from "@/lib/pb/types";
|
||||
|
||||
export interface PlanningCard {
|
||||
|
|
@ -25,6 +26,7 @@ export interface PlanningCard {
|
|||
subtaskDone: number;
|
||||
subtaskTotal: number;
|
||||
href: string;
|
||||
area: Pick<WorkAreaRecord, "id" | "name" | "color"> | null;
|
||||
}
|
||||
|
||||
export interface PlanningProject {
|
||||
|
|
@ -34,6 +36,7 @@ export interface PlanningProject {
|
|||
clientColor: string;
|
||||
plan: ProjectPlan;
|
||||
cards: PlanningCard[];
|
||||
areas: WorkAreaRecord[];
|
||||
}
|
||||
|
||||
interface PlanningRows {
|
||||
|
|
@ -42,6 +45,7 @@ interface PlanningRows {
|
|||
columns: ColumnRecord[];
|
||||
cards: CardRecord[];
|
||||
subtasks: SubtaskRecord[];
|
||||
areas: WorkAreaRecord[];
|
||||
}
|
||||
|
||||
/** Review and Done have left the planning queue. */
|
||||
|
|
@ -56,9 +60,11 @@ export function buildPlanningBoard({
|
|||
columns,
|
||||
cards,
|
||||
subtasks,
|
||||
areas,
|
||||
}: PlanningRows): PlanningProject[] {
|
||||
const clientsById = new Map(clients.map((client) => [client.id, client]));
|
||||
const columnsById = new Map(columns.map((column) => [column.id, column]));
|
||||
const areasById = new Map(areas.map((area) => [area.id, area]));
|
||||
const counts = new Map<string, { done: number; total: number }>();
|
||||
for (const subtask of subtasks) {
|
||||
const count = counts.get(subtask.card) ?? { done: 0, total: 0 };
|
||||
|
|
@ -84,6 +90,7 @@ export function buildPlanningBoard({
|
|||
const plan = readProjectPlan(project.plan_data);
|
||||
const projectCards = (cardsByProject.get(project.id) ?? []).map<PlanningCard>((card) => {
|
||||
const count = counts.get(card.id) ?? { done: 0, total: 0 };
|
||||
const area = areasById.get(card.work_area);
|
||||
return {
|
||||
id: card.id,
|
||||
created: card.created,
|
||||
|
|
@ -98,6 +105,7 @@ export function buildPlanningBoard({
|
|||
subtaskDone: count.done,
|
||||
subtaskTotal: count.total,
|
||||
href: `/p/${client.slug}/${project.slug}/c/${card.id}`,
|
||||
area: area?.project === project.id ? area : null,
|
||||
};
|
||||
});
|
||||
if (projectCards.length === 0) continue;
|
||||
|
|
@ -108,6 +116,7 @@ export function buildPlanningBoard({
|
|||
clientColor: client.brand_color || "var(--brand)",
|
||||
plan,
|
||||
cards: sortPlanningCards(projectCards, "date"),
|
||||
areas: areas.filter((area) => area.project === project.id),
|
||||
});
|
||||
}
|
||||
return output.sort((a, b) =>
|
||||
|
|
@ -119,12 +128,13 @@ export function buildPlanningBoard({
|
|||
export async function loadPlanningBoard(): Promise<PlanningProject[]> {
|
||||
const pb = await getServerPb();
|
||||
if (!pb.authStore.isValid) return [];
|
||||
const [clients, projects, columns, cards, subtasks] = await Promise.all([
|
||||
const [clients, projects, columns, cards, subtasks, areas] = await Promise.all([
|
||||
pb.collection("clients").getFullList<ClientRecord>(),
|
||||
pb.collection("projects").getFullList<ProjectRecord>({ filter: 'status = "active"' }),
|
||||
pb.collection("columns").getFullList<ColumnRecord>(),
|
||||
pb.collection("cards").getFullList<CardRecord>({ filter: "archived = false" }),
|
||||
pb.collection("subtasks").getFullList<SubtaskRecord>({ fields: "card,done" }),
|
||||
pb.collection("work_areas").getFullList<WorkAreaRecord>(),
|
||||
]);
|
||||
return buildPlanningBoard({ clients, projects, columns, cards, subtasks });
|
||||
return buildPlanningBoard({ clients, projects, columns, cards, subtasks, areas });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ function card(id: string, number: number, priority: PlanningCard["priority"], st
|
|||
subtaskDone: 0,
|
||||
subtaskTotal: 0,
|
||||
href: `/cards/${id}`,
|
||||
area: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
18
src/lib/work-areas/core.test.ts
Normal file
18
src/lib/work-areas/core.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { cleanWorkAreaName, nextWorkAreaColor, WORK_AREA_COLORS, workAreaForCard } from "@/lib/work-areas/core";
|
||||
|
||||
describe("work areas", () => {
|
||||
it("normalizes names and reuses only distinct saved colors until the palette is full", () => {
|
||||
expect(cleanWorkAreaName(" Delivery Notes \n")).toBe("Delivery Notes");
|
||||
expect(cleanWorkAreaName(12)).toBe("");
|
||||
expect(nextWorkAreaColor([{ color: WORK_AREA_COLORS[0] }])).toBe(WORK_AREA_COLORS[1]);
|
||||
expect(nextWorkAreaColor(WORK_AREA_COLORS.map((color) => ({ color })))).toBe(WORK_AREA_COLORS[0]);
|
||||
});
|
||||
|
||||
it("finds only a card's linked area", () => {
|
||||
const areas = [{ id: "delivery", name: "Delivery Notes" }];
|
||||
expect(workAreaForCard({ work_area: "delivery" }, areas)).toEqual(areas[0]);
|
||||
expect(workAreaForCard({ work_area: "other" }, areas)).toBeNull();
|
||||
});
|
||||
});
|
||||
30
src/lib/work-areas/core.ts
Normal file
30
src/lib/work-areas/core.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
||||
export interface WorkAreaChoice {
|
||||
areaId?: string;
|
||||
newAreaName?: string;
|
||||
}
|
||||
|
||||
/** Distinct accents on both light and dark surfaces. Text always accompanies color. */
|
||||
export const WORK_AREA_COLORS = [
|
||||
"#3189aa", "#ad668a", "#ae7938", "#4f9569",
|
||||
"#696bb5", "#bb655b", "#829145", "#597eaa",
|
||||
] as const;
|
||||
|
||||
export function cleanWorkAreaName(value: unknown): string {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.trim().replace(/\s+/g, " ").slice(0, 80);
|
||||
}
|
||||
|
||||
export function nextWorkAreaColor(areas: Pick<WorkAreaRecord, "color">[]): string {
|
||||
const used = new Set(areas.map((area) => area.color.toLowerCase()));
|
||||
return WORK_AREA_COLORS.find((color) => !used.has(color))
|
||||
?? WORK_AREA_COLORS[areas.length % WORK_AREA_COLORS.length];
|
||||
}
|
||||
|
||||
export function workAreaForCard<T extends Pick<WorkAreaRecord, "id">>(
|
||||
card: { work_area?: string },
|
||||
areas: T[]
|
||||
): T | null {
|
||||
return areas.find((area) => area.id === card.work_area) ?? null;
|
||||
}
|
||||
40
src/lib/work-areas/server.test.ts
Normal file
40
src/lib/work-areas/server.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { resolveWorkArea } from "@/lib/work-areas/server";
|
||||
import { WORK_AREA_COLORS } from "@/lib/work-areas/core";
|
||||
import type { WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
||||
describe("resolveWorkArea", () => {
|
||||
it("rejects an area from another project and reuses a matching name", async () => {
|
||||
const rows = [
|
||||
{ id: "delivery", project: "p1", name: "Delivery Notes", color: WORK_AREA_COLORS[0] },
|
||||
{ id: "foreign", project: "p2", name: "Vehicles", color: WORK_AREA_COLORS[0] },
|
||||
] as WorkAreaRecord[];
|
||||
const create = vi.fn();
|
||||
const pb = {
|
||||
filter: (_template: string, values: { project: string }) => values.project,
|
||||
collection: () => ({
|
||||
getFullList: ({ filter }: { filter: string }) => Promise.resolve(rows.filter((row) => row.project === filter)),
|
||||
create,
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(resolveWorkArea(pb as never, "p1", { areaId: "foreign" })).rejects.toThrow("Area not found");
|
||||
expect(await resolveWorkArea(pb as never, "p1", { newAreaName: " delivery notes " })).toBe("delivery");
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
await expect(resolveWorkArea(pb as never, "p1", { areaId: "delivery", newAreaName: "Other" })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("assigns a distinct saved color to a new area", async () => {
|
||||
const create = vi.fn(async (body: { color: string }) => ({ id: "new", ...body }));
|
||||
const pb = {
|
||||
filter: () => "p1",
|
||||
collection: () => ({
|
||||
getFullList: async () => [{ id: "existing", project: "p1", name: "Search", color: WORK_AREA_COLORS[0] }],
|
||||
create,
|
||||
}),
|
||||
};
|
||||
expect(await resolveWorkArea(pb as never, "p1", { newAreaName: "Purchase Orders" })).toBe("new");
|
||||
expect(create).toHaveBeenCalledWith({ project: "p1", name: "Purchase Orders", color: WORK_AREA_COLORS[1] });
|
||||
});
|
||||
});
|
||||
48
src/lib/work-areas/server.ts
Normal file
48
src/lib/work-areas/server.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import "server-only";
|
||||
|
||||
import type PocketBase from "pocketbase";
|
||||
|
||||
import { cleanWorkAreaName, nextWorkAreaColor, type WorkAreaChoice } from "@/lib/work-areas/core";
|
||||
import type { WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
||||
export async function listWorkAreas(pb: PocketBase, projectId: string): Promise<WorkAreaRecord[]> {
|
||||
return pb.collection("work_areas").getFullList<WorkAreaRecord>({
|
||||
filter: pb.filter("project = {:project}", { project: projectId }),
|
||||
sort: "created,name",
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve only within the authorized project; never trust a submitted area id or color. */
|
||||
export async function resolveWorkArea(
|
||||
pb: PocketBase,
|
||||
projectId: string,
|
||||
choice: WorkAreaChoice
|
||||
): Promise<string> {
|
||||
const areaId = typeof choice.areaId === "string" ? choice.areaId : "";
|
||||
const newName = cleanWorkAreaName(choice.newAreaName);
|
||||
if (areaId && newName) throw new Error("Choose an existing area or create a new one");
|
||||
if (!areaId && !newName) return "";
|
||||
|
||||
const areas = await listWorkAreas(pb, projectId);
|
||||
if (areaId) {
|
||||
if (!areas.some((area) => area.id === areaId)) throw new Error("Area not found in this project");
|
||||
return areaId;
|
||||
}
|
||||
|
||||
const existing = areas.find((area) => area.name.toLocaleLowerCase() === newName.toLocaleLowerCase());
|
||||
if (existing) return existing.id;
|
||||
try {
|
||||
const created = await pb.collection("work_areas").create<WorkAreaRecord>({
|
||||
project: projectId,
|
||||
name: newName,
|
||||
color: nextWorkAreaColor(areas),
|
||||
});
|
||||
return created.id;
|
||||
} catch (error) {
|
||||
// A second request may have created the same name after our read.
|
||||
const match = (await listWorkAreas(pb, projectId))
|
||||
.find((area) => area.name.toLocaleLowerCase() === newName.toLocaleLowerCase());
|
||||
if (match) return match.id;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue