fiddy/apps/web/features/entries/components/use-entries-panel-crud.ts

408 lines
16 KiB
TypeScript

"use client";
import { useRef, useState } from "react";
import type { FormEvent } from "react";
import { useRouter } from "next/navigation";
import type { Entry, Schedule } from "@/lib/shared/types";
import type {
DeleteTarget,
EntryDetailsFormState,
EntryFormState,
ScheduleDetailsFormState,
ScheduleFormState
} from "@/features/entries/components/entries-panel.types";
import { getTodayIsoDate, normalizeTagList } from "@/features/entries/components/entries-panel.utils";
type CreateEntryInput = {
entryType: "SPENDING" | "INCOME";
amountDollars: number;
occurredAt: string;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchaseType: string;
notes?: string;
tags?: string[];
bucketId?: number | null;
};
type UpdateEntryInput = CreateEntryInput & { id: number };
type ScheduleInput = {
entryType: "SPENDING" | "INCOME";
amountDollars: number;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchaseType: string;
notes?: string;
tags?: string[];
startsOn: string;
frequency: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY";
intervalCount?: number;
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE";
endCount?: number | null;
endDate?: string | null;
};
type NotifyInput = {
title: string;
message?: string;
tone?: "info" | "success" | "danger";
durationMs?: number;
};
type UseEntriesPanelCrudParams = {
filteredEntries: Entry[];
schedules: Schedule[];
createEntry: (input: CreateEntryInput) => Promise<Entry | null>;
updateEntry: (input: UpdateEntryInput) => Promise<Entry | null>;
deleteEntry: (id: number | string) => Promise<Entry | null>;
createSchedule: (input: ScheduleInput & { createEntryNow?: boolean }) => Promise<Schedule | null>;
updateSchedule: (input: ScheduleInput & { id: number; nextRunOn?: string; isActive?: boolean }) => Promise<Schedule | null>;
deleteSchedule: (id: number | string) => Promise<Schedule | null>;
notify: (input: NotifyInput) => void;
notifyEntryMutation: () => void;
canManageTags: boolean;
};
function createInitialEntryForm(today: string): EntryFormState {
return {
amountDollars: "",
occurredAt: today,
necessity: "NECESSARY",
notes: "",
tags: [],
entryType: "SPENDING"
};
}
function createInitialScheduleForm(today: string): ScheduleFormState {
return {
amountDollars: "",
startsOn: today,
necessity: "NECESSARY",
notes: "",
tags: [],
entryType: "SPENDING",
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "NEVER",
endCount: "",
endDate: "",
createEntryNow: false
};
}
function createInitialScheduleDetailsForm(today: string): ScheduleDetailsFormState {
return {
amountDollars: "",
startsOn: today,
necessity: "NECESSARY",
notes: "",
tags: [],
entryType: "SPENDING",
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "NEVER",
endCount: "",
endDate: "",
nextRunOn: today,
isActive: true
};
}
export default function useEntriesPanelCrud({
filteredEntries,
schedules,
createEntry,
updateEntry,
deleteEntry,
createSchedule,
updateSchedule,
deleteSchedule,
notify,
notifyEntryMutation,
canManageTags
}: UseEntriesPanelCrudParams) {
const router = useRouter();
const today = getTodayIsoDate();
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<DeleteTarget>("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<EntryFormState>(() => createInitialEntryForm(today));
const [entryDetailsForm, setEntryDetailsForm] = useState<EntryDetailsFormState>(() => createInitialEntryForm(today));
const [entryDetailsOriginal, setEntryDetailsOriginal] = useState<EntryDetailsFormState | null>(null);
const [scheduleForm, setScheduleForm] = useState<ScheduleFormState>(() => createInitialScheduleForm(today));
const [scheduleDetailsForm, setScheduleDetailsForm] = useState<ScheduleDetailsFormState>(() => createInitialScheduleDetailsForm(today));
const [scheduleDetailsOriginal, setScheduleDetailsOriginal] = useState<ScheduleDetailsFormState | null>(null);
const amountInputRef = useRef<HTMLInputElement>(null);
const tagsInputRef = useRef<HTMLInputElement>(null);
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(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!event.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,
purchaseType: entryForm.tags.join(", ") || "General",
notes: entryForm.notes.trim() || undefined,
tags: entryForm.tags
});
if (!created) return;
setNewEntryOpen(false);
setEntryForm(createInitialEntryForm(today));
notify({ title: "Entry added", message: `${created.tags.join(", ")} - $${created.amountDollars.toFixed(2)}`, tone: "success" });
notifyEntryMutation();
}
async function submitNewSchedule(event: FormEvent<HTMLFormElement>) {
event.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(createInitialScheduleForm(today));
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: EntryDetailsFormState = {
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: ScheduleDetailsFormState = {
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(event: FormEvent<HTMLFormElement>) {
event.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,
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(event: FormEvent<HTMLFormElement>) {
event.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 {
newEntryOpen,
setNewEntryOpen,
newScheduleOpen,
setNewScheduleOpen,
entryDetailsOpen,
setEntryDetailsOpen,
scheduleDetailsOpen,
setScheduleDetailsOpen,
confirmDeleteOpen,
setConfirmDeleteOpen,
deleteTarget,
setDeleteTarget,
selectedEntryIndex,
selectedScheduleId,
entryRemovedTags,
setEntryRemovedTags,
scheduleRemovedTags,
setScheduleRemovedTags,
entryForm,
setEntryForm,
entryDetailsForm,
setEntryDetailsForm,
entryDetailsOriginal,
scheduleForm,
setScheduleForm,
scheduleDetailsForm,
setScheduleDetailsForm,
scheduleDetailsOriginal,
amountInputRef,
tagsInputRef,
handleEmptyTagAction,
hasEntryChanges,
hasScheduleChanges,
submitNewEntry,
submitNewSchedule,
openEntryDetails,
openScheduleDetails,
submitEntryUpdate,
submitScheduleUpdate,
confirmDelete,
prevEntry,
nextEntry
};
}
export type EntriesPanelCrudState = ReturnType<typeof useEntriesPanelCrud>;