231 lines
9.7 KiB
TypeScript
231 lines
9.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useGroupsContext } from "@/hooks/groups-context";
|
|
import useBuckets from "@/hooks/use-buckets";
|
|
import useTags from "@/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;
|
|
const pct = limit > 0 ? (spent / limit) * 100 : 0;
|
|
return { limit, spent, pct };
|
|
}
|
|
|
|
function renderUsageBar(bucket: typeof buckets[number]) {
|
|
const { limit, spent, pct } = budgetUsage(bucket);
|
|
if (!limit) return null;
|
|
const clamped = Math.max(0, pct);
|
|
const overage = Math.max(0, clamped - 100);
|
|
const overColor = clamped > 200 ? "bg-red-500" : "bg-yellow-400";
|
|
const tone = overage > 0 ? overColor : clamped >= 80 ? "bg-yellow-400" : "bg-green-400";
|
|
return (
|
|
<div className="mt-3">
|
|
<div className="h-2 w-full rounded-full bg-surface">
|
|
<div
|
|
className={`h-2 rounded-full ${tone}`}
|
|
style={{ width: `${Math.min(100, clamped)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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}
|
|
renderUsageBar={renderUsageBar}
|
|
/>
|
|
})
|
|
) : (
|
|
<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);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|