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.
This commit is contained in:
parent
87fa28c4d1
commit
34c36eee15
4 changed files with 70 additions and 28 deletions
|
|
@ -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<IntakeLinkRecord>({
|
||||
project: input.projectId,
|
||||
const createFields: Record<string, unknown> = {
|
||||
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<IntakeLinkRecord>(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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<IntakeLinkRecord>({
|
||||
filter: `client = "${clientId}"`,
|
||||
sort: "-created",
|
||||
});
|
||||
|
||||
return (
|
||||
<AppShell role="owner" currentPath="/admin/clients">
|
||||
<TopBar title={client.name} subtitle="Request links and project picker">
|
||||
|
|
@ -55,9 +61,7 @@ export default async function ClientAdminPage({
|
|||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="mx-auto max-w-2xl space-y-4">
|
||||
<section className="rounded-[var(--radius-tile)] border border-border-subtle p-4 text-xs text-text-muted">
|
||||
Request links panel lands in Task 2.
|
||||
</section>
|
||||
<IntakeLinksPanel mode="client" clientId={client.id} links={links} />
|
||||
|
||||
<section className="space-y-3 rounded-[var(--radius-tile)] border border-border-subtle p-4">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export default async function ProjectAdminPage({
|
|||
commitCount={commits.totalItems}
|
||||
/>
|
||||
<SummaryPanel projectId={project.id} status={summary} />
|
||||
<IntakeLinksPanel projectId={project.id} links={links} />
|
||||
<IntakeLinksPanel mode="project" projectId={project.id} links={links} />
|
||||
<ShowInIntakeToggle
|
||||
projectId={project.id}
|
||||
initial={Boolean(project.show_in_intake)}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,18 @@ import {
|
|||
rotateIntakeTokenAction,
|
||||
setIntakeLinkActiveAction,
|
||||
setIntakeLinkNotesAction,
|
||||
type IntakeLinkScope,
|
||||
} from "@/app/actions/intake-links";
|
||||
import type { IntakeLinkRecord } from "@/lib/pb/types";
|
||||
|
||||
/**
|
||||
* Owner UI for public intake links.
|
||||
*
|
||||
* Phase 8: mounted on `/admin/projects/[projectId]` with `mode="project"`.
|
||||
* Phase 9: mounted on `/admin/clients/[clientId]` with `mode="client"`.
|
||||
* The two paths share the same UI; only the `scope` they forward to the
|
||||
* Server Actions differs.
|
||||
*
|
||||
* The URL is shown once on creation and on rotation. After that the owner
|
||||
* can only re-copy the same URL — losing the URL does not lose access,
|
||||
* because the admin page itself can read it. The token itself never leaves
|
||||
|
|
@ -24,12 +30,23 @@ import type { IntakeLinkRecord } from "@/lib/pb/types";
|
|||
* what the spec's §5.3 said the owner UI should read.
|
||||
*/
|
||||
export function IntakeLinksPanel({
|
||||
mode,
|
||||
projectId,
|
||||
clientId,
|
||||
links,
|
||||
}: {
|
||||
projectId: string;
|
||||
mode: "project" | "client";
|
||||
/** Phase 8 path: required when mode === "project". */
|
||||
projectId?: string;
|
||||
/** Phase 9 path: required when mode === "client". */
|
||||
clientId?: string;
|
||||
links: IntakeLinkRecord[];
|
||||
}) {
|
||||
const scope: IntakeLinkScope =
|
||||
mode === "project"
|
||||
? { type: "project", projectId: projectId! }
|
||||
: { type: "client", clientId: clientId! };
|
||||
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [newUrl, setNewUrl] = useState<string | null>(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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue