feat(01-03): add data hooks, utilities, UI store, and foundational components

- API fetch wrapper with error handling and multipart upload
- Weight/price formatters for display
- TanStack Query hooks for items, categories, and totals with cache invalidation
- Zustand UI store for panel and confirm dialog state
- TotalsBar, CategoryHeader, ItemCard, ConfirmDialog, ImageUpload components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 22:44:48 +01:00
parent a5df33a2d8
commit b099a47eb4
11 changed files with 647 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
import { useState } from "react";
import { formatWeight, formatPrice } from "../lib/formatters";
import { useUpdateCategory, useDeleteCategory } from "../hooks/useCategories";
interface CategoryHeaderProps {
categoryId: number;
name: string;
emoji: string;
totalWeight: number;
totalCost: number;
itemCount: number;
}
export function CategoryHeader({
categoryId,
name,
emoji,
totalWeight,
totalCost,
itemCount,
}: CategoryHeaderProps) {
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(name);
const [editEmoji, setEditEmoji] = useState(emoji);
const updateCategory = useUpdateCategory();
const deleteCategory = useDeleteCategory();
const isUncategorized = categoryId === 1;
function handleSave() {
if (!editName.trim()) return;
updateCategory.mutate(
{ id: categoryId, name: editName.trim(), emoji: editEmoji },
{ onSuccess: () => setIsEditing(false) },
);
}
function handleDelete() {
if (
confirm(`Delete category "${name}"? Items will be moved to Uncategorized.`)
) {
deleteCategory.mutate(categoryId);
}
}
if (isEditing) {
return (
<div className="flex items-center gap-3 py-4">
<input
type="text"
value={editEmoji}
onChange={(e) => setEditEmoji(e.target.value)}
className="w-12 text-center text-xl border border-gray-200 rounded-md px-1 py-1"
maxLength={4}
/>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="text-lg font-semibold border border-gray-200 rounded-md px-2 py-1"
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") setIsEditing(false);
}}
autoFocus
/>
<button
type="button"
onClick={handleSave}
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
>
Save
</button>
<button
type="button"
onClick={() => setIsEditing(false)}
className="text-sm text-gray-400 hover:text-gray-600"
>
Cancel
</button>
</div>
);
}
return (
<div className="group flex items-center gap-3 py-4">
<span className="text-xl">{emoji}</span>
<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)} · {formatPrice(totalCost)}
</span>
{!isUncategorized && (
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => {
setEditName(name);
setEditEmoji(emoji);
setIsEditing(true);
}}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
title="Edit category"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
<button
type="button"
onClick={handleDelete}
className="p-1 text-gray-400 hover:text-red-500 rounded"
title="Delete category"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
)}
</div>
);
}