From 34c36eee156ba4de1b4bb0effcae062c0425b668 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 21 Sep 2026 13:31:21 +0200 Subject: [PATCH] feat(admin): client-bound IntakeLinksPanel Phase 9b Task 2. Panel takes a mode prop ('project' keeps Phase 8 behaviour; 'client' is the new shape). The Server Action's input takes an IntakeLinkScope union ({type:'project', projectId} | {type:'client', clientId}) so revalidatePath hits the right admin page after every change. The Phase 8 site ('/admin/projects/[id]') passes mode='project' and projectId; the Phase 9 site ('/admin/clients/[id]') passes mode='client' and clientId. Both render the same UI; only the scope forwarded to the actions differs. --- src/app/actions/intake-links.ts | 43 +++++++++++++++------ src/app/admin/clients/[clientId]/page.tsx | 26 +++++++------ src/app/admin/projects/[projectId]/page.tsx | 2 +- src/components/admin/intake-links-panel.tsx | 27 ++++++++++--- 4 files changed, 70 insertions(+), 28 deletions(-) diff --git a/src/app/actions/intake-links.ts b/src/app/actions/intake-links.ts index 14c1df7..20d3490 100644 --- a/src/app/actions/intake-links.ts +++ b/src/app/actions/intake-links.ts @@ -14,8 +14,19 @@ async function requireOwner() { return session; } +/** Where a link lives in the admin tree. Phase 8: project. Phase 9: client. */ +export type IntakeLinkScope = + | { type: "project"; projectId: string } + | { type: "client"; clientId: string }; + +function adminPathForScope(scope: IntakeLinkScope): string { + return scope.type === "project" + ? `/admin/projects/${scope.projectId}` + : `/admin/clients/${scope.clientId}`; +} + export async function createIntakeLinkAction(input: { - projectId: string; + scope: IntakeLinkScope; label: string; expiresAt?: string; maxSubmissionsPerDay?: number; @@ -27,8 +38,7 @@ export async function createIntakeLinkAction(input: { const label = input.label.trim().slice(0, 80); if (!label) throw new Error("A label is required"); - const record = await pb.collection("intake_links").create({ - project: input.projectId, + const createFields: Record = { label, token: generateIntakeToken(), active: true, @@ -39,9 +49,20 @@ export async function createIntakeLinkAction(input: { submission_count_today_date: "", last_submission_at: "", notes: (input.notes ?? "").slice(0, 1000), - }); + }; + if (input.scope.type === "project") { + createFields.project = input.scope.projectId; + createFields.client = ""; + } else { + createFields.project = ""; + createFields.client = input.scope.clientId; + } - revalidatePath(`/admin/projects/${input.projectId}`); + const record = await pb + .collection("intake_links") + .create(createFields); + + revalidatePath(adminPathForScope(input.scope)); return { id: record.id, token: record.token, @@ -53,21 +74,21 @@ export async function createIntakeLinkAction(input: { * immediately. */ export async function rotateIntakeTokenAction(input: { linkId: string; - projectId: string; + scope: IntakeLinkScope; }): Promise<{ token: string; url: string }> { await requireOwner(); const pb = await getAdminPb(); const token = generateIntakeToken(); await pb.collection("intake_links").update(input.linkId, { token }); - revalidatePath(`/admin/projects/${input.projectId}`); + revalidatePath(adminPathForScope(input.scope)); return { token, url: intakeUrlFor(token) }; } export async function setIntakeLinkActiveAction(input: { linkId: string; - projectId: string; + scope: IntakeLinkScope; active: boolean; }): Promise { await requireOwner(); @@ -76,12 +97,12 @@ export async function setIntakeLinkActiveAction(input: { await pb.collection("intake_links").update(input.linkId, { active: input.active, }); - revalidatePath(`/admin/projects/${input.projectId}`); + revalidatePath(adminPathForScope(input.scope)); } export async function setIntakeLinkNotesAction(input: { linkId: string; - projectId: string; + scope: IntakeLinkScope; notes: string; }): Promise { await requireOwner(); @@ -90,7 +111,7 @@ export async function setIntakeLinkNotesAction(input: { await pb.collection("intake_links").update(input.linkId, { notes: input.notes.slice(0, 1000), }); - revalidatePath(`/admin/projects/${input.projectId}`); + revalidatePath(adminPathForScope(input.scope)); } function intakeUrlFor(token: string): string { diff --git a/src/app/admin/clients/[clientId]/page.tsx b/src/app/admin/clients/[clientId]/page.tsx index 0da046f..b106844 100644 --- a/src/app/admin/clients/[clientId]/page.tsx +++ b/src/app/admin/clients/[clientId]/page.tsx @@ -2,22 +2,21 @@ import { notFound } from "next/navigation"; import { AppShell } from "@/components/shell/app-shell"; import { TopBar } from "@/components/shell/top-bar"; +import { IntakeLinksPanel } from "@/components/admin/intake-links-panel"; import { ShowInIntakeToggle } from "@/components/admin/show-in-intake-toggle"; import { getAdminPb } from "@/lib/pb/admin"; -import type { ClientRecord, ProjectRecord } from "@/lib/pb/types"; +import type { ClientRecord, IntakeLinkRecord, ProjectRecord } from "@/lib/pb/types"; /** * Per-client request-link admin page. * * Two stacked panels: a Request-links panel (the same component Phase 8 - * shipped for the project page, now bound to client instead — wired in - * Task 2) and a Projects list with the "List on request page" toggle - * next to each. + * shipped for the project page, now bound to client instead) and a + * Projects list with the "List on request page" toggle next to each. * - * The Phase 8 /admin/projects/[projectId] page keeps its own - * Request-links panel for the legacy per-project link shape. The picker - * is the new default; the legacy path is for owners who haven't - * migrated yet. + * The Phase 8 /admin/projects/[projectId] page keeps its own Request-links + * panel for the legacy per-project link shape. The picker is the new + * default; the legacy path is for owners who haven't migrated yet. * * Owner-only by virtue of the admin layout, which is already enforced * upstream. @@ -45,6 +44,13 @@ export default async function ClientAdminPage({ sort: "name", }); + const links = await pb + .collection("intake_links") + .getFullList({ + filter: `client = "${clientId}"`, + sort: "-created", + }); + return ( @@ -55,9 +61,7 @@ export default async function ClientAdminPage({
-
- Request links panel lands in Task 2. -
+
diff --git a/src/app/admin/projects/[projectId]/page.tsx b/src/app/admin/projects/[projectId]/page.tsx index bbc932c..7c4cb6c 100644 --- a/src/app/admin/projects/[projectId]/page.tsx +++ b/src/app/admin/projects/[projectId]/page.tsx @@ -65,7 +65,7 @@ export default async function ProjectAdminPage({ commitCount={commits.totalItems} /> - + (null); const [newLabel, setNewLabel] = useState(""); @@ -42,7 +59,7 @@ export function IntakeLinksPanel({ startTransition(async () => { try { const result = await createIntakeLinkAction({ - projectId, + scope, label: newLabel, }); setNewUrl(result.url); @@ -57,7 +74,7 @@ export function IntakeLinksPanel({ startTransition(async () => { const result = await rotateIntakeTokenAction({ linkId: link.id, - projectId, + scope, }); setNewUrl(result.url); }); @@ -67,7 +84,7 @@ export function IntakeLinksPanel({ startTransition(async () => { await setIntakeLinkActiveAction({ linkId: link.id, - projectId, + scope, active: !link.active, }); }); @@ -77,7 +94,7 @@ export function IntakeLinksPanel({ startTransition(async () => { await setIntakeLinkNotesAction({ linkId: link.id, - projectId, + scope, notes, }); });