feat(02-02): add thread detail page with candidate CRUD and resolution flow

- Create CandidateCard with edit, delete, and pick winner actions
- Create CandidateForm with same fields as ItemForm for candidate add/edit
- Build thread detail page with candidate grid and resolution banner
- Update root layout with candidate panel, delete dialog, and resolve dialog
- Hide FAB on thread detail pages, keep it for gear tab
- Resolution navigates back to planning tab after success
This commit is contained in:
2026-03-15 11:46:19 +01:00
parent a9d624dc83
commit 7d043a8585
4 changed files with 748 additions and 28 deletions

View File

@@ -1,24 +1,54 @@
import { useState } from "react";
import { createRootRoute, Outlet } from "@tanstack/react-router";
import {
createRootRoute,
Outlet,
useMatchRoute,
useNavigate,
} from "@tanstack/react-router";
import "../app.css";
import { TotalsBar } from "../components/TotalsBar";
import { SlideOutPanel } from "../components/SlideOutPanel";
import { ItemForm } from "../components/ItemForm";
import { CandidateForm } from "../components/CandidateForm";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { OnboardingWizard } from "../components/OnboardingWizard";
import { useUIStore } from "../stores/uiStore";
import { useOnboardingComplete } from "../hooks/useSettings";
import { useThread, useResolveThread } from "../hooks/useThreads";
import { useDeleteCandidate } from "../hooks/useCandidates";
export const Route = createRootRoute({
component: RootLayout,
});
function RootLayout() {
const navigate = useNavigate();
// Item panel state
const panelMode = useUIStore((s) => s.panelMode);
const editingItemId = useUIStore((s) => s.editingItemId);
const openAddPanel = useUIStore((s) => s.openAddPanel);
const closePanel = useUIStore((s) => s.closePanel);
// Candidate panel state
const candidatePanelMode = useUIStore((s) => s.candidatePanelMode);
const editingCandidateId = useUIStore((s) => s.editingCandidateId);
const closeCandidatePanel = useUIStore((s) => s.closeCandidatePanel);
// Candidate delete state
const confirmDeleteCandidateId = useUIStore(
(s) => s.confirmDeleteCandidateId,
);
const closeConfirmDeleteCandidate = useUIStore(
(s) => s.closeConfirmDeleteCandidate,
);
// Resolution dialog state
const resolveThreadId = useUIStore((s) => s.resolveThreadId);
const resolveCandidateId = useUIStore((s) => s.resolveCandidateId);
const closeResolveDialog = useUIStore((s) => s.closeResolveDialog);
// Onboarding
const { data: onboardingComplete, isLoading: onboardingLoading } =
useOnboardingComplete();
const [wizardDismissed, setWizardDismissed] = useState(false);
@@ -26,7 +56,16 @@ function RootLayout() {
const showWizard =
!onboardingLoading && onboardingComplete !== "true" && !wizardDismissed;
const isOpen = panelMode !== "closed";
const isItemPanelOpen = panelMode !== "closed";
const isCandidatePanelOpen = candidatePanelMode !== "closed";
// Detect if we're on a thread detail page to get the threadId for candidate forms
const matchRoute = useMatchRoute();
const threadMatch = matchRoute({
to: "/threads/$threadId",
fuzzy: true,
}) as { threadId?: string } | false;
const currentThreadId = threadMatch ? Number(threadMatch.threadId) : null;
// Show a minimal loading state while checking onboarding status
if (onboardingLoading) {
@@ -42,9 +81,9 @@ function RootLayout() {
<TotalsBar />
<Outlet />
{/* Slide-out Panel */}
{/* Item Slide-out Panel */}
<SlideOutPanel
isOpen={isOpen}
isOpen={isItemPanelOpen}
onClose={closePanel}
title={panelMode === "add" ? "Add Item" : "Edit Item"}
>
@@ -54,30 +93,76 @@ function RootLayout() {
)}
</SlideOutPanel>
{/* Confirm Delete Dialog */}
{/* Candidate Slide-out Panel */}
{currentThreadId != null && (
<SlideOutPanel
isOpen={isCandidatePanelOpen}
onClose={closeCandidatePanel}
title={
candidatePanelMode === "add" ? "Add Candidate" : "Edit Candidate"
}
>
{candidatePanelMode === "add" && (
<CandidateForm mode="add" threadId={currentThreadId} />
)}
{candidatePanelMode === "edit" && (
<CandidateForm
mode="edit"
threadId={currentThreadId}
candidateId={editingCandidateId}
/>
)}
</SlideOutPanel>
)}
{/* Item Confirm Delete Dialog */}
<ConfirmDialog />
{/* Floating Add Button */}
<button
type="button"
onClick={openAddPanel}
className="fixed bottom-6 right-6 z-20 w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center"
title="Add new item"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
{/* Candidate Delete Confirm Dialog */}
{confirmDeleteCandidateId != null && currentThreadId != null && (
<CandidateDeleteDialog
candidateId={confirmDeleteCandidateId}
threadId={currentThreadId}
onClose={closeConfirmDeleteCandidate}
/>
)}
{/* Resolution Confirm Dialog */}
{resolveThreadId != null && resolveCandidateId != null && (
<ResolveDialog
threadId={resolveThreadId}
candidateId={resolveCandidateId}
onClose={closeResolveDialog}
onResolved={() => {
closeResolveDialog();
navigate({ to: "/", search: { tab: "planning" } });
}}
/>
)}
{/* Floating Add Button - only on gear tab */}
{!threadMatch && (
<button
type="button"
onClick={openAddPanel}
className="fixed bottom-6 right-6 z-20 w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center"
title="Add new item"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
)}
{/* Onboarding Wizard */}
{showWizard && (
@@ -86,3 +171,125 @@ function RootLayout() {
</div>
);
}
function CandidateDeleteDialog({
candidateId,
threadId,
onClose,
}: {
candidateId: number;
threadId: number;
onClose: () => void;
}) {
const deleteCandidate = useDeleteCandidate(threadId);
const { data: thread } = useThread(threadId);
const candidate = thread?.candidates.find((c) => c.id === candidateId);
const candidateName = candidate?.name ?? "this candidate";
function handleDelete() {
deleteCandidate.mutate(candidateId, {
onSuccess: () => onClose(),
});
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/30"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
/>
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Delete Candidate
</h3>
<p className="text-sm text-gray-600 mb-6">
Are you sure you want to delete{" "}
<span className="font-medium">{candidateName}</span>? This action
cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleteCandidate.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
>
{deleteCandidate.isPending ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
);
}
function ResolveDialog({
threadId,
candidateId,
onClose,
onResolved,
}: {
threadId: number;
candidateId: number;
onClose: () => void;
onResolved: () => void;
}) {
const resolveThread = useResolveThread();
const { data: thread } = useThread(threadId);
const candidate = thread?.candidates.find((c) => c.id === candidateId);
const candidateName = candidate?.name ?? "this candidate";
function handleResolve() {
resolveThread.mutate(
{ threadId, candidateId },
{ onSuccess: () => onResolved() },
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/30"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
/>
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Pick Winner
</h3>
<p className="text-sm text-gray-600 mb-6">
Pick <span className="font-medium">{candidateName}</span> as the
winner? This will add it to your collection and archive the thread.
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleResolve}
disabled={resolveThread.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 disabled:opacity-50 rounded-lg transition-colors"
>
{resolveThread.isPending ? "Resolving..." : "Pick Winner"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,15 +1,147 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useThread } from "../../hooks/useThreads";
import { CandidateCard } from "../../components/CandidateCard";
import { useUIStore } from "../../stores/uiStore";
export const Route = createFileRoute("/threads/$threadId")({
component: ThreadDetailPage,
});
function ThreadDetailPage() {
const { threadId } = Route.useParams();
const { threadId: threadIdParam } = Route.useParams();
const threadId = Number(threadIdParam);
const { data: thread, isLoading, isError } = useThread(threadId);
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
if (isLoading) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="animate-pulse space-y-6">
<div className="h-6 bg-gray-200 rounded w-48" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-gray-200 rounded-xl" />
))}
</div>
</div>
</div>
);
}
if (isError || !thread) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Thread not found
</h2>
<Link
to="/"
search={{ tab: "planning" }}
className="text-sm text-blue-600 hover:text-blue-700"
>
Back to planning
</Link>
</div>
);
}
const isActive = thread.status === "active";
const winningCandidate = thread.resolvedCandidateId
? thread.candidates.find((c) => c.id === thread.resolvedCandidateId)
: null;
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<p>Thread {threadId} - detail page placeholder</p>
{/* Header */}
<div className="mb-6">
<Link
to="/"
search={{ tab: "planning" }}
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
>
&larr; Back to planning
</Link>
<div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-gray-900">
{thread.name}
</h1>
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
isActive
? "bg-blue-50 text-blue-700"
: "bg-gray-100 text-gray-500"
}`}
>
{isActive ? "Active" : "Resolved"}
</span>
</div>
</div>
{/* Resolution banner */}
{!isActive && winningCandidate && (
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl">
<p className="text-sm text-amber-800">
<span className="font-medium">{winningCandidate.name}</span> was
picked as the winner and added to your collection.
</p>
</div>
)}
{/* Add candidate button */}
{isActive && (
<div className="mb-6">
<button
type="button"
onClick={openCandidateAddPanel}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Add Candidate
</button>
</div>
)}
{/* Candidate grid */}
{thread.candidates.length === 0 ? (
<div className="py-12 text-center">
<div className="text-4xl mb-3">🏷</div>
<h3 className="text-lg font-semibold text-gray-900 mb-1">
No candidates yet
</h3>
<p className="text-sm text-gray-500">
Add your first candidate to start comparing.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{thread.candidates.map((candidate) => (
<CandidateCard
key={candidate.id}
id={candidate.id}
name={candidate.name}
weightGrams={candidate.weightGrams}
priceCents={candidate.priceCents}
categoryName={candidate.categoryName}
categoryEmoji={candidate.categoryEmoji}
imageFilename={candidate.imageFilename}
threadId={threadId}
isActive={isActive}
/>
))}
</div>
)}
</div>
);
}