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:
@@ -2,6 +2,7 @@ import { useWeightUnit } from "../hooks/useWeightUnit";
|
|||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
import { StatusBadge } from "./StatusBadge";
|
||||||
|
|
||||||
interface CandidateCardProps {
|
interface CandidateCardProps {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -14,6 +15,8 @@ interface CandidateCardProps {
|
|||||||
productUrl?: string | null;
|
productUrl?: string | null;
|
||||||
threadId: number;
|
threadId: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
status: "researching" | "ordered" | "arrived";
|
||||||
|
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CandidateCard({
|
export function CandidateCard({
|
||||||
@@ -27,6 +30,8 @@ export function CandidateCard({
|
|||||||
productUrl,
|
productUrl,
|
||||||
threadId,
|
threadId,
|
||||||
isActive,
|
isActive,
|
||||||
|
status,
|
||||||
|
onStatusChange,
|
||||||
}: CandidateCardProps) {
|
}: CandidateCardProps) {
|
||||||
const unit = useWeightUnit();
|
const unit = useWeightUnit();
|
||||||
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
||||||
@@ -106,6 +111,7 @@ export function CandidateCard({
|
|||||||
/>{" "}
|
/>{" "}
|
||||||
{categoryName}
|
{categoryName}
|
||||||
</span>
|
</span>
|
||||||
|
<StatusBadge status={status} onStatusChange={onStatusChange} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
103
src/client/components/StatusBadge.tsx
Normal file
103
src/client/components/StatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { CandidateCard } from "../../components/CandidateCard";
|
import { CandidateCard } from "../../components/CandidateCard";
|
||||||
|
import { useUpdateCandidate } from "../../hooks/useCandidates";
|
||||||
import { useThread } from "../../hooks/useThreads";
|
import { useThread } from "../../hooks/useThreads";
|
||||||
import { LucideIcon } from "../../lib/iconData";
|
import { LucideIcon } from "../../lib/iconData";
|
||||||
import { useUIStore } from "../../stores/uiStore";
|
import { useUIStore } from "../../stores/uiStore";
|
||||||
@@ -13,6 +14,7 @@ function ThreadDetailPage() {
|
|||||||
const threadId = Number(threadIdParam);
|
const threadId = Number(threadIdParam);
|
||||||
const { data: thread, isLoading, isError } = useThread(threadId);
|
const { data: thread, isLoading, isError } = useThread(threadId);
|
||||||
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
|
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
|
||||||
|
const updateCandidate = useUpdateCandidate(threadId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -144,6 +146,13 @@ function ThreadDetailPage() {
|
|||||||
productUrl={candidate.productUrl}
|
productUrl={candidate.productUrl}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
|
status={candidate.status}
|
||||||
|
onStatusChange={(newStatus) =>
|
||||||
|
updateCandidate.mutate({
|
||||||
|
candidateId: candidate.id,
|
||||||
|
status: newStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ export const updateThreadSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Candidate status
|
// Candidate status
|
||||||
export const candidateStatusSchema = z.enum(["researching", "ordered", "arrived"]);
|
export const candidateStatusSchema = z.enum([
|
||||||
|
"researching",
|
||||||
|
"ordered",
|
||||||
|
"arrived",
|
||||||
|
]);
|
||||||
|
|
||||||
// Candidate schemas (same fields as items)
|
// Candidate schemas (same fields as items)
|
||||||
export const createCandidateSchema = z.object({
|
export const createCandidateSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user