feat(08-01): create StatusBadge component and wire into CandidateCard

- StatusBadge: clickable pill badge with popup menu (researching/ordered/arrived)
- Muted gray styling, LucideIcon per status, click-outside dismiss, Escape key support
- CandidateCard: status + onStatusChange props, StatusBadge in pill row after category
- Thread detail page: passes candidate.status and useUpdateCandidate for onStatusChange
- Fix Biome formatting for candidateStatusSchema enum

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 14:12:02 +01:00
parent 5f89acd503
commit 25956ed3ee
4 changed files with 123 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
import { StatusBadge } from "./StatusBadge";
interface CandidateCardProps {
id: number;
@@ -14,6 +15,8 @@ interface CandidateCardProps {
productUrl?: string | null;
threadId: number;
isActive: boolean;
status: "researching" | "ordered" | "arrived";
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
}
export function CandidateCard({
@@ -27,6 +30,8 @@ export function CandidateCard({
productUrl,
threadId,
isActive,
status,
onStatusChange,
}: CandidateCardProps) {
const unit = useWeightUnit();
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
@@ -106,6 +111,7 @@ export function CandidateCard({
/>{" "}
{categoryName}
</span>
<StatusBadge status={status} onStatusChange={onStatusChange} />
</div>
<div className="flex gap-2">
<button

View File

@@ -0,0 +1,103 @@
import { useEffect, useRef, useState } from "react";
import { LucideIcon } from "../lib/iconData";
const STATUS_CONFIG = {
researching: { icon: "search", label: "Researching" },
ordered: { icon: "truck", label: "Ordered" },
arrived: { icon: "check", label: "Arrived" },
} as const;
type CandidateStatus = keyof typeof STATUS_CONFIG;
interface StatusBadgeProps {
status: CandidateStatus;
onStatusChange: (status: CandidateStatus) => void;
}
export function StatusBadge({ status, onStatusChange }: StatusBadgeProps) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const config = STATUS_CONFIG[status];
useEffect(() => {
if (!isOpen) return;
function handleClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen]);
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsOpen((prev) => !prev);
}}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors cursor-pointer"
>
<LucideIcon name={config.icon} size={14} className="text-gray-500" />
{config.label}
</button>
{isOpen && (
<div className="absolute right-0 top-full mt-1 z-20 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-[150px]">
{(Object.keys(STATUS_CONFIG) as CandidateStatus[]).map((key) => {
const option = STATUS_CONFIG[key];
const isActive = key === status;
return (
<button
key={key}
type="button"
onClick={(e) => {
e.stopPropagation();
onStatusChange(key);
setIsOpen(false);
}}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-gray-50 transition-colors ${
isActive ? "bg-gray-50 font-medium" : ""
}`}
>
<LucideIcon
name={option.icon}
size={14}
className={isActive ? "text-gray-700" : "text-gray-400"}
/>
<span className={isActive ? "text-gray-900" : "text-gray-600"}>
{option.label}
</span>
{isActive && (
<LucideIcon
name="check"
size={14}
className="ml-auto text-gray-500"
/>
)}
</button>
);
})}
</div>
)}
</div>
);
}