"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import useEntries from "@/features/entries/hooks/use-entries"; import { useGroupsContext } from "@/hooks/groups-context"; import NewEntryModal from "@/components/new-entry-modal"; import EntryDetailsModal from "@/components/entry-details-modal"; import { useNotificationsContext } from "@/hooks/notifications-context"; import useTags from "@/features/tags/hooks/use-tags"; import ConfirmSlideModal from "@/components/confirm-slide-modal"; import useGroupSettings from "@/features/groups/hooks/use-group-settings"; import { useEntryMutation } from "@/hooks/entry-mutation-context"; import EntriesList from "@/features/entries/components/entries-list"; import EntriesFilterModal, { type EntriesFilters } from "@/features/entries/components/entries-filter-modal"; import EntriesDiscardModal from "@/features/entries/components/entries-discard-modal"; import ToggleButtonGroup from "@/components/toggle-button-group"; export default function EntriesPanel() { const today = new Date().toISOString().slice(0, 10); const { groups, activeGroupId } = useGroupsContext(); const router = useRouter(); const { entries, loading, error, createEntry, updateEntry, deleteEntry } = useEntries(activeGroupId); const { notifyEntryMutation } = useEntryMutation(); const { notify } = useNotificationsContext(); const { tags: tagSuggestions } = useTags(activeGroupId); const { settings } = useGroupSettings(activeGroupId); const activeGroup = groups.find(group => group.id === activeGroupId) || null; const canManageTags = Boolean(activeGroup && (activeGroup.role !== "MEMBER" || settings.allowMemberTagManage)); const emptyTagActionLabel = canManageTags ? "No Tags Assigned Yet - Click To Assign Tags" : "No Tags Assigned Yet - Contact Your Group Admin"; const [form, setForm] = useState({ amountDollars: "", occurredAt: today, necessity: "NECESSARY", notes: "", tags: [] as string[], entryType: "SPENDING" as "SPENDING" | "INCOME", isRecurring: false, frequency: "MONTHLY" as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY", intervalCount: 1, endCondition: "NEVER" as "NEVER" | "AFTER_COUNT" | "BY_DATE", endCount: "", endDate: "" }); const [isModalOpen, setIsModalOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); const [selectedId, setSelectedId] = useState(null); const [selectedIndex, setSelectedIndex] = useState(null); const [detailsForm, setDetailsForm] = useState({ amountDollars: "", occurredAt: today, necessity: "NECESSARY", notes: "", tags: [] as string[], entryType: "SPENDING" as "SPENDING" | "INCOME", isRecurring: false, frequency: "MONTHLY" as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY", intervalCount: 1, endCondition: "NEVER" as "NEVER" | "AFTER_COUNT" | "BY_DATE", endCount: "", endDate: "" }); const [detailsOriginal, setDetailsOriginal] = useState(null); const [removedTags, setRemovedTags] = useState([]); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [discardOpen, setDiscardOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false); const [entryTab, setEntryTab] = useState<"SINGLE" | "RECURRING">("SINGLE"); const emptyFilters: EntriesFilters = { amountMin: "", amountMax: "", dateFrom: "", dateTo: "", necessity: "ANY", notesQuery: "", tags: [] as string[], tagsMode: "ANY" as "ANY" | "ALL" }; const [filters, setFilters] = useState(emptyFilters); const amountInputRef = useRef(null); const tagsInputRef = useRef(null); const pendingDiscardRef = useRef< | { type: "close" } | { type: "prev" } | { type: "next" } | { type: "open"; entry: typeof entries[number]; index: number } | null >(null); const filteredEntries = useMemo(() => { if (!entries.length) return entries; const min = filters.amountMin ? Number(filters.amountMin) : null; const max = filters.amountMax ? Number(filters.amountMax) : null; const from = filters.dateFrom ? new Date(filters.dateFrom).getTime() : null; const to = filters.dateTo ? new Date(filters.dateTo).getTime() : null; const query = filters.notesQuery.trim().toLowerCase(); const tagsFilter = filters.tags.map(tag => tag.toLowerCase()); return entries.filter(entry => { if (min != null && entry.amountDollars < min) return false; if (max != null && entry.amountDollars > max) return false; if (from != null) { const time = new Date(entry.occurredAt).getTime(); if (!Number.isNaN(from) && time < from) return false; } if (to != null) { const time = new Date(entry.occurredAt).getTime(); if (!Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false; } if (filters.necessity !== "ANY" && entry.necessity !== filters.necessity) return false; if (query) { const notes = (entry.notes || "").toLowerCase(); if (!notes.includes(query)) return false; } if (tagsFilter.length) { const entryTags = (entry.tags || []).map(tag => tag.toLowerCase()); if (filters.tagsMode === "ALL") { if (!tagsFilter.every(tag => entryTags.includes(tag))) return false; } else if (!tagsFilter.some(tag => entryTags.includes(tag))) return false; } return true; }); }, [entries, filters]); const visibleEntries = useMemo(() => filteredEntries.filter(entry => entry.isRecurring === (entryTab === "RECURRING")), [filteredEntries, entryTab]); const totalEntries = visibleEntries.length; const activeFilterCount = useMemo(() => { let count = 0; if (filters.amountMin) count += 1; if (filters.amountMax) count += 1; if (filters.dateFrom) count += 1; if (filters.dateTo) count += 1; if (filters.necessity !== "ANY") count += 1; if (filters.notesQuery.trim()) count += 1; if (filters.tags.length) count += 1; return count; }, [filters]); function handleEmptyTagAction() { if (!activeGroupId || !canManageTags) return; router.push("/groups/settings"); } useEffect(() => { if (tagsInputRef.current) tagsInputRef.current.setCustomValidity(form.tags.length ? "" : "Please fill out this field"); }, [form.tags.length]); useEffect(() => { if (!filterOpen && !discardOpen) return; function handleKeyDown(event: KeyboardEvent) { if (event.key === "Escape") { if (discardOpen) handleCancelDiscard(); if (filterOpen) setFilterOpen(false); } } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [discardOpen, filterOpen]); async function handleCreate(e: React.FormEvent) { e.preventDefault(); if (tagsInputRef.current) tagsInputRef.current.setCustomValidity(form.tags.length ? "" : "Please fill out this field"); if (!e.currentTarget.reportValidity()) { if (!form.amountDollars) amountInputRef.current?.focus(); else if (!form.tags.length) tagsInputRef.current?.focus(); return; } const amountDollars = Number(form.amountDollars || 0); if (!Number.isFinite(amountDollars) || amountDollars <= 0) { return; } if (!form.occurredAt) { return; } if (!form.tags.length) { return; } const purchaseType = form.tags.join(", ") || "General"; const nextRunAt = form.isRecurring ? form.occurredAt : null; const createdEntry = await createEntry({ entryType: form.entryType, amountDollars, occurredAt: form.occurredAt, necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", purchaseType, notes: form.notes.trim() || undefined, tags: form.tags, isRecurring: form.isRecurring, frequency: form.isRecurring ? form.frequency : null, intervalCount: form.intervalCount, endCondition: form.isRecurring ? form.endCondition : null, endCount: form.endCondition === "AFTER_COUNT" ? Number(form.endCount || 0) || null : null, endDate: form.endCondition === "BY_DATE" ? form.endDate || null : null, nextRunAt }); if (createdEntry) { setForm({ amountDollars: "", occurredAt: today, necessity: "NECESSARY", notes: "", tags: [], entryType: "SPENDING", isRecurring: false, frequency: "MONTHLY", intervalCount: 1, endCondition: "NEVER", endCount: "", endDate: "" }); setIsModalOpen(false); notify({ title: "Entry added", message: `${form.tags.join(", ")} - $${amountDollars.toFixed(2)}`, tone: "success" }); notifyEntryMutation(); } } function setDetailsFromEntry(entry: typeof entries[number], index: number) { const nextId = Number(entry.id); if (!Number.isFinite(nextId) || nextId <= 0) { alert("Invalid entry id"); return; } const nextForm = { amountDollars: String(entry.amountDollars), occurredAt: new Date(entry.occurredAt).toISOString().slice(0, 10), necessity: entry.necessity, notes: entry.notes || "", tags: entry.tags || [], entryType: entry.entryType, isRecurring: entry.isRecurring, frequency: entry.frequency || "MONTHLY", intervalCount: entry.intervalCount || 1, endCondition: entry.endCondition || "NEVER", endCount: entry.endCount ? String(entry.endCount) : "", endDate: entry.endDate || "" }; setSelectedId(nextId); setSelectedIndex(index); setRemovedTags([]); setDetailsForm(nextForm); setDetailsOriginal(nextForm); setDetailsOpen(true); } function handleOpenDetails(entry: typeof entries[number], index: number) { requestDiscard({ type: "open", entry, index }); } function normalizeTags(tags: string[]) { return tags.map(tag => tag.toLowerCase()).sort(); } function handleFilterAddTag(tag: string) { setFilters(prev => (prev.tags.includes(tag) ? prev : { ...prev, tags: [...prev.tags, tag] })); } function handleFilterToggleTag(tag: string) { setFilters(prev => ({ ...prev, tags: prev.tags.filter(item => item !== tag) })); } function handleClearFilters() { setFilters(emptyFilters); } function hasDetailsChanges() { if (!detailsOriginal) return false; const currentTags = detailsForm.tags.filter(tag => !removedTags.includes(tag)); const currentTagsKey = normalizeTags(currentTags).join("|"); const originalTagsKey = normalizeTags(detailsOriginal.tags).join("|"); return ( detailsForm.amountDollars !== detailsOriginal.amountDollars || detailsForm.occurredAt !== detailsOriginal.occurredAt || detailsForm.necessity !== detailsOriginal.necessity || detailsForm.notes !== detailsOriginal.notes || detailsForm.entryType !== detailsOriginal.entryType || detailsForm.isRecurring !== detailsOriginal.isRecurring || detailsForm.frequency !== detailsOriginal.frequency || detailsForm.intervalCount !== detailsOriginal.intervalCount || detailsForm.endCondition !== detailsOriginal.endCondition || detailsForm.endCount !== detailsOriginal.endCount || detailsForm.endDate !== detailsOriginal.endDate || currentTagsKey !== originalTagsKey ); } function requestDiscard(action: NonNullable) { if (!detailsOpen || !hasDetailsChanges()) { runDiscardAction(action); return; } pendingDiscardRef.current = action; setDiscardOpen(true); } function runDiscardAction(action: NonNullable) { if (action.type === "close") { setDetailsOpen(false); setDetailsOriginal(null); setRemovedTags([]); return; } if (action.type === "open") { setDetailsFromEntry(action.entry, action.index); return; } if (!totalEntries) return; const current = selectedIndex ?? 0; if (action.type === "prev") { const nextIndex = current === 0 ? totalEntries - 1 : current - 1; setDetailsFromEntry(visibleEntries[nextIndex], nextIndex); } if (action.type === "next") { const nextIndex = current === totalEntries - 1 ? 0 : current + 1; setDetailsFromEntry(visibleEntries[nextIndex], nextIndex); } } async function handleUpdate(e: React.FormEvent) { e.preventDefault(); if (!selectedId) return; if (!hasDetailsChanges()) return; const amountDollars = Number(detailsForm.amountDollars || 0); if (!Number.isFinite(amountDollars) || amountDollars <= 0) { alert("Enter a valid amount"); return; } if (!detailsForm.occurredAt) { alert("Select a date"); return; } const nextTags = detailsForm.tags.filter(tag => !removedTags.includes(tag)); if (!nextTags.length) { alert("Add at least one tag"); return; } const purchaseType = nextTags.join(", ") || "General"; const nextRunAt = detailsForm.isRecurring ? detailsForm.occurredAt : null; const beforeEntry = entries.find(entry => Number(entry.id) === Number(selectedId)) || null; const updatedEntry = await updateEntry({ id: selectedId, entryType: detailsForm.entryType, amountDollars, occurredAt: detailsForm.occurredAt, necessity: detailsForm.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", purchaseType, notes: detailsForm.notes.trim() || undefined, tags: nextTags, isRecurring: detailsForm.isRecurring, frequency: detailsForm.isRecurring ? detailsForm.frequency : null, intervalCount: detailsForm.intervalCount, endCondition: detailsForm.isRecurring ? detailsForm.endCondition : null, endCount: detailsForm.endCondition === "AFTER_COUNT" ? Number(detailsForm.endCount || 0) || null : null, endDate: detailsForm.endCondition === "BY_DATE" ? detailsForm.endDate || null : null, nextRunAt }); if (updatedEntry) { setDetailsOpen(false); setDetailsOriginal(null); setRemovedTags([]); notify({ title: "Entry updated", message: `${nextTags.join(", ")} - $${amountDollars.toFixed(2)}` }); notifyEntryMutation(); } } async function handleDelete() { if (!selectedId || !Number.isFinite(selectedId)) return; const beforeEntry = entries.find(entry => Number(entry.id) === Number(selectedId)) || null; const deletedEntry = await deleteEntry(selectedId); if (deletedEntry || beforeEntry) { setDetailsOpen(false); setDetailsOriginal(null); setRemovedTags([]); notify({ title: "Entry deleted", message: detailsForm.tags.join(", ") || "Entry removed", tone: "danger" }); notifyEntryMutation(); } } function handleToggleTag(tag: string) { setRemovedTags(prev => prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag]); } function handleAddTag(tag: string) { setDetailsForm(prev => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags : [...prev.tags, tag] })); setRemovedTags(prev => prev.filter(item => item !== tag)); } function handlePrev() { if (!totalEntries) return; requestDiscard({ type: "prev" }); } function handleNext() { if (!totalEntries) return; requestDiscard({ type: "next" }); } function handleCloseDetails() { requestDiscard({ type: "close" }); } function handleConfirmDiscard() { const action = pendingDiscardRef.current; pendingDiscardRef.current = null; setDiscardOpen(false); if (action) runDiscardAction(action); } function handleCancelDiscard() { pendingDiscardRef.current = null; setDiscardOpen(false); } function handleRevertDetails() { if (!detailsOriginal) return; setDetailsForm(detailsOriginal); setRemovedTags([]); } return ( <>

Entries

setIsModalOpen(false)} onSubmit={handleCreate} onChange={next => setForm(prev => ({ ...prev, ...next }))} tagSuggestions={tagSuggestions} emptyTagActionLabel={emptyTagActionLabel} emptyTagActionDisabled={!canManageTags} onEmptyTagAction={handleEmptyTagAction} amountInputRef={amountInputRef} tagsInputRef={tagsInputRef} /> setConfirmDeleteOpen(true)} onRevert={handleRevertDetails} onChange={next => setDetailsForm(prev => ({ ...prev, ...next }))} onAddTag={handleAddTag} onToggleTag={handleToggleTag} removedTags={removedTags} tagSuggestions={tagSuggestions} emptyTagActionLabel={emptyTagActionLabel} emptyTagActionDisabled={!canManageTags} onEmptyTagAction={handleEmptyTagAction} onPrev={handlePrev} onNext={handleNext} loopHintPrev={selectedIndex === 0 && totalEntries > 1 ? "Loop" : ""} loopHintNext={selectedIndex === totalEntries - 1 && totalEntries > 1 ? "Loop" : ""} canNavigate={totalEntries > 1} /> setFilterOpen(false)} /> setConfirmDeleteOpen(false)} onConfirm={() => { setConfirmDeleteOpen(false); handleDelete(); }} /> ); }