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:
143
src/client/components/CategoryHeader.tsx
Normal file
143
src/client/components/CategoryHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user