feat(card): detail page with checklist, comments and history
This commit is contained in:
parent
0b60c791b5
commit
c4a8ad2860
3 changed files with 240 additions and 0 deletions
89
src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx
Normal file
89
src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
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 { CardHeader } from "@/components/card/card-header";
|
||||
import { CardActions } from "@/components/card/card-actions";
|
||||
import { SubtaskList } from "@/components/card/subtask-list";
|
||||
import { CommentThread } from "@/components/card/comment-thread";
|
||||
import { ActivityTimeline } from "@/components/card/activity-timeline";
|
||||
import { Pill } from "@/components/ui/pill";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { loadCardDetail } from "@/lib/data/card";
|
||||
|
||||
export default async function CardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ clientSlug: string; projectSlug: string; cardId: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const { clientSlug, projectSlug, cardId } = await params;
|
||||
const detail = await loadCardDetail(clientSlug, projectSlug, cardId);
|
||||
if (!detail) notFound();
|
||||
|
||||
const isOwner = session.role === "owner";
|
||||
const boardHref = `/p/${clientSlug}/${projectSlug}`;
|
||||
|
||||
return (
|
||||
<AppShell role={session.role} currentPath={boardHref}>
|
||||
<TopBar title={detail.project.name} subtitle={detail.client.name}>
|
||||
{isOwner && (
|
||||
<CardActions
|
||||
card={detail.card}
|
||||
clientSlug={clientSlug}
|
||||
projectSlug={projectSlug}
|
||||
/>
|
||||
)}
|
||||
</TopBar>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-3xl space-y-8 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
<Pill tone="neutral">{detail.column.name}</Pill>
|
||||
{!detail.card.accepted &&
|
||||
detail.card.created_by_role === "client" && (
|
||||
<Pill tone="brand">Requested</Pill>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardHeader
|
||||
card={detail.card}
|
||||
editable={isOwner}
|
||||
clientSlug={clientSlug}
|
||||
projectSlug={projectSlug}
|
||||
/>
|
||||
|
||||
<SubtaskList
|
||||
cardId={detail.card.id}
|
||||
subtasks={detail.subtasks}
|
||||
editable={isOwner}
|
||||
clientSlug={clientSlug}
|
||||
projectSlug={projectSlug}
|
||||
/>
|
||||
|
||||
<CommentThread
|
||||
cardId={detail.card.id}
|
||||
comments={detail.comments}
|
||||
names={detail.names}
|
||||
role={session.role}
|
||||
clientSlug={clientSlug}
|
||||
projectSlug={projectSlug}
|
||||
/>
|
||||
|
||||
<ActivityTimeline activity={detail.activity} names={detail.names} />
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
61
src/components/card/card-actions.tsx
Normal file
61
src/components/card/card-actions.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, Archive } from "lucide-react";
|
||||
|
||||
import { acceptCard, archiveCard } from "@/app/actions/cards";
|
||||
import type { CardRecord } from "@/lib/pb/types";
|
||||
|
||||
export function CardActions({
|
||||
card,
|
||||
clientSlug,
|
||||
projectSlug,
|
||||
}: {
|
||||
card: CardRecord;
|
||||
clientSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const showAccept = !card.accepted && card.created_by_role === "client";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{showAccept && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(() =>
|
||||
acceptCard({ cardId: card.id, clientSlug, projectSlug }).then(() =>
|
||||
router.push(`/p/${clientSlug}/${projectSlug}`)
|
||||
)
|
||||
)
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg transition-colors hover:bg-brand-hover disabled:opacity-50"
|
||||
>
|
||||
<Check size={14} strokeWidth={2} />
|
||||
Accept request
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(() =>
|
||||
archiveCard({ cardId: card.id, clientSlug, projectSlug }).then(() =>
|
||||
router.push(`/p/${clientSlug}/${projectSlug}`)
|
||||
)
|
||||
)
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-sm font-medium text-text-secondary transition-colors hover:bg-surface-raised disabled:opacity-50"
|
||||
>
|
||||
<Archive size={14} strokeWidth={2} />
|
||||
Archive
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
src/components/card/card-header.tsx
Normal file
90
src/components/card/card-header.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
import { updateCard } from "@/app/actions/cards";
|
||||
import type { CardRecord } from "@/lib/pb/types";
|
||||
|
||||
/**
|
||||
* Title and description.
|
||||
*
|
||||
* Editable in place for the owner, read-only for a client manager. The client
|
||||
* may not change these — their two editable fields are priority and target
|
||||
* date, which live on the board tile and go through /api/client/*.
|
||||
*/
|
||||
export function CardHeader({
|
||||
card,
|
||||
editable,
|
||||
clientSlug,
|
||||
projectSlug,
|
||||
}: {
|
||||
card: CardRecord;
|
||||
editable: boolean;
|
||||
clientSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [title, setTitle] = useState(card.title);
|
||||
const [description, setDescription] = useState(card.description);
|
||||
|
||||
function saveTitle() {
|
||||
if (title.trim() === card.title || !title.trim()) {
|
||||
setTitle(card.title);
|
||||
return;
|
||||
}
|
||||
startTransition(() =>
|
||||
updateCard({ cardId: card.id, title, clientSlug, projectSlug }).then(
|
||||
() => undefined
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function saveDescription() {
|
||||
if (description === card.description) return;
|
||||
startTransition(() =>
|
||||
updateCard({ cardId: card.id, description, clientSlug, projectSlug }).then(
|
||||
() => undefined
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!editable) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-text-primary">
|
||||
{card.title}
|
||||
</h1>
|
||||
{card.description ? (
|
||||
<p className="whitespace-pre-wrap text-sm text-text-secondary">
|
||||
{card.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No description yet.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
onBlur={saveTitle}
|
||||
disabled={pending}
|
||||
aria-label="Card title"
|
||||
className="w-full rounded-lg bg-transparent text-xl font-semibold tracking-tight text-text-primary outline-none hover:bg-surface focus:bg-surface"
|
||||
/>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
onBlur={saveDescription}
|
||||
disabled={pending}
|
||||
rows={5}
|
||||
placeholder="Add a description"
|
||||
aria-label="Card description"
|
||||
className="w-full rounded-[var(--radius-tile)] bg-transparent p-2 text-sm text-text-secondary outline-none placeholder:text-text-muted hover:bg-surface focus:bg-surface"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue