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

207 lines
8.6 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 "@/features/buckets/components/new-bucket-modal";
import ConfirmSlideModal from "@/shared/components/modals/confirm-slide-modal";
import { bucketIcons } from "@/lib/shared/bucket-icons";
import BucketCard from "./bucket-card";
import { useEntryMutation } from "@/hooks/entry-mutation-context";
import { useNotificationsContext } from "@/hooks/notifications-context";
export default function BucketsPanel() {
const { activeGroupId } = useGroupsContext();
const { buckets, loading, error, createBucket, updateBucket, deleteBucket, reload } = useBuckets(activeGroupId);
const { mutationVersion } = useEntryMutation();
const { notify } = useNotificationsContext();
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 [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(() => {
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);
}
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) {
notify({ title: "Bucket updated", message: form.name.trim(), tone: "success" });
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) {
notify({ title: "Bucket created", message: form.name.trim(), tone: "success" });
setModalOpen(false);
}
}
}
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 [grid-template-columns:repeat(auto-fit,minmax(260px,1fr))]">
{!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 usageLabel = limit ? `$${spent.toFixed(2)} / $${limit.toFixed(2)}` : "";
return <BucketCard
key={bucket.id}
bucket={bucket}
icon={icon}
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}
canDelete={Boolean(editId)}
onDelete={() => {
if (!editId) return;
setConfirmDeleteId(editId);
}}
/>
<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 deletedBucket = buckets.find(bucket => bucket.id === confirmDeleteId) || null;
const ok = await deleteBucket(confirmDeleteId);
if (ok) {
notify({
title: "Bucket deleted",
message: deletedBucket?.name || "Bucket removed",
tone: "danger"
});
setConfirmDeleteId(null);
setModalOpen(false);
resetForm();
}
}}
/>
</>
);
}