Redesign WeightSummaryCard stats from a disconnected 4-column grid to a compact legend-style list with color dots, percentages, and a divider before the total row. Switch chart and legend colors to a neutral gray palette. Add a currency selector to settings (USD, EUR, GBP, JPY, CAD, AUD) that changes the displayed symbol across the app. This is visual only — no value conversion is performed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
309 lines
8.9 KiB
TypeScript
309 lines
8.9 KiB
TypeScript
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
|
import { useState } from "react";
|
|
import { CategoryHeader } from "../../components/CategoryHeader";
|
|
import { ItemCard } from "../../components/ItemCard";
|
|
import { ItemPicker } from "../../components/ItemPicker";
|
|
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
|
|
import {
|
|
useDeleteSetup,
|
|
useRemoveSetupItem,
|
|
useSetup,
|
|
useUpdateItemClassification,
|
|
} from "../../hooks/useSetups";
|
|
import { useCurrency } from "../../hooks/useCurrency";
|
|
import { useWeightUnit } from "../../hooks/useWeightUnit";
|
|
import { formatPrice, formatWeight } from "../../lib/formatters";
|
|
import { LucideIcon } from "../../lib/iconData";
|
|
|
|
export const Route = createFileRoute("/setups/$setupId")({
|
|
component: SetupDetailPage,
|
|
});
|
|
|
|
function SetupDetailPage() {
|
|
const { setupId } = Route.useParams();
|
|
const unit = useWeightUnit();
|
|
const currency = useCurrency();
|
|
const navigate = useNavigate();
|
|
const numericId = Number(setupId);
|
|
const { data: setup, isLoading } = useSetup(numericId);
|
|
const deleteSetup = useDeleteSetup();
|
|
const removeItem = useRemoveSetupItem(numericId);
|
|
const updateClassification = useUpdateItemClassification(numericId);
|
|
|
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
<div className="animate-pulse space-y-6">
|
|
<div className="h-8 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 (!setup) {
|
|
return (
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
|
|
<p className="text-gray-500">Setup not found.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Compute totals from items
|
|
const totalWeight = setup.items.reduce(
|
|
(sum, item) => sum + (item.weightGrams ?? 0),
|
|
0,
|
|
);
|
|
const totalCost = setup.items.reduce(
|
|
(sum, item) => sum + (item.priceCents ?? 0),
|
|
0,
|
|
);
|
|
const itemCount = setup.items.length;
|
|
const currentItemIds = setup.items.map((item) => item.id);
|
|
|
|
// Group items by category
|
|
const groupedItems = new Map<
|
|
number,
|
|
{
|
|
items: typeof setup.items;
|
|
categoryName: string;
|
|
categoryIcon: string;
|
|
}
|
|
>();
|
|
|
|
for (const item of setup.items) {
|
|
const group = groupedItems.get(item.categoryId);
|
|
if (group) {
|
|
group.items.push(item);
|
|
} else {
|
|
groupedItems.set(item.categoryId, {
|
|
items: [item],
|
|
categoryName: item.categoryName,
|
|
categoryIcon: item.categoryIcon,
|
|
});
|
|
}
|
|
}
|
|
|
|
function nextClassification(current: string): string {
|
|
const order = ["base", "worn", "consumable"];
|
|
const idx = order.indexOf(current);
|
|
return order[(idx + 1) % order.length];
|
|
}
|
|
|
|
function handleDelete() {
|
|
deleteSetup.mutate(numericId, {
|
|
onSuccess: () => navigate({ to: "/setups" }),
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
{/* Setup-specific sticky bar */}
|
|
<div className="sticky top-14 z-[9] bg-gray-50 border-b border-gray-100 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8">
|
|
<div className="flex items-center justify-between h-12">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<Link
|
|
to="/collection"
|
|
search={{ tab: "setups" }}
|
|
className="text-sm text-gray-500 hover:text-gray-700 shrink-0"
|
|
>
|
|
←
|
|
</Link>
|
|
<h2 className="text-base font-semibold text-gray-900 truncate">
|
|
{setup.name}
|
|
</h2>
|
|
</div>
|
|
<div className="flex items-center gap-4 text-sm text-gray-500">
|
|
<span>
|
|
<span className="font-medium text-gray-700">{itemCount}</span>{" "}
|
|
{itemCount === 1 ? "item" : "items"}
|
|
</span>
|
|
<span>
|
|
<span className="font-medium text-gray-700">
|
|
{formatWeight(totalWeight, unit)}
|
|
</span>{" "}
|
|
total
|
|
</span>
|
|
<span>
|
|
<span className="font-medium text-gray-700">
|
|
{formatPrice(totalCost, currency)}
|
|
</span>{" "}
|
|
cost
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-3 py-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPickerOpen(true)}
|
|
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 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 Items
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmDelete(true)}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors"
|
|
>
|
|
Delete Setup
|
|
</button>
|
|
</div>
|
|
|
|
{/* Empty state */}
|
|
{itemCount === 0 && (
|
|
<div className="py-16 text-center">
|
|
<div className="max-w-md mx-auto">
|
|
<div className="mb-4">
|
|
<LucideIcon
|
|
name="package"
|
|
size={48}
|
|
className="text-gray-400 mx-auto"
|
|
/>
|
|
</div>
|
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
|
No items in this setup
|
|
</h2>
|
|
<p className="text-sm text-gray-500 mb-6">
|
|
Add items from your collection to build this loadout.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPickerOpen(true)}
|
|
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
|
>
|
|
Add Items
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Weight summary card + items grouped by category */}
|
|
{itemCount > 0 && (
|
|
<div className="pb-6">
|
|
<WeightSummaryCard items={setup.items} />
|
|
{Array.from(groupedItems.entries()).map(
|
|
([
|
|
categoryId,
|
|
{ items: categoryItems, categoryName, categoryIcon },
|
|
]) => {
|
|
const catWeight = categoryItems.reduce(
|
|
(sum, item) => sum + (item.weightGrams ?? 0),
|
|
0,
|
|
);
|
|
const catCost = categoryItems.reduce(
|
|
(sum, item) => sum + (item.priceCents ?? 0),
|
|
0,
|
|
);
|
|
return (
|
|
<div key={categoryId} className="mb-8">
|
|
<CategoryHeader
|
|
categoryId={categoryId}
|
|
name={categoryName}
|
|
icon={categoryIcon}
|
|
totalWeight={catWeight}
|
|
totalCost={catCost}
|
|
itemCount={categoryItems.length}
|
|
/>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{categoryItems.map((item) => (
|
|
<ItemCard
|
|
key={item.id}
|
|
id={item.id}
|
|
name={item.name}
|
|
weightGrams={item.weightGrams}
|
|
priceCents={item.priceCents}
|
|
categoryName={categoryName}
|
|
categoryIcon={categoryIcon}
|
|
imageFilename={item.imageFilename}
|
|
productUrl={item.productUrl}
|
|
onRemove={() => removeItem.mutate(item.id)}
|
|
classification={item.classification}
|
|
onClassificationCycle={() =>
|
|
updateClassification.mutate({
|
|
itemId: item.id,
|
|
classification: nextClassification(
|
|
item.classification,
|
|
),
|
|
})
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Item Picker */}
|
|
<ItemPicker
|
|
setupId={numericId}
|
|
currentItemIds={currentItemIds}
|
|
isOpen={pickerOpen}
|
|
onClose={() => setPickerOpen(false)}
|
|
/>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
{confirmDelete && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div
|
|
className="absolute inset-0 bg-black/30"
|
|
onClick={() => setConfirmDelete(false)}
|
|
/>
|
|
<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 Setup
|
|
</h3>
|
|
<p className="text-sm text-gray-600 mb-6">
|
|
Are you sure you want to delete{" "}
|
|
<span className="font-medium">{setup.name}</span>? This will not
|
|
remove items from your collection.
|
|
</p>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmDelete(false)}
|
|
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={deleteSetup.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"
|
|
>
|
|
{deleteSetup.isPending ? "Deleting..." : "Delete"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|