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

557 lines
23 KiB
TypeScript

"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<number | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(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<typeof detailsForm | null>(null);
const [removedTags, setRemovedTags] = useState<string[]>([]);
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<HTMLInputElement>(null);
const tagsInputRef = useRef<HTMLInputElement>(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<HTMLFormElement>) {
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<typeof pendingDiscardRef.current>) {
if (!detailsOpen || !hasDetailsChanges()) {
runDiscardAction(action);
return;
}
pendingDiscardRef.current = action;
setDiscardOpen(true);
}
function runDiscardAction(action: NonNullable<typeof pendingDiscardRef.current>) {
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<HTMLFormElement>) {
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 (
<>
<div className="space-y-4">
<div className="panel panel-accent p-4">
<div className="card-header">
<div className="flex flex-wrap items-center gap-3">
<h2 className="card-title text-lg">Entries</h2>
<ToggleButtonGroup
value={entryTab}
onChange={setEntryTab}
ariaLabel="Entries tab"
sizeClassName="px-3 py-2 text-xs font-semibold"
options={[
{ value: "SINGLE", label: "Existing", className: "mr-[-10px] w-20" },
{ value: "RECURRING", label: "Scheduled", className: "w-20" }
]}
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setFilterOpen(true)}
className="rounded-lg btn-outline-accent px-3 py-1 text-xs font-semibold disabled:opacity-50"
disabled={!activeGroupId}
>
Filters{activeFilterCount ? ` (${activeFilterCount})` : ""}
</button>
<button
type="button"
onClick={() => setIsModalOpen(true)}
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 entry"
>
+
</button>
</div>
</div>
<EntriesList
activeGroupId={activeGroupId}
loading={loading}
entries={entries}
visibleEntries={visibleEntries}
activeFilterCount={activeFilterCount}
onOpenDetails={handleOpenDetails}
onClearFilters={handleClearFilters}
/>
</div>
</div>
<NewEntryModal
isOpen={isModalOpen && Boolean(activeGroupId)}
form={form}
error={error}
onClose={() => setIsModalOpen(false)}
onSubmit={handleCreate}
onChange={next => setForm(prev => ({ ...prev, ...next }))}
tagSuggestions={tagSuggestions}
emptyTagActionLabel={emptyTagActionLabel}
emptyTagActionDisabled={!canManageTags}
onEmptyTagAction={handleEmptyTagAction}
amountInputRef={amountInputRef}
tagsInputRef={tagsInputRef}
/>
<EntryDetailsModal
isOpen={detailsOpen}
form={detailsForm}
originalForm={detailsOriginal}
isDirty={hasDetailsChanges()}
error={error}
onClose={handleCloseDetails}
onSubmit={handleUpdate}
onRequestDelete={() => 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}
/>
<EntriesFilterModal
isOpen={filterOpen}
filters={filters}
setFilters={setFilters}
activeFilterCount={activeFilterCount}
tagSuggestions={tagSuggestions}
canManageTags={canManageTags}
emptyTagActionLabel={emptyTagActionLabel}
onEmptyTagAction={handleEmptyTagAction}
onClearFilters={handleClearFilters}
onFilterAddTag={handleFilterAddTag}
onFilterToggleTag={handleFilterToggleTag}
onClose={() => setFilterOpen(false)}
/>
<EntriesDiscardModal
isOpen={discardOpen}
onCancel={handleCancelDiscard}
onConfirm={handleConfirmDiscard}
/>
<ConfirmSlideModal
isOpen={confirmDeleteOpen}
title="Delete entry"
description="This will permanently remove the entry and its tags."
confirmLabel="Delete entry"
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() => {
setConfirmDeleteOpen(false);
handleDelete();
}}
/>
</>
);
}