feat(linking): unassigned commit tray with manual attachment

This commit is contained in:
Dave 2026-09-16 13:24:57 +02:00
parent d7e0b97ff4
commit 444612fc55
3 changed files with 195 additions and 0 deletions

View file

@ -35,6 +35,14 @@ export default async function BoardPage({
>
Archive
</Link>
{session.role === "owner" && (
<Link
href={`/p/${clientSlug}/${projectSlug}/unassigned`}
className="text-xs text-text-muted hover:text-text-secondary"
>
Unassigned
</Link>
)}
</TopBar>
<Board
role={session.role}

View file

@ -0,0 +1,70 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { AppShell } from "@/components/shell/app-shell";
import { TopBar } from "@/components/shell/top-bar";
import { UnassignedList, type CardOption } from "@/components/linking/unassigned-list";
import { getSession } from "@/lib/auth/session";
import { getServerPb } from "@/lib/pb/server";
import { loadBoard } from "@/lib/data/board";
import type { CommitRecord } from "@/lib/pb/types";
/**
* Owner-only. A client manager has no business reassigning commits, and the
* tray is a working surface rather than a progress report.
*/
export default async function UnassignedPage({
params,
}: {
params: Promise<{ clientSlug: string; projectSlug: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
if (session.role !== "owner") notFound();
const { clientSlug, projectSlug } = await params;
const board = await loadBoard(clientSlug, projectSlug);
if (!board) notFound();
const pb = await getServerPb();
const commits = await pb.collection("commits").getFullList<CommitRecord>({
filter: `project = "${board.project.id}" && card = "" && is_merge = false`,
sort: "-authored_at",
});
const cards: CardOption[] = board.cards
.filter((card) => !card.archived)
.sort((a, b) => a.number - b.number)
.map((card) => ({ id: card.id, number: card.number, title: card.title }));
const boardHref = `/p/${clientSlug}/${projectSlug}`;
return (
<AppShell role={session.role} currentPath={boardHref}>
<TopBar
title="Unassigned commits"
subtitle={`${board.project.name} · ${commits.length} not attached to a card`}
/>
<div className="flex-1 space-y-4 overflow-y-auto p-6">
<Link
href={boardHref}
className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-secondary"
>
<ArrowLeft size={13} strokeWidth={2} />
Back to board
</Link>
<UnassignedList
projectId={board.project.id}
commits={commits}
cards={cards}
clientSlug={clientSlug}
projectSlug={projectSlug}
/>
</div>
</AppShell>
);
}

View file

@ -0,0 +1,117 @@
"use client";
import { useState, useTransition } from "react";
import { RefreshCw } from "lucide-react";
import { linkCommitAction, relinkProjectAction } from "@/app/actions/linking";
import { relativeTime } from "@/lib/time/relative";
import { Pill } from "@/components/ui/pill";
import type { CommitRecord } from "@/lib/pb/types";
export interface CardOption {
id: string;
number: number;
title: string;
}
export function UnassignedList({
projectId,
commits,
cards,
clientSlug,
projectSlug,
}: {
projectId: string;
commits: CommitRecord[];
cards: CardOption[];
clientSlug: string;
projectSlug: string;
}) {
const [pending, startTransition] = useTransition();
const [status, setStatus] = useState<string | null>(null);
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await relinkProjectAction({
projectId,
clientSlug,
projectSlug,
});
setStatus(
`${result.linked} linked, ${result.unlinked} left unassigned, ${result.preserved} manual links kept.`
);
})
}
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-raised disabled:opacity-50"
>
<RefreshCw size={14} strokeWidth={2} />
Re-run linking
</button>
{status && <span className="text-xs text-text-muted">{status}</span>}
</div>
{commits.length === 0 ? (
<p className="rounded-[var(--radius-tile)] border border-dashed border-border-subtle p-8 text-center text-sm text-text-muted">
Every commit is attached to a card.
</p>
) : (
<ul className="space-y-2">
{commits.map((commit) => (
<li
key={commit.id}
className="flex items-center gap-3 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-3"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-text-primary">
{commit.subject || commit.short_sha}
</p>
<p className="mt-0.5 flex items-center gap-2 text-xs text-text-muted">
<code>{commit.short_sha}</code>
<span>{relativeTime(commit.authored_at)}</span>
{commit.type && commit.type !== "other" && (
<Pill tone="neutral">{commit.type}</Pill>
)}
{commit.scope && <Pill tone="brand">{commit.scope}</Pill>}
</p>
</div>
<label className="shrink-0">
<span className="sr-only">Attach {commit.short_sha} to a card</span>
<select
defaultValue=""
disabled={pending}
onChange={(event) => {
const cardId = event.target.value;
if (!cardId) return;
startTransition(async () => {
await linkCommitAction({
commitId: commit.id,
cardId,
clientSlug,
projectSlug,
});
});
}}
className="rounded-lg border border-border-subtle bg-panel px-2 py-1.5 text-xs text-text-secondary outline-none focus:border-brand disabled:opacity-50"
>
<option value="">Attach to…</option>
{cards.map((card) => (
<option key={card.id} value={card.id}>
#{card.number} · {card.title.slice(0, 50)}
</option>
))}
</select>
</label>
</li>
))}
</ul>
)}
</div>
);
}