feat(04-02): overhaul PlanningView with empty state, pill tabs, and category filter

- Replace inline thread creation form with modal trigger
- Add educational empty state with 3-step workflow guide
- Add Active/Resolved pill tab selector replacing checkbox
- Add category filter dropdown for thread list
- Display category emoji + name badge on ThreadCard
- Add aria-hidden to decorative SVG icons for a11y
- Auto-format pre-existing indentation issues (spaces to tabs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 16:38:48 +01:00
parent eb79ab671e
commit d05aac0687
2 changed files with 401 additions and 277 deletions

View File

@@ -2,76 +2,84 @@ import { useNavigate } from "@tanstack/react-router";
import { formatPrice } from "../lib/formatters";
interface ThreadCardProps {
id: number;
name: string;
candidateCount: number;
minPriceCents: number | null;
maxPriceCents: number | null;
createdAt: string;
status: "active" | "resolved";
id: number;
name: string;
candidateCount: number;
minPriceCents: number | null;
maxPriceCents: number | null;
createdAt: string;
status: "active" | "resolved";
categoryName: string;
categoryEmoji: string;
}
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
const d = new Date(iso);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function formatPriceRange(
min: number | null,
max: number | null,
min: number | null,
max: number | null,
): string | null {
if (min == null && max == null) return null;
if (min === max) return formatPrice(min);
return `${formatPrice(min)} - ${formatPrice(max)}`;
if (min == null && max == null) return null;
if (min === max) return formatPrice(min);
return `${formatPrice(min)} - ${formatPrice(max)}`;
}
export function ThreadCard({
id,
name,
candidateCount,
minPriceCents,
maxPriceCents,
createdAt,
status,
id,
name,
candidateCount,
minPriceCents,
maxPriceCents,
createdAt,
status,
categoryName,
categoryEmoji,
}: ThreadCardProps) {
const navigate = useNavigate();
const navigate = useNavigate();
const isResolved = status === "resolved";
const priceRange = formatPriceRange(minPriceCents, maxPriceCents);
const isResolved = status === "resolved";
const priceRange = formatPriceRange(minPriceCents, maxPriceCents);
return (
<button
type="button"
onClick={() =>
navigate({ to: "/threads/$threadId", params: { threadId: String(id) } })
}
className={`w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all p-4 ${
isResolved ? "opacity-60" : ""
}`}
>
<div className="flex items-start justify-between mb-2">
<h3 className="text-sm font-semibold text-gray-900 truncate">
{name}
</h3>
{isResolved && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 shrink-0">
Resolved
</span>
)}
</div>
<div className="flex flex-wrap gap-1.5">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700">
{candidateCount} {candidateCount === 1 ? "candidate" : "candidates"}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
{formatDate(createdAt)}
</span>
{priceRange && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700">
{priceRange}
</span>
)}
</div>
</button>
);
return (
<button
type="button"
onClick={() =>
navigate({
to: "/threads/$threadId",
params: { threadId: String(id) },
})
}
className={`w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all p-4 ${
isResolved ? "opacity-60" : ""
}`}
>
<div className="flex items-start justify-between mb-2">
<h3 className="text-sm font-semibold text-gray-900 truncate">{name}</h3>
{isResolved && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 shrink-0">
Resolved
</span>
)}
</div>
<div className="flex flex-wrap gap-1.5">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700">
{categoryEmoji} {categoryName}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700">
{candidateCount} {candidateCount === 1 ? "candidate" : "candidates"}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
{formatDate(createdAt)}
</span>
{priceRange && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700">
{priceRange}
</span>
)}
</div>
</button>
);
}