207 lines
8.6 KiB
TypeScript
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 "@/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";
|
|
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();
|
|
}
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|