780 lines
36 KiB
TypeScript
780 lines
36 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 useSchedules from "@/features/entries/hooks/use-schedules";
|
|
import { useGroupsContext } from "@/hooks/groups-context";
|
|
import { useNotificationsContext } from "@/hooks/notifications-context";
|
|
import { useEntryMutation } from "@/hooks/entry-mutation-context";
|
|
import useTags from "@/features/tags/hooks/use-tags";
|
|
import useGroupSettings from "@/features/groups/hooks/use-group-settings";
|
|
import useUserSettings from "@/hooks/use-user-settings";
|
|
import ToggleButtonGroup from "@/components/toggle-button-group";
|
|
import NewEntryModal from "@/components/new-entry-modal";
|
|
import EntryDetailsModal from "@/components/entry-details-modal";
|
|
import NewScheduleModal, { type NewScheduleForm } from "@/components/new-schedule-modal";
|
|
import ScheduleDetailsModal, { type ScheduleDetailsForm } from "@/components/schedule-details-modal";
|
|
import EntriesList from "@/features/entries/components/entries-list";
|
|
import SchedulesList from "@/features/entries/components/schedules-list";
|
|
import EntriesFilterModal, { type EntriesFilters } from "@/features/entries/components/entries-filter-modal";
|
|
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
|
|
|
const EMPTY_FILTERS: EntriesFilters = {
|
|
amountMin: "",
|
|
amountMax: "",
|
|
dateFrom: "",
|
|
dateTo: "",
|
|
necessity: "ANY",
|
|
notesQuery: "",
|
|
tags: [],
|
|
tagsMode: "ANY"
|
|
};
|
|
|
|
function normalizeTagList(tags: string[]) {
|
|
return tags.map(tag => tag.toLowerCase()).sort().join("|");
|
|
}
|
|
|
|
function isEditableTarget(target: EventTarget | null) {
|
|
if (!(target instanceof HTMLElement)) return false;
|
|
if (target.isContentEditable) return true;
|
|
const tag = target.tagName;
|
|
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
|
}
|
|
|
|
function ListProgressSignal({
|
|
hasMore,
|
|
shownCount,
|
|
totalCount,
|
|
noun
|
|
}: {
|
|
hasMore: boolean;
|
|
shownCount: number;
|
|
totalCount: number;
|
|
noun: "entries" | "schedules";
|
|
}) {
|
|
if (totalCount <= 0) return null;
|
|
|
|
return (
|
|
<div
|
|
className={`mt-3 flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-xs ${hasMore ? "border-accent-weak bg-accent-soft text-[color:var(--color-text)]" : "border-accent-weak bg-panel text-soft"}`}
|
|
aria-live="polite"
|
|
>
|
|
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full border ${hasMore ? "border-accent bg-panel text-[color:var(--color-accent)]" : "border-accent-weak text-soft"}`}>
|
|
{hasMore ? "\u21e3" : "\u2713"}
|
|
</span>
|
|
<span>
|
|
{hasMore
|
|
? `Keep scrolling for more ${noun} (${shownCount} of ${totalCount})`
|
|
: `You have reached the end of ${noun} (${totalCount} total)`}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function EntriesPanel() {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const { groups, activeGroupId } = useGroupsContext();
|
|
const { entries, loading: entriesLoading, error: entriesError, createEntry, updateEntry, deleteEntry } = useEntries(activeGroupId);
|
|
const { schedules, loading: schedulesLoading, error: schedulesError, createSchedule, updateSchedule, deleteSchedule } = useSchedules(activeGroupId);
|
|
const { settings: userSettings } = useUserSettings();
|
|
const { notify } = useNotificationsContext();
|
|
const { notifyEntryMutation } = useEntryMutation();
|
|
const { tags: tagSuggestions } = useTags(activeGroupId);
|
|
const { settings: groupSettings } = useGroupSettings(activeGroupId);
|
|
const router = useRouter();
|
|
|
|
const activeGroup = groups.find(group => group.id === activeGroupId) || null;
|
|
const canManageTags = Boolean(activeGroup && (activeGroup.role !== "MEMBER" || groupSettings.allowMemberTagManage));
|
|
const emptyTagActionLabel = canManageTags
|
|
? "No Tags Assigned Yet - Click To Assign Tags"
|
|
: "No Tags Assigned Yet - Contact Your Group Admin";
|
|
|
|
const pageSize = Math.max(1, Number(userSettings.entryPanelPageSize || 10));
|
|
|
|
const [entryTab, setEntryTab] = useState<"ENTRIES" | "SCHEDULES">("ENTRIES");
|
|
const [filters, setFilters] = useState<EntriesFilters>(EMPTY_FILTERS);
|
|
const [entryVisibleCount, setEntryVisibleCount] = useState(pageSize);
|
|
const [scheduleVisibleCount, setScheduleVisibleCount] = useState(pageSize);
|
|
const [filterOpen, setFilterOpen] = useState(false);
|
|
|
|
const [newEntryOpen, setNewEntryOpen] = useState(false);
|
|
const [newScheduleOpen, setNewScheduleOpen] = useState(false);
|
|
const [entryDetailsOpen, setEntryDetailsOpen] = useState(false);
|
|
const [scheduleDetailsOpen, setScheduleDetailsOpen] = useState(false);
|
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
const [deleteTarget, setDeleteTarget] = useState<"ENTRY" | "SCHEDULE">("ENTRY");
|
|
|
|
const [selectedEntryId, setSelectedEntryId] = useState<number | null>(null);
|
|
const [selectedEntryIndex, setSelectedEntryIndex] = useState<number | null>(null);
|
|
const [selectedScheduleId, setSelectedScheduleId] = useState<number | null>(null);
|
|
|
|
const [entryRemovedTags, setEntryRemovedTags] = useState<string[]>([]);
|
|
const [scheduleRemovedTags, setScheduleRemovedTags] = useState<string[]>([]);
|
|
|
|
const [entryForm, setEntryForm] = useState({
|
|
amountDollars: "",
|
|
occurredAt: today,
|
|
necessity: "NECESSARY",
|
|
notes: "",
|
|
tags: [] as string[],
|
|
entryType: "SPENDING" as "SPENDING" | "INCOME"
|
|
});
|
|
const [entryDetailsForm, setEntryDetailsForm] = useState({
|
|
amountDollars: "",
|
|
occurredAt: today,
|
|
necessity: "NECESSARY",
|
|
notes: "",
|
|
tags: [] as string[],
|
|
entryType: "SPENDING" as "SPENDING" | "INCOME"
|
|
});
|
|
const [entryDetailsOriginal, setEntryDetailsOriginal] = useState<typeof entryDetailsForm | null>(null);
|
|
|
|
const [scheduleForm, setScheduleForm] = useState<NewScheduleForm>({
|
|
amountDollars: "",
|
|
startsOn: today,
|
|
necessity: "NECESSARY",
|
|
notes: "",
|
|
tags: [],
|
|
entryType: "SPENDING",
|
|
frequency: "MONTHLY",
|
|
intervalCount: 1,
|
|
endCondition: "NEVER",
|
|
endCount: "",
|
|
endDate: "",
|
|
createEntryNow: false
|
|
});
|
|
const [scheduleDetailsForm, setScheduleDetailsForm] = useState<ScheduleDetailsForm>({
|
|
amountDollars: "",
|
|
startsOn: today,
|
|
necessity: "NECESSARY",
|
|
notes: "",
|
|
tags: [],
|
|
entryType: "SPENDING",
|
|
frequency: "MONTHLY",
|
|
intervalCount: 1,
|
|
endCondition: "NEVER",
|
|
endCount: "",
|
|
endDate: "",
|
|
nextRunOn: today,
|
|
isActive: true
|
|
});
|
|
const [scheduleDetailsOriginal, setScheduleDetailsOriginal] = useState<ScheduleDetailsForm | null>(null);
|
|
|
|
const amountInputRef = useRef<HTMLInputElement>(null);
|
|
const tagsInputRef = useRef<HTMLInputElement>(null);
|
|
const entriesLoadSentinelRef = useRef<HTMLDivElement>(null);
|
|
const schedulesLoadSentinelRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
setEntryVisibleCount(pageSize);
|
|
setScheduleVisibleCount(pageSize);
|
|
}, [pageSize]);
|
|
|
|
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]);
|
|
|
|
const filteredEntries = useMemo(() => {
|
|
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;
|
|
const time = new Date(entry.occurredAt).getTime();
|
|
if (from != null && !Number.isNaN(from) && time < from) return false;
|
|
if (to != null && !Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false;
|
|
if (filters.necessity !== "ANY" && entry.necessity !== filters.necessity) return false;
|
|
if (query && !(entry.notes || "").toLowerCase().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 filteredSchedules = useMemo(() => {
|
|
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 schedules.filter(schedule => {
|
|
if (min != null && schedule.amountDollars < min) return false;
|
|
if (max != null && schedule.amountDollars > max) return false;
|
|
const time = new Date(schedule.startsOn).getTime();
|
|
if (from != null && !Number.isNaN(from) && time < from) return false;
|
|
if (to != null && !Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false;
|
|
if (filters.necessity !== "ANY" && schedule.necessity !== filters.necessity) return false;
|
|
if (query && !(schedule.notes || "").toLowerCase().includes(query)) return false;
|
|
if (tagsFilter.length) {
|
|
const scheduleTags = (schedule.tags || []).map(tag => tag.toLowerCase());
|
|
if (filters.tagsMode === "ALL") {
|
|
if (!tagsFilter.every(tag => scheduleTags.includes(tag))) return false;
|
|
} else if (!tagsFilter.some(tag => scheduleTags.includes(tag))) return false;
|
|
}
|
|
return true;
|
|
});
|
|
}, [schedules, filters]);
|
|
|
|
const visibleEntries = useMemo(() => filteredEntries.slice(0, entryVisibleCount), [filteredEntries, entryVisibleCount]);
|
|
const visibleSchedules = useMemo(() => filteredSchedules.slice(0, scheduleVisibleCount), [filteredSchedules, scheduleVisibleCount]);
|
|
const hasMoreEntries = filteredEntries.length > visibleEntries.length;
|
|
const hasMoreSchedules = filteredSchedules.length > visibleSchedules.length;
|
|
|
|
useEffect(() => {
|
|
if (entryTab !== "ENTRIES" || !hasMoreEntries) return;
|
|
|
|
let touchY: number | null = null;
|
|
let lastLoadAt = 0;
|
|
let lastScrollY = window.scrollY;
|
|
|
|
function shouldLoadMore() {
|
|
const sentinel = entriesLoadSentinelRef.current;
|
|
if (!sentinel) return false;
|
|
const rect = sentinel.getBoundingClientRect();
|
|
return rect.top <= window.innerHeight + 48;
|
|
}
|
|
|
|
function tryLoadMore() {
|
|
if (!shouldLoadMore()) return;
|
|
const now = Date.now();
|
|
if (now - lastLoadAt < 150) return;
|
|
lastLoadAt = now;
|
|
setEntryVisibleCount(prev => {
|
|
if (prev >= filteredEntries.length) return prev;
|
|
return Math.min(prev + pageSize, filteredEntries.length);
|
|
});
|
|
}
|
|
|
|
function onWheel(event: WheelEvent) {
|
|
if (event.deltaY <= 0) return;
|
|
tryLoadMore();
|
|
}
|
|
|
|
function onScroll() {
|
|
const nextY = window.scrollY;
|
|
if (nextY <= lastScrollY) {
|
|
lastScrollY = nextY;
|
|
return;
|
|
}
|
|
lastScrollY = nextY;
|
|
tryLoadMore();
|
|
}
|
|
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
if (isEditableTarget(event.target)) return;
|
|
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "End" || (event.key === " " && !event.shiftKey)) {
|
|
tryLoadMore();
|
|
}
|
|
}
|
|
|
|
function onTouchStart(event: TouchEvent) {
|
|
touchY = event.touches[0]?.clientY ?? null;
|
|
}
|
|
|
|
function onTouchMove(event: TouchEvent) {
|
|
const nextY = event.touches[0]?.clientY;
|
|
if (touchY == null || nextY == null) return;
|
|
const delta = touchY - nextY;
|
|
touchY = nextY;
|
|
if (delta > 10) tryLoadMore();
|
|
}
|
|
|
|
window.addEventListener("wheel", onWheel, { passive: true });
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
window.addEventListener("keydown", onKeyDown);
|
|
window.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
window.addEventListener("touchmove", onTouchMove, { passive: true });
|
|
|
|
return () => {
|
|
window.removeEventListener("wheel", onWheel);
|
|
window.removeEventListener("scroll", onScroll);
|
|
window.removeEventListener("keydown", onKeyDown);
|
|
window.removeEventListener("touchstart", onTouchStart);
|
|
window.removeEventListener("touchmove", onTouchMove);
|
|
};
|
|
}, [entryTab, hasMoreEntries, filteredEntries.length, pageSize]);
|
|
|
|
useEffect(() => {
|
|
if (entryTab !== "SCHEDULES" || !hasMoreSchedules) return;
|
|
|
|
let touchY: number | null = null;
|
|
let lastLoadAt = 0;
|
|
let lastScrollY = window.scrollY;
|
|
|
|
function shouldLoadMore() {
|
|
const sentinel = schedulesLoadSentinelRef.current;
|
|
if (!sentinel) return false;
|
|
const rect = sentinel.getBoundingClientRect();
|
|
return rect.top <= window.innerHeight + 48;
|
|
}
|
|
|
|
function tryLoadMore() {
|
|
if (!shouldLoadMore()) return;
|
|
const now = Date.now();
|
|
if (now - lastLoadAt < 150) return;
|
|
lastLoadAt = now;
|
|
setScheduleVisibleCount(prev => {
|
|
if (prev >= filteredSchedules.length) return prev;
|
|
return Math.min(prev + pageSize, filteredSchedules.length);
|
|
});
|
|
}
|
|
|
|
function onWheel(event: WheelEvent) {
|
|
if (event.deltaY <= 0) return;
|
|
tryLoadMore();
|
|
}
|
|
|
|
function onScroll() {
|
|
const nextY = window.scrollY;
|
|
if (nextY <= lastScrollY) {
|
|
lastScrollY = nextY;
|
|
return;
|
|
}
|
|
lastScrollY = nextY;
|
|
tryLoadMore();
|
|
}
|
|
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
if (isEditableTarget(event.target)) return;
|
|
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "End" || (event.key === " " && !event.shiftKey)) {
|
|
tryLoadMore();
|
|
}
|
|
}
|
|
|
|
function onTouchStart(event: TouchEvent) {
|
|
touchY = event.touches[0]?.clientY ?? null;
|
|
}
|
|
|
|
function onTouchMove(event: TouchEvent) {
|
|
const nextY = event.touches[0]?.clientY;
|
|
if (touchY == null || nextY == null) return;
|
|
const delta = touchY - nextY;
|
|
touchY = nextY;
|
|
if (delta > 10) tryLoadMore();
|
|
}
|
|
|
|
window.addEventListener("wheel", onWheel, { passive: true });
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
window.addEventListener("keydown", onKeyDown);
|
|
window.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
window.addEventListener("touchmove", onTouchMove, { passive: true });
|
|
|
|
return () => {
|
|
window.removeEventListener("wheel", onWheel);
|
|
window.removeEventListener("scroll", onScroll);
|
|
window.removeEventListener("keydown", onKeyDown);
|
|
window.removeEventListener("touchstart", onTouchStart);
|
|
window.removeEventListener("touchmove", onTouchMove);
|
|
};
|
|
}, [entryTab, hasMoreSchedules, filteredSchedules.length, pageSize]);
|
|
|
|
function clearFilters() {
|
|
setFilters(EMPTY_FILTERS);
|
|
setEntryVisibleCount(pageSize);
|
|
setScheduleVisibleCount(pageSize);
|
|
}
|
|
|
|
function handleEmptyTagAction() {
|
|
if (!canManageTags) return;
|
|
router.push("/groups/settings");
|
|
}
|
|
|
|
function hasEntryChanges() {
|
|
if (!entryDetailsOriginal) return false;
|
|
const tags = entryDetailsForm.tags.filter(tag => !entryRemovedTags.includes(tag));
|
|
return (
|
|
entryDetailsForm.amountDollars !== entryDetailsOriginal.amountDollars ||
|
|
entryDetailsForm.occurredAt !== entryDetailsOriginal.occurredAt ||
|
|
entryDetailsForm.necessity !== entryDetailsOriginal.necessity ||
|
|
entryDetailsForm.notes !== entryDetailsOriginal.notes ||
|
|
entryDetailsForm.entryType !== entryDetailsOriginal.entryType ||
|
|
normalizeTagList(tags) !== normalizeTagList(entryDetailsOriginal.tags)
|
|
);
|
|
}
|
|
|
|
function hasScheduleChanges() {
|
|
if (!scheduleDetailsOriginal) return false;
|
|
const tags = scheduleDetailsForm.tags.filter(tag => !scheduleRemovedTags.includes(tag));
|
|
return (
|
|
scheduleDetailsForm.amountDollars !== scheduleDetailsOriginal.amountDollars ||
|
|
scheduleDetailsForm.startsOn !== scheduleDetailsOriginal.startsOn ||
|
|
scheduleDetailsForm.necessity !== scheduleDetailsOriginal.necessity ||
|
|
scheduleDetailsForm.notes !== scheduleDetailsOriginal.notes ||
|
|
scheduleDetailsForm.entryType !== scheduleDetailsOriginal.entryType ||
|
|
scheduleDetailsForm.frequency !== scheduleDetailsOriginal.frequency ||
|
|
scheduleDetailsForm.intervalCount !== scheduleDetailsOriginal.intervalCount ||
|
|
scheduleDetailsForm.endCondition !== scheduleDetailsOriginal.endCondition ||
|
|
scheduleDetailsForm.endCount !== scheduleDetailsOriginal.endCount ||
|
|
scheduleDetailsForm.endDate !== scheduleDetailsOriginal.endDate ||
|
|
scheduleDetailsForm.nextRunOn !== scheduleDetailsOriginal.nextRunOn ||
|
|
scheduleDetailsForm.isActive !== scheduleDetailsOriginal.isActive ||
|
|
normalizeTagList(tags) !== normalizeTagList(scheduleDetailsOriginal.tags)
|
|
);
|
|
}
|
|
|
|
async function submitNewEntry(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault();
|
|
if (!e.currentTarget.reportValidity()) return;
|
|
const amountDollars = Number(entryForm.amountDollars || 0);
|
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0 || !entryForm.tags.length) return;
|
|
const created = await createEntry({
|
|
entryType: entryForm.entryType,
|
|
amountDollars,
|
|
occurredAt: entryForm.occurredAt,
|
|
necessity: entryForm.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
|
purchaseType: entryForm.tags.join(", ") || "General",
|
|
notes: entryForm.notes.trim() || undefined,
|
|
tags: entryForm.tags
|
|
});
|
|
if (!created) return;
|
|
setNewEntryOpen(false);
|
|
setEntryForm({ amountDollars: "", occurredAt: today, necessity: "NECESSARY", notes: "", tags: [], entryType: "SPENDING" });
|
|
notify({ title: "Entry added", message: `${created.tags.join(", ")} - $${created.amountDollars.toFixed(2)}`, tone: "success" });
|
|
notifyEntryMutation();
|
|
}
|
|
|
|
async function submitNewSchedule(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault();
|
|
const amountDollars = Number(scheduleForm.amountDollars || 0);
|
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0 || !scheduleForm.tags.length) return;
|
|
const created = await createSchedule({
|
|
entryType: scheduleForm.entryType,
|
|
amountDollars,
|
|
startsOn: scheduleForm.startsOn,
|
|
necessity: scheduleForm.necessity,
|
|
purchaseType: scheduleForm.tags.join(", ") || "General",
|
|
notes: scheduleForm.notes.trim() || undefined,
|
|
tags: scheduleForm.tags,
|
|
frequency: scheduleForm.frequency,
|
|
intervalCount: scheduleForm.intervalCount,
|
|
endCondition: scheduleForm.endCondition,
|
|
endCount: scheduleForm.endCondition === "AFTER_COUNT" ? Number(scheduleForm.endCount || 0) || null : null,
|
|
endDate: scheduleForm.endCondition === "BY_DATE" ? scheduleForm.endDate || null : null,
|
|
createEntryNow: scheduleForm.createEntryNow
|
|
});
|
|
if (!created) return;
|
|
setNewScheduleOpen(false);
|
|
setScheduleForm({ amountDollars: "", startsOn: today, necessity: "NECESSARY", notes: "", tags: [], entryType: "SPENDING", frequency: "MONTHLY", intervalCount: 1, endCondition: "NEVER", endCount: "", endDate: "", createEntryNow: false });
|
|
notify({ title: "Schedule added", message: `${created.tags.join(", ") || "Schedule"} - $${created.amountDollars.toFixed(2)}`, tone: "success" });
|
|
if (scheduleForm.createEntryNow) notifyEntryMutation();
|
|
}
|
|
|
|
function openEntryDetails(id: number) {
|
|
const index = filteredEntries.findIndex(entry => Number(entry.id) === Number(id));
|
|
if (index < 0) return;
|
|
const entry = filteredEntries[index];
|
|
const form = { amountDollars: String(entry.amountDollars), occurredAt: entry.occurredAt, necessity: entry.necessity, notes: entry.notes || "", tags: entry.tags || [], entryType: entry.entryType };
|
|
setSelectedEntryId(Number(id));
|
|
setSelectedEntryIndex(index);
|
|
setEntryDetailsForm(form);
|
|
setEntryDetailsOriginal(form);
|
|
setEntryRemovedTags([]);
|
|
setEntryDetailsOpen(true);
|
|
}
|
|
|
|
function openScheduleDetails(id: number) {
|
|
const schedule = schedules.find(item => Number(item.id) === Number(id));
|
|
if (!schedule) return;
|
|
const form: ScheduleDetailsForm = {
|
|
amountDollars: String(schedule.amountDollars),
|
|
startsOn: schedule.startsOn,
|
|
necessity: schedule.necessity,
|
|
notes: schedule.notes || "",
|
|
tags: schedule.tags || [],
|
|
entryType: schedule.entryType,
|
|
frequency: schedule.frequency,
|
|
intervalCount: schedule.intervalCount,
|
|
endCondition: schedule.endCondition,
|
|
endCount: schedule.endCount == null ? "" : String(schedule.endCount),
|
|
endDate: schedule.endDate || "",
|
|
nextRunOn: schedule.nextRunOn,
|
|
isActive: schedule.isActive
|
|
};
|
|
setSelectedScheduleId(Number(id));
|
|
setScheduleDetailsForm(form);
|
|
setScheduleDetailsOriginal(form);
|
|
setScheduleRemovedTags([]);
|
|
setScheduleDetailsOpen(true);
|
|
}
|
|
|
|
async function submitEntryUpdate(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault();
|
|
if (!selectedEntryId || !hasEntryChanges()) return;
|
|
const amount = Number(entryDetailsForm.amountDollars || 0);
|
|
const tags = entryDetailsForm.tags.filter(tag => !entryRemovedTags.includes(tag));
|
|
if (!Number.isFinite(amount) || amount <= 0 || !tags.length) return;
|
|
const updated = await updateEntry({
|
|
id: selectedEntryId,
|
|
entryType: entryDetailsForm.entryType,
|
|
amountDollars: amount,
|
|
occurredAt: entryDetailsForm.occurredAt,
|
|
necessity: entryDetailsForm.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
|
purchaseType: tags.join(", ") || "General",
|
|
notes: entryDetailsForm.notes.trim() || undefined,
|
|
tags
|
|
});
|
|
if (!updated) return;
|
|
setEntryDetailsOpen(false);
|
|
notify({ title: "Entry updated", message: `${updated.tags.join(", ")} - $${updated.amountDollars.toFixed(2)}` });
|
|
notifyEntryMutation();
|
|
}
|
|
|
|
async function submitScheduleUpdate(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault();
|
|
if (!selectedScheduleId || !hasScheduleChanges()) return;
|
|
const amount = Number(scheduleDetailsForm.amountDollars || 0);
|
|
const tags = scheduleDetailsForm.tags.filter(tag => !scheduleRemovedTags.includes(tag));
|
|
if (!Number.isFinite(amount) || amount <= 0 || !tags.length) return;
|
|
const updated = await updateSchedule({
|
|
id: selectedScheduleId,
|
|
entryType: scheduleDetailsForm.entryType,
|
|
amountDollars: amount,
|
|
startsOn: scheduleDetailsForm.startsOn,
|
|
necessity: scheduleDetailsForm.necessity,
|
|
purchaseType: tags.join(", ") || "General",
|
|
notes: scheduleDetailsForm.notes.trim() || undefined,
|
|
tags,
|
|
frequency: scheduleDetailsForm.frequency,
|
|
intervalCount: scheduleDetailsForm.intervalCount,
|
|
endCondition: scheduleDetailsForm.endCondition,
|
|
endCount: scheduleDetailsForm.endCondition === "AFTER_COUNT" ? Number(scheduleDetailsForm.endCount || 0) || null : null,
|
|
endDate: scheduleDetailsForm.endCondition === "BY_DATE" ? scheduleDetailsForm.endDate || null : null,
|
|
nextRunOn: scheduleDetailsForm.nextRunOn,
|
|
isActive: scheduleDetailsForm.isActive
|
|
});
|
|
if (!updated) return;
|
|
setScheduleDetailsOpen(false);
|
|
notify({ title: "Schedule updated", message: `${updated.tags.join(", ")} - $${updated.amountDollars.toFixed(2)}` });
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (deleteTarget === "ENTRY" && selectedEntryId) {
|
|
const removed = await deleteEntry(selectedEntryId);
|
|
if (!removed) return;
|
|
setEntryDetailsOpen(false);
|
|
notify({ title: "Entry deleted", message: removed.tags.join(", ") || "Entry removed", tone: "danger" });
|
|
notifyEntryMutation();
|
|
}
|
|
if (deleteTarget === "SCHEDULE" && selectedScheduleId) {
|
|
const removed = await deleteSchedule(selectedScheduleId);
|
|
if (!removed) return;
|
|
setScheduleDetailsOpen(false);
|
|
notify({ title: "Schedule deleted", message: removed.tags.join(", ") || "Schedule removed", tone: "danger" });
|
|
}
|
|
}
|
|
|
|
function prevEntry() {
|
|
if (!filteredEntries.length) return;
|
|
const current = selectedEntryIndex ?? 0;
|
|
const index = current === 0 ? filteredEntries.length - 1 : current - 1;
|
|
openEntryDetails(filteredEntries[index].id);
|
|
}
|
|
|
|
function nextEntry() {
|
|
if (!filteredEntries.length) return;
|
|
const current = selectedEntryIndex ?? 0;
|
|
const index = current === filteredEntries.length - 1 ? 0 : current + 1;
|
|
openEntryDetails(filteredEntries[index].id);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-4">
|
|
<div className="panel panel-accent p-4">
|
|
<div className="card-header">
|
|
<ToggleButtonGroup
|
|
value={entryTab}
|
|
onChange={setEntryTab}
|
|
ariaLabel="Entries and schedules tab"
|
|
sizeClassName="px-3 py-2 text-xs font-semibold"
|
|
options={[
|
|
{ value: "ENTRIES", label: "Entries", className: "mr-[-10px] w-24" },
|
|
{ value: "SCHEDULES", label: "Schedules", className: "w-24" }
|
|
]}
|
|
/>
|
|
<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={() => entryTab === "ENTRIES" ? setNewEntryOpen(true) : setNewScheduleOpen(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={entryTab === "ENTRIES" ? "Add entry" : "Add schedule"}>
|
|
+
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{entryTab === "ENTRIES" ? (
|
|
<>
|
|
<EntriesList
|
|
activeGroupId={activeGroupId}
|
|
loading={entriesLoading}
|
|
entries={entries}
|
|
visibleEntries={visibleEntries}
|
|
activeFilterCount={activeFilterCount}
|
|
onOpenDetails={entry => openEntryDetails(entry.id)}
|
|
onClearFilters={clearFilters}
|
|
/>
|
|
<div ref={entriesLoadSentinelRef} className="h-1" aria-hidden="true" />
|
|
<ListProgressSignal
|
|
hasMore={hasMoreEntries}
|
|
shownCount={visibleEntries.length}
|
|
totalCount={filteredEntries.length}
|
|
noun="entries"
|
|
/>
|
|
</>
|
|
) : (
|
|
<>
|
|
<SchedulesList
|
|
activeGroupId={activeGroupId}
|
|
loading={schedulesLoading}
|
|
schedules={schedules}
|
|
visibleSchedules={visibleSchedules}
|
|
activeFilterCount={activeFilterCount}
|
|
onOpenDetails={schedule => openScheduleDetails(schedule.id)}
|
|
onClearFilters={clearFilters}
|
|
/>
|
|
<div ref={schedulesLoadSentinelRef} className="h-1" aria-hidden="true" />
|
|
<ListProgressSignal
|
|
hasMore={hasMoreSchedules}
|
|
shownCount={visibleSchedules.length}
|
|
totalCount={filteredSchedules.length}
|
|
noun="schedules"
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<NewEntryModal
|
|
isOpen={newEntryOpen && Boolean(activeGroupId)}
|
|
form={entryForm}
|
|
error={entriesError}
|
|
onClose={() => setNewEntryOpen(false)}
|
|
onSubmit={submitNewEntry}
|
|
onChange={next => setEntryForm(prev => ({ ...prev, ...next }))}
|
|
tagSuggestions={tagSuggestions}
|
|
emptyTagActionLabel={emptyTagActionLabel}
|
|
emptyTagActionDisabled={!canManageTags}
|
|
onEmptyTagAction={handleEmptyTagAction}
|
|
amountInputRef={amountInputRef}
|
|
tagsInputRef={tagsInputRef}
|
|
/>
|
|
<NewScheduleModal
|
|
isOpen={newScheduleOpen && Boolean(activeGroupId)}
|
|
form={scheduleForm}
|
|
error={schedulesError}
|
|
onClose={() => setNewScheduleOpen(false)}
|
|
onSubmit={submitNewSchedule}
|
|
onChange={next => setScheduleForm(prev => ({ ...prev, ...next }))}
|
|
tagSuggestions={tagSuggestions}
|
|
emptyTagActionLabel={emptyTagActionLabel}
|
|
emptyTagActionDisabled={!canManageTags}
|
|
onEmptyTagAction={handleEmptyTagAction}
|
|
/>
|
|
<EntryDetailsModal
|
|
isOpen={entryDetailsOpen}
|
|
form={entryDetailsForm}
|
|
originalForm={entryDetailsOriginal}
|
|
isDirty={hasEntryChanges()}
|
|
error={entriesError}
|
|
onClose={() => setEntryDetailsOpen(false)}
|
|
onSubmit={submitEntryUpdate}
|
|
onRequestDelete={() => {
|
|
setDeleteTarget("ENTRY");
|
|
setConfirmDeleteOpen(true);
|
|
}}
|
|
onRevert={() => {
|
|
if (!entryDetailsOriginal) return;
|
|
setEntryDetailsForm(entryDetailsOriginal);
|
|
setEntryRemovedTags([]);
|
|
}}
|
|
onChange={next => setEntryDetailsForm(prev => ({ ...prev, ...next }))}
|
|
onAddTag={tag => {
|
|
setEntryDetailsForm(prev => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags : [...prev.tags, tag] }));
|
|
setEntryRemovedTags(prev => prev.filter(item => item !== tag));
|
|
}}
|
|
onToggleTag={tag => setEntryRemovedTags(prev => prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag])}
|
|
removedTags={entryRemovedTags}
|
|
tagSuggestions={tagSuggestions}
|
|
emptyTagActionLabel={emptyTagActionLabel}
|
|
emptyTagActionDisabled={!canManageTags}
|
|
onEmptyTagAction={handleEmptyTagAction}
|
|
onPrev={prevEntry}
|
|
onNext={nextEntry}
|
|
loopHintPrev={selectedEntryIndex === 0 && filteredEntries.length > 1 ? "Loop" : ""}
|
|
loopHintNext={selectedEntryIndex === filteredEntries.length - 1 && filteredEntries.length > 1 ? "Loop" : ""}
|
|
canNavigate={filteredEntries.length > 1}
|
|
/>
|
|
<ScheduleDetailsModal
|
|
isOpen={scheduleDetailsOpen}
|
|
form={scheduleDetailsForm}
|
|
originalForm={scheduleDetailsOriginal}
|
|
isDirty={hasScheduleChanges()}
|
|
error={schedulesError}
|
|
onClose={() => setScheduleDetailsOpen(false)}
|
|
onSubmit={submitScheduleUpdate}
|
|
onRequestDelete={() => {
|
|
setDeleteTarget("SCHEDULE");
|
|
setConfirmDeleteOpen(true);
|
|
}}
|
|
onRevert={() => {
|
|
if (!scheduleDetailsOriginal) return;
|
|
setScheduleDetailsForm(scheduleDetailsOriginal);
|
|
setScheduleRemovedTags([]);
|
|
}}
|
|
onChange={next => setScheduleDetailsForm(prev => ({ ...prev, ...next }))}
|
|
onAddTag={tag => {
|
|
setScheduleDetailsForm(prev => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags : [...prev.tags, tag] }));
|
|
setScheduleRemovedTags(prev => prev.filter(item => item !== tag));
|
|
}}
|
|
onToggleTag={tag => setScheduleRemovedTags(prev => prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag])}
|
|
removedTags={scheduleRemovedTags}
|
|
tagSuggestions={tagSuggestions}
|
|
emptyTagActionLabel={emptyTagActionLabel}
|
|
emptyTagActionDisabled={!canManageTags}
|
|
onEmptyTagAction={handleEmptyTagAction}
|
|
/>
|
|
<EntriesFilterModal
|
|
isOpen={filterOpen}
|
|
filters={filters}
|
|
setFilters={setFilters}
|
|
activeFilterCount={activeFilterCount}
|
|
tagSuggestions={tagSuggestions}
|
|
canManageTags={canManageTags}
|
|
emptyTagActionLabel={emptyTagActionLabel}
|
|
onEmptyTagAction={handleEmptyTagAction}
|
|
onClearFilters={clearFilters}
|
|
onFilterAddTag={tag => setFilters(prev => (prev.tags.includes(tag) ? prev : { ...prev, tags: [...prev.tags, tag] }))}
|
|
onFilterToggleTag={tag => setFilters(prev => ({ ...prev, tags: prev.tags.filter(item => item !== tag) }))}
|
|
onClose={() => setFilterOpen(false)}
|
|
/>
|
|
<ConfirmSlideModal
|
|
isOpen={confirmDeleteOpen}
|
|
title={deleteTarget === "ENTRY" ? "Delete entry" : "Delete schedule"}
|
|
description={deleteTarget === "ENTRY" ? "This will permanently remove the entry and its tags." : "This will permanently remove the schedule and its tags."}
|
|
confirmLabel={deleteTarget === "ENTRY" ? "Delete entry" : "Delete schedule"}
|
|
onClose={() => setConfirmDeleteOpen(false)}
|
|
onConfirm={() => {
|
|
setConfirmDeleteOpen(false);
|
|
confirmDelete();
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|