408 lines
16 KiB
TypeScript
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>;
|