feat: redesign weight summary legend and add currency selector
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>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
@@ -34,6 +35,7 @@ export function CandidateCard({
|
||||
onStatusChange,
|
||||
}: CandidateCardProps) {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
||||
const openConfirmDeleteCandidate = useUIStore(
|
||||
(s) => s.openConfirmDeleteCandidate,
|
||||
@@ -42,14 +44,74 @@ export function CandidateCard({
|
||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||
|
||||
return (
|
||||
<div className="relative bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCandidateEditPanel(id)}
|
||||
className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group"
|
||||
>
|
||||
{/* Hover-reveal action buttons */}
|
||||
{isActive && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openResolveDialog(threadId, id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
openResolveDialog(threadId, id);
|
||||
}
|
||||
}}
|
||||
className="absolute top-2 left-2 z-10 px-2 py-0.5 flex items-center gap-1 rounded-full text-xs font-medium bg-amber-100/90 text-amber-700 hover:bg-amber-200 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
|
||||
title="Pick as winner"
|
||||
>
|
||||
<LucideIcon name="trophy" size={12} />
|
||||
Winner
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openConfirmDeleteCandidate(id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
openConfirmDeleteCandidate(id);
|
||||
}
|
||||
}}
|
||||
className={`absolute top-2 ${productUrl ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-red-100 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
|
||||
title="Delete candidate"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{productUrl && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => openExternalLink(productUrl)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openExternalLink(productUrl);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
openExternalLink(productUrl);
|
||||
}
|
||||
}}
|
||||
@@ -92,7 +154,7 @@ export function CandidateCard({
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2 truncate">
|
||||
{name}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{weightGrams != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||
{formatWeight(weightGrams, unit)}
|
||||
@@ -100,7 +162,7 @@ export function CandidateCard({
|
||||
)}
|
||||
{priceCents != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||
{formatPrice(priceCents)}
|
||||
{formatPrice(priceCents, currency)}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||
@@ -113,32 +175,7 @@ export function CandidateCard({
|
||||
</span>
|
||||
<StatusBadge status={status} onStatusChange={onStatusChange} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCandidateEditPanel(id)}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 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>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
@@ -23,6 +24,7 @@ export function CategoryHeader({
|
||||
itemCount,
|
||||
}: CategoryHeaderProps) {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState(name);
|
||||
const [editIcon, setEditIcon] = useState(icon);
|
||||
@@ -87,7 +89,7 @@ export function CategoryHeader({
|
||||
<h2 className="text-lg font-semibold text-gray-900">{name}</h2>
|
||||
<span className="text-sm text-gray-400">
|
||||
{itemCount} {itemCount === 1 ? "item" : "items"} ·{" "}
|
||||
{formatWeight(totalWeight, unit)} · {formatPrice(totalCost)}
|
||||
{formatWeight(totalWeight, unit)} · {formatPrice(totalCost, currency)}
|
||||
</span>
|
||||
{!isUncategorized && (
|
||||
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
import { ClassificationBadge } from "./ClassificationBadge";
|
||||
|
||||
interface ItemCardProps {
|
||||
id: number;
|
||||
@@ -13,6 +15,8 @@ interface ItemCardProps {
|
||||
imageFilename: string | null;
|
||||
productUrl?: string | null;
|
||||
onRemove?: () => void;
|
||||
classification?: string;
|
||||
onClassificationCycle?: () => void;
|
||||
}
|
||||
|
||||
export function ItemCard({
|
||||
@@ -25,8 +29,11 @@ export function ItemCard({
|
||||
imageFilename,
|
||||
productUrl,
|
||||
onRemove,
|
||||
classification,
|
||||
onClassificationCycle,
|
||||
}: ItemCardProps) {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const openEditPanel = useUIStore((s) => s.openEditPanel);
|
||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||
|
||||
@@ -129,7 +136,7 @@ export function ItemCard({
|
||||
)}
|
||||
{priceCents != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||
{formatPrice(priceCents)}
|
||||
{formatPrice(priceCents, currency)}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||
@@ -140,6 +147,12 @@ export function ItemCard({
|
||||
/>{" "}
|
||||
{categoryName}
|
||||
</span>
|
||||
{classification && onClassificationCycle && (
|
||||
<ClassificationBadge
|
||||
classification={classification}
|
||||
onCycle={onClassificationCycle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useItems } from "../hooks/useItems";
|
||||
import { useSyncSetupItems } from "../hooks/useSetups";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
@@ -22,6 +23,7 @@ export function ItemPicker({
|
||||
const { data: items } = useItems();
|
||||
const syncItems = useSyncSetupItems(setupId);
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Reset selected IDs when panel opens
|
||||
@@ -121,7 +123,7 @@ export function ItemPicker({
|
||||
item.priceCents != null &&
|
||||
" · "}
|
||||
{item.priceCents != null &&
|
||||
formatPrice(item.priceCents)}
|
||||
formatPrice(item.priceCents, currency)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
|
||||
@@ -18,6 +19,7 @@ export function SetupCard({
|
||||
totalCost,
|
||||
}: SetupCardProps) {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
return (
|
||||
<Link
|
||||
to="/setups/$setupId"
|
||||
@@ -35,7 +37,7 @@ export function SetupCard({
|
||||
{formatWeight(totalWeight, unit)}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||
{formatPrice(totalCost)}
|
||||
{formatPrice(totalCost, currency)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { formatPrice } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
|
||||
@@ -22,10 +23,11 @@ function formatDate(iso: string): string {
|
||||
function formatPriceRange(
|
||||
min: number | null,
|
||||
max: number | null,
|
||||
currency: Parameters<typeof formatPrice>[1],
|
||||
): string | null {
|
||||
if (min == null && max == null) return null;
|
||||
if (min === max) return formatPrice(min);
|
||||
return `${formatPrice(min)} - ${formatPrice(max)}`;
|
||||
if (min === max) return formatPrice(min, currency);
|
||||
return `${formatPrice(min, currency)} - ${formatPrice(max, currency)}`;
|
||||
}
|
||||
|
||||
export function ThreadCard({
|
||||
@@ -40,9 +42,10 @@ export function ThreadCard({
|
||||
categoryIcon,
|
||||
}: ThreadCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const currency = useCurrency();
|
||||
|
||||
const isResolved = status === "resolved";
|
||||
const priceRange = formatPriceRange(minPriceCents, maxPriceCents);
|
||||
const priceRange = formatPriceRange(minPriceCents, maxPriceCents, currency);
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -10,24 +10,25 @@ import {
|
||||
import type { SetupItemWithCategory } from "../hooks/useSetups";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatWeight, type WeightUnit } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
|
||||
const CATEGORY_COLORS = [
|
||||
"#6366f1",
|
||||
"#f59e0b",
|
||||
"#10b981",
|
||||
"#ef4444",
|
||||
"#8b5cf6",
|
||||
"#06b6d4",
|
||||
"#f97316",
|
||||
"#ec4899",
|
||||
"#14b8a6",
|
||||
"#84cc16",
|
||||
"#374151",
|
||||
"#4b5563",
|
||||
"#6b7280",
|
||||
"#7f8a94",
|
||||
"#9ca3af",
|
||||
"#b0b7bf",
|
||||
"#c4c9cf",
|
||||
"#d1d5db",
|
||||
"#dfe2e6",
|
||||
"#e5e7eb",
|
||||
];
|
||||
|
||||
const CLASSIFICATION_COLORS: Record<string, string> = {
|
||||
base: "#6366f1",
|
||||
worn: "#f59e0b",
|
||||
consumable: "#10b981",
|
||||
base: "#6b7280",
|
||||
worn: "#9ca3af",
|
||||
consumable: "#d1d5db",
|
||||
};
|
||||
|
||||
const CLASSIFICATION_LABELS: Record<string, string> = {
|
||||
@@ -109,29 +110,34 @@ function CustomTooltip({
|
||||
);
|
||||
}
|
||||
|
||||
function SubtotalColumn({
|
||||
function LegendRow({
|
||||
color,
|
||||
label,
|
||||
weight,
|
||||
unit,
|
||||
color,
|
||||
percent,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
weight: number;
|
||||
unit: WeightUnit;
|
||||
color?: string;
|
||||
percent?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{color && (
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">{label}</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-600 flex-1">{label}</span>
|
||||
<span className="text-sm font-semibold text-gray-900 tabular-nums">
|
||||
{formatWeight(weight, unit)}
|
||||
</span>
|
||||
{percent != null && (
|
||||
<span className="text-xs text-gray-400 w-10 text-right tabular-nums">
|
||||
{(percent * 100).toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -237,27 +243,39 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Weight subtotals columns */}
|
||||
<div className="flex-1 grid grid-cols-4 gap-4">
|
||||
<SubtotalColumn
|
||||
label="Base"
|
||||
{/* Weight legend */}
|
||||
<div className="flex-1 flex flex-col justify-center min-w-0">
|
||||
<LegendRow
|
||||
color="#6b7280"
|
||||
label="Base Weight"
|
||||
weight={baseWeight}
|
||||
unit={unit}
|
||||
color="#6366f1"
|
||||
percent={totalWeight > 0 ? baseWeight / totalWeight : undefined}
|
||||
/>
|
||||
<SubtotalColumn
|
||||
<LegendRow
|
||||
color="#9ca3af"
|
||||
label="Worn"
|
||||
weight={wornWeight}
|
||||
unit={unit}
|
||||
color="#f59e0b"
|
||||
percent={totalWeight > 0 ? wornWeight / totalWeight : undefined}
|
||||
/>
|
||||
<SubtotalColumn
|
||||
<LegendRow
|
||||
color="#d1d5db"
|
||||
label="Consumable"
|
||||
weight={consumableWeight}
|
||||
unit={unit}
|
||||
color="#10b981"
|
||||
percent={totalWeight > 0 ? consumableWeight / totalWeight : undefined}
|
||||
/>
|
||||
<SubtotalColumn label="Total" weight={totalWeight} unit={unit} />
|
||||
<div className="border-t border-gray-200 mt-1.5 pt-1.5">
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<LucideIcon name="sigma" size={10} className="text-gray-400 shrink-0 ml-0.5" />
|
||||
<span className="text-sm font-medium text-gray-700 flex-1">Total</span>
|
||||
<span className="text-sm font-bold text-gray-900 tabular-nums">
|
||||
{formatWeight(totalWeight, unit)}
|
||||
</span>
|
||||
<span className="w-10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user