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:
91
src/client/components/CandidateCard.tsx
Normal file
91
src/client/components/CandidateCard.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { formatWeight, formatPrice } from "../lib/formatters";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
|
||||||
|
interface CandidateCardProps {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
weightGrams: number | null;
|
||||||
|
priceCents: number | null;
|
||||||
|
categoryName: string;
|
||||||
|
categoryEmoji: string;
|
||||||
|
imageFilename: string | null;
|
||||||
|
threadId: number;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CandidateCard({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
weightGrams,
|
||||||
|
priceCents,
|
||||||
|
categoryName,
|
||||||
|
categoryEmoji,
|
||||||
|
imageFilename,
|
||||||
|
threadId,
|
||||||
|
isActive,
|
||||||
|
}: CandidateCardProps) {
|
||||||
|
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
||||||
|
const openConfirmDeleteCandidate = useUIStore(
|
||||||
|
(s) => s.openConfirmDeleteCandidate,
|
||||||
|
);
|
||||||
|
const openResolveDialog = useUIStore((s) => s.openResolveDialog);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden">
|
||||||
|
{imageFilename && (
|
||||||
|
<div className="aspect-[4/3] bg-gray-50">
|
||||||
|
<img
|
||||||
|
src={`/uploads/${imageFilename}`}
|
||||||
|
alt={name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-2 truncate">
|
||||||
|
{name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
|
{weightGrams != null && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700">
|
||||||
|
{formatWeight(weightGrams)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{priceCents != null && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700">
|
||||||
|
{formatPrice(priceCents)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||||
|
{categoryEmoji} {categoryName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openCandidateEditPanel(id)}
|
||||||
|
className="text-xs text-gray-500 hover:text-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openConfirmDeleteCandidate(id)}
|
||||||
|
className="text-xs text-gray-500 hover:text-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{isActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openResolveDialog(threadId, id)}
|
||||||
|
className="ml-auto text-xs font-medium text-amber-600 hover:text-amber-700 transition-colors"
|
||||||
|
>
|
||||||
|
Pick Winner
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
290
src/client/components/CandidateForm.tsx
Normal file
290
src/client/components/CandidateForm.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
useCreateCandidate,
|
||||||
|
useUpdateCandidate,
|
||||||
|
} from "../hooks/useCandidates";
|
||||||
|
import { useThread } from "../hooks/useThreads";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
import { CategoryPicker } from "./CategoryPicker";
|
||||||
|
import { ImageUpload } from "./ImageUpload";
|
||||||
|
|
||||||
|
interface CandidateFormProps {
|
||||||
|
mode: "add" | "edit";
|
||||||
|
threadId: number;
|
||||||
|
candidateId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
name: string;
|
||||||
|
weightGrams: string;
|
||||||
|
priceDollars: string;
|
||||||
|
categoryId: number;
|
||||||
|
notes: string;
|
||||||
|
productUrl: string;
|
||||||
|
imageFilename: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_FORM: FormData = {
|
||||||
|
name: "",
|
||||||
|
weightGrams: "",
|
||||||
|
priceDollars: "",
|
||||||
|
categoryId: 1,
|
||||||
|
notes: "",
|
||||||
|
productUrl: "",
|
||||||
|
imageFilename: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CandidateForm({
|
||||||
|
mode,
|
||||||
|
threadId,
|
||||||
|
candidateId,
|
||||||
|
}: CandidateFormProps) {
|
||||||
|
const { data: thread } = useThread(threadId);
|
||||||
|
const createCandidate = useCreateCandidate(threadId);
|
||||||
|
const updateCandidate = useUpdateCandidate(threadId);
|
||||||
|
const closeCandidatePanel = useUIStore((s) => s.closeCandidatePanel);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormData>(INITIAL_FORM);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Pre-fill form when editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "edit" && candidateId != null && thread?.candidates) {
|
||||||
|
const candidate = thread.candidates.find((c) => c.id === candidateId);
|
||||||
|
if (candidate) {
|
||||||
|
setForm({
|
||||||
|
name: candidate.name,
|
||||||
|
weightGrams:
|
||||||
|
candidate.weightGrams != null ? String(candidate.weightGrams) : "",
|
||||||
|
priceDollars:
|
||||||
|
candidate.priceCents != null
|
||||||
|
? (candidate.priceCents / 100).toFixed(2)
|
||||||
|
: "",
|
||||||
|
categoryId: candidate.categoryId,
|
||||||
|
notes: candidate.notes ?? "",
|
||||||
|
productUrl: candidate.productUrl ?? "",
|
||||||
|
imageFilename: candidate.imageFilename,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (mode === "add") {
|
||||||
|
setForm(INITIAL_FORM);
|
||||||
|
}
|
||||||
|
}, [mode, candidateId, thread?.candidates]);
|
||||||
|
|
||||||
|
function validate(): boolean {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
newErrors.name = "Name is required";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
form.weightGrams &&
|
||||||
|
(isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
|
||||||
|
) {
|
||||||
|
newErrors.weightGrams = "Must be a positive number";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
form.priceDollars &&
|
||||||
|
(isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
|
||||||
|
) {
|
||||||
|
newErrors.priceDollars = "Must be a positive number";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
form.productUrl &&
|
||||||
|
form.productUrl.trim() !== "" &&
|
||||||
|
!form.productUrl.match(/^https?:\/\//)
|
||||||
|
) {
|
||||||
|
newErrors.productUrl = "Must be a valid URL (https://...)";
|
||||||
|
}
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
|
||||||
|
priceCents: form.priceDollars
|
||||||
|
? Math.round(Number(form.priceDollars) * 100)
|
||||||
|
: undefined,
|
||||||
|
categoryId: form.categoryId,
|
||||||
|
notes: form.notes.trim() || undefined,
|
||||||
|
productUrl: form.productUrl.trim() || undefined,
|
||||||
|
imageFilename: form.imageFilename ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === "add") {
|
||||||
|
createCandidate.mutate(payload, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setForm(INITIAL_FORM);
|
||||||
|
closeCandidatePanel();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (candidateId != null) {
|
||||||
|
updateCandidate.mutate(
|
||||||
|
{ candidateId, ...payload },
|
||||||
|
{ onSuccess: () => closeCandidatePanel() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPending = createCandidate.isPending || updateCandidate.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="candidate-name"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="candidate-name"
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="e.g. Osprey Talon 22"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weight */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="candidate-weight"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Weight (g)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="candidate-weight"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={form.weightGrams}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, weightGrams: e.target.value }))
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="e.g. 680"
|
||||||
|
/>
|
||||||
|
{errors.weightGrams && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{errors.weightGrams}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="candidate-price"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Price ($)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="candidate-price"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={form.priceDollars}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, priceDollars: e.target.value }))
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="e.g. 129.99"
|
||||||
|
/>
|
||||||
|
{errors.priceDollars && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{errors.priceDollars}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<CategoryPicker
|
||||||
|
value={form.categoryId}
|
||||||
|
onChange={(id) => setForm((f) => ({ ...f, categoryId: id }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="candidate-notes"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Notes
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="candidate-notes"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||||
|
placeholder="Any additional notes..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Link */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="candidate-url"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Product Link
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="candidate-url"
|
||||||
|
type="url"
|
||||||
|
value={form.productUrl}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, productUrl: e.target.value }))
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
{errors.productUrl && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{errors.productUrl}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Image
|
||||||
|
</label>
|
||||||
|
<ImageUpload
|
||||||
|
value={form.imageFilename}
|
||||||
|
onChange={(filename) =>
|
||||||
|
setForm((f) => ({ ...f, imageFilename: filename }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="flex-1 py-2.5 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isPending
|
||||||
|
? "Saving..."
|
||||||
|
: mode === "add"
|
||||||
|
? "Add Candidate"
|
||||||
|
: "Save Changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,54 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
import {
|
||||||
|
createRootRoute,
|
||||||
|
Outlet,
|
||||||
|
useMatchRoute,
|
||||||
|
useNavigate,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
import "../app.css";
|
import "../app.css";
|
||||||
import { TotalsBar } from "../components/TotalsBar";
|
import { TotalsBar } from "../components/TotalsBar";
|
||||||
import { SlideOutPanel } from "../components/SlideOutPanel";
|
import { SlideOutPanel } from "../components/SlideOutPanel";
|
||||||
import { ItemForm } from "../components/ItemForm";
|
import { ItemForm } from "../components/ItemForm";
|
||||||
|
import { CandidateForm } from "../components/CandidateForm";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { OnboardingWizard } from "../components/OnboardingWizard";
|
import { OnboardingWizard } from "../components/OnboardingWizard";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
import { useOnboardingComplete } from "../hooks/useSettings";
|
import { useOnboardingComplete } from "../hooks/useSettings";
|
||||||
|
import { useThread, useResolveThread } from "../hooks/useThreads";
|
||||||
|
import { useDeleteCandidate } from "../hooks/useCandidates";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
});
|
});
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Item panel state
|
||||||
const panelMode = useUIStore((s) => s.panelMode);
|
const panelMode = useUIStore((s) => s.panelMode);
|
||||||
const editingItemId = useUIStore((s) => s.editingItemId);
|
const editingItemId = useUIStore((s) => s.editingItemId);
|
||||||
const openAddPanel = useUIStore((s) => s.openAddPanel);
|
const openAddPanel = useUIStore((s) => s.openAddPanel);
|
||||||
const closePanel = useUIStore((s) => s.closePanel);
|
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 } =
|
const { data: onboardingComplete, isLoading: onboardingLoading } =
|
||||||
useOnboardingComplete();
|
useOnboardingComplete();
|
||||||
const [wizardDismissed, setWizardDismissed] = useState(false);
|
const [wizardDismissed, setWizardDismissed] = useState(false);
|
||||||
@@ -26,7 +56,16 @@ function RootLayout() {
|
|||||||
const showWizard =
|
const showWizard =
|
||||||
!onboardingLoading && onboardingComplete !== "true" && !wizardDismissed;
|
!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
|
// Show a minimal loading state while checking onboarding status
|
||||||
if (onboardingLoading) {
|
if (onboardingLoading) {
|
||||||
@@ -42,9 +81,9 @@ function RootLayout() {
|
|||||||
<TotalsBar />
|
<TotalsBar />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
||||||
{/* Slide-out Panel */}
|
{/* Item Slide-out Panel */}
|
||||||
<SlideOutPanel
|
<SlideOutPanel
|
||||||
isOpen={isOpen}
|
isOpen={isItemPanelOpen}
|
||||||
onClose={closePanel}
|
onClose={closePanel}
|
||||||
title={panelMode === "add" ? "Add Item" : "Edit Item"}
|
title={panelMode === "add" ? "Add Item" : "Edit Item"}
|
||||||
>
|
>
|
||||||
@@ -54,10 +93,55 @@ function RootLayout() {
|
|||||||
)}
|
)}
|
||||||
</SlideOutPanel>
|
</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 />
|
<ConfirmDialog />
|
||||||
|
|
||||||
{/* Floating Add Button */}
|
{/* 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openAddPanel}
|
onClick={openAddPanel}
|
||||||
@@ -78,6 +162,7 @@ function RootLayout() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Onboarding Wizard */}
|
{/* Onboarding Wizard */}
|
||||||
{showWizard && (
|
{showWizard && (
|
||||||
@@ -86,3 +171,125 @@ function RootLayout() {
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")({
|
export const Route = createFileRoute("/threads/$threadId")({
|
||||||
component: ThreadDetailPage,
|
component: ThreadDetailPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
function 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 (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<p>Thread {threadId} - detail page placeholder</p>
|
<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">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
search={{ tab: "planning" }}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
|
||||||
|
>
|
||||||
|
← 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user