feat(linking): unassigned commit tray with manual attachment
This commit is contained in:
parent
d7e0b97ff4
commit
444612fc55
3 changed files with 195 additions and 0 deletions
|
|
@ -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}
|
||||
|
|
|
|||
70
src/app/p/[clientSlug]/[projectSlug]/unassigned/page.tsx
Normal file
70
src/app/p/[clientSlug]/[projectSlug]/unassigned/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
src/components/linking/unassigned-list.tsx
Normal file
117
src/components/linking/unassigned-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue