fiddy/apps/web/features/buckets/components/buckets-panel.tsx

211 lines
8.8 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { useGroupsContext } from "@/hooks/groups-context";
import useBuckets from "@/features/buckets/hooks/use-buckets";
import useTags from "@/features/tags/hooks/use-tags";
import NewBucketModal from "@/components/new-bucket-modal";
import ConfirmSlideModal from "@/components/confirm-slide-modal";
import { bucketIcons } from "@/lib/shared/bucket-icons";
import BucketCard from "./bucket-card";
import { useEntryMutation } from "@/hooks/entry-mutation-context";
export default function BucketsPanel() {
const { activeGroupId } = useGroupsContext();
const { buckets, loading, error, createBucket, updateBucket, deleteBucket, reload } = useBuckets(activeGroupId);
const { mutationVersion } = useEntryMutation();
const { tags: tagSuggestions } = useTags(activeGroupId);
const [modalOpen, setModalOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
const [menuOpenId, setMenuOpenId] = useState<number | null>(null);
const [expandedIds, setExpandedIds] = useState<number[]>([]);
const [form, setForm] = useState({
name: "",
description: "",
iconKey: "none",
budgetLimitDollars: "",
tags: [] as string[],
necessity: "BOTH",
windowDays: "30"
});
const iconMap = useMemo(() => new Map(bucketIcons.map(item => [item.key, item.icon])), []);
const orderedBuckets = useMemo(() => [...buckets].sort((a, b) => a.position - b.position || a.name.localeCompare(b.name)), [buckets]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (!menuOpenId) return;
const target = event.target as HTMLElement | null;
if (!target) return;
if (target.closest("[data-bucket-menu]") || target.closest("[data-bucket-menu-button]")) return;
setMenuOpenId(null);
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [menuOpenId]);
useEffect(() => {
if (!activeGroupId) return;
if (mutationVersion === 0) return;
reload();
}, [mutationVersion, activeGroupId, reload]);
function resetForm() {
setForm({ name: "", description: "", iconKey: "none", budgetLimitDollars: "", tags: [], necessity: "BOTH", windowDays: "30" });
setEditId(null);
}
function openCreate() {
resetForm();
setModalOpen(true);
}
function openEdit(bucketId: number) {
const bucket = buckets.find(item => item.id === bucketId);
if (!bucket) return;
setEditId(bucketId);
setForm({
name: bucket.name,
description: bucket.description || "",
iconKey: bucket.iconKey || "none",
budgetLimitDollars: bucket.budgetLimitDollars != null ? String(bucket.budgetLimitDollars) : "",
tags: bucket.tags || [],
necessity: bucket.necessity,
windowDays: bucket.windowDays ? String(bucket.windowDays) : "30"
});
setModalOpen(true);
setMenuOpenId(null);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const budget = form.budgetLimitDollars ? Number(form.budgetLimitDollars) : null;
const windowDays = form.windowDays ? Number(form.windowDays) : 30;
if (!form.name.trim()) return;
const iconKey = form.iconKey && form.iconKey !== "none" ? form.iconKey : null;
if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365) return;
if (editId) {
const ok = await updateBucket({
id: editId,
name: form.name.trim(),
description: form.description.trim() || undefined,
iconKey,
budgetLimitDollars: budget,
tags: form.tags,
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
windowDays
});
if (ok) setModalOpen(false);
} else {
const ok = await createBucket({
name: form.name.trim(),
description: form.description.trim() || undefined,
iconKey,
budgetLimitDollars: budget,
tags: form.tags,
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
windowDays
});
if (ok) setModalOpen(false);
}
}
function toggleExpanded(bucketId: number) {
setExpandedIds(prev => prev.includes(bucketId)
? prev.filter(id => id !== bucketId)
: [...prev, bucketId]);
}
function budgetUsage(bucket: typeof buckets[number]) {
const limit = bucket.budgetLimitDollars || 0;
const spent = bucket.totalUsage || 0;
return { limit, spent };
}
return (
<>
<div className="panel panel-accent p-4">
<div className="card-header">
<h2 className="card-title text-lg">Buckets</h2>
<button
type="button"
onClick={openCreate}
className="flex h-8 w-8 -translate-y-0.5 items-center justify-center rounded-full border border-accent bg-panel text-lg font-semibold leading-none hover:border-accent-strong disabled:opacity-50"
disabled={!activeGroupId}
aria-label="Add bucket"
>
+
</button>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{!activeGroupId ? (
<div className="text-sm text-muted">Select a group to view buckets.</div>
) : loading ? (
<div className="space-y-2">
{[0, 1].map(row => (
<div key={row} className="rounded-lg border border-accent-weak bg-panel px-3 py-3">
<div className="animate-pulse space-y-2">
<div className="h-4 w-28 rounded bg-surface" />
<div className="h-3 w-40 rounded bg-surface" />
</div>
</div>
))}
</div>
) : orderedBuckets.length ? (
orderedBuckets.map(bucket => {
const icon = bucket.iconKey ? iconMap.get(bucket.iconKey) : null;
const { limit, spent } = budgetUsage(bucket);
const isExpanded = expandedIds.includes(bucket.id);
const usageLabel = limit ? `$${spent.toFixed(2)} / $${limit.toFixed(2)}` : "";
return <BucketCard
key={bucket.id}
bucket={bucket}
icon={icon}
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
isMenuOpen={menuOpenId === bucket.id}
setMenuOpenId={setMenuOpenId}
setConfirmDeleteId={setConfirmDeleteId}
openEdit={openEdit}
limit={limit}
usageLabel={usageLabel}
/>
})
) : (
<div className="text-sm text-muted">No buckets yet.</div>
)}
</div>
</div>
<NewBucketModal
isOpen={modalOpen}
title={editId ? "Edit bucket" : "New bucket"}
form={form}
error={error}
onClose={() => setModalOpen(false)}
onSubmit={handleSubmit}
onChange={next => setForm(prev => ({ ...prev, ...next }))}
tagSuggestions={tagSuggestions}
/>
<ConfirmSlideModal
isOpen={Boolean(confirmDeleteId)}
title="Delete bucket"
description="This will permanently remove the bucket."
confirmLabel="Delete bucket"
onClose={() => setConfirmDeleteId(null)}
onConfirm={async () => {
if (!confirmDeleteId) return;
const ok = await deleteBucket(confirmDeleteId);
if (ok) setConfirmDeleteId(null);
}}
/>
</>
);
}