fiddy/apps/web/features/entries/components/entries-panel.tsx
Nico f8e426542d
Some checks failed
Build & Deploy Fiddy (Dokploy) / build (push) Has been cancelled
Build & Deploy Fiddy (Dokploy) / deploy (push) Has been cancelled
feat: implement schedules pivot, scheduler service, and dokploy deploy flow
2026-02-15 17:10:58 -08:00

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();
}}
/>
</>
);
}