786 lines
39 KiB
TypeScript
786 lines
39 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import useEntries from "@/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 "@/hooks/use-tags";
|
|
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
|
import useGroupSettings from "@/hooks/use-group-settings";
|
|
import TagInput from "@/components/tag-input";
|
|
import { emitEntryMutated } from "@/lib/client/entry-mutation-events";
|
|
|
|
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 { 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 = {
|
|
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"
|
|
});
|
|
emitEntryMutated({ before: null, after: createdEntry });
|
|
}
|
|
}
|
|
|
|
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)}`
|
|
});
|
|
emitEntryMutated({ before: beforeEntry, after: updatedEntry });
|
|
}
|
|
}
|
|
|
|
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"
|
|
});
|
|
emitEntryMutated({ before: deletedEntry || beforeEntry, after: null });
|
|
}
|
|
}
|
|
|
|
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>
|
|
<div className="flex items-center gap-0 rounded-full border border-accent-weak bg-panel">
|
|
<button
|
|
type="button"
|
|
className={`mr-[-10px] w-20 rounded-full px-3 py-2 text-xs font-semibold ${entryTab === "SINGLE" ? "btn-accent" : "text-muted"}`}
|
|
onClick={() => setEntryTab(prev => prev === "SINGLE" ? "RECURRING" : "SINGLE")}
|
|
>
|
|
Existing
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`rounded-full w-20 px-3 py-2 text-xs font-semibold ${entryTab === "RECURRING" ? "btn-accent" : "text-muted"}`}
|
|
onClick={() => setEntryTab(prev => prev === "RECURRING" ? "SINGLE" : "RECURRING")}
|
|
>
|
|
Scheduled
|
|
</button>
|
|
</div>
|
|
</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>
|
|
<div className="mt-3 space-y-2">
|
|
{!activeGroupId ? (
|
|
<div className="text-sm text-muted">Select a group to view entries.</div>
|
|
) : loading ? (
|
|
<div className="space-y-2">
|
|
{[0, 1, 2].map(row => (
|
|
<div key={row} className="rounded-lg border border-accent-weak bg-panel px-3 py-3">
|
|
<div className="animate-pulse space-y-2">
|
|
<div className="h-4 w-28 rounded bg-surface" />
|
|
<div className="h-3 w-40 rounded bg-surface" />
|
|
<div className="flex flex-wrap gap-2">
|
|
<div className="h-5 w-14 rounded-full bg-surface" />
|
|
<div className="h-5 w-12 rounded-full bg-surface" />
|
|
<div className="h-5 w-16 rounded-full bg-surface" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : entries.length ? (
|
|
visibleEntries.length ? (
|
|
visibleEntries.map((entry, index) => {
|
|
const tags = entry.tags ?? [];
|
|
const mobileTagLimit = 2;
|
|
const mobileTags = tags.slice(0, mobileTagLimit);
|
|
const extraTagCount = Math.max(tags.length - mobileTagLimit, 0);
|
|
|
|
return (
|
|
<div
|
|
key={entry.id}
|
|
className="flex items-center justify-between rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm transition hover:border-accent hover:bg-accent-soft"
|
|
onClick={() => handleOpenDetails(entry, index)}
|
|
>
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-base font-semibold">${entry.amountDollars.toFixed(2)}</div>
|
|
<div className="text-xs text-muted">
|
|
{new Date(entry.occurredAt).toISOString().slice(0, 10)} · {entry.necessity}
|
|
</div>
|
|
</div>
|
|
{tags.length ? (
|
|
<>
|
|
<div className="flex flex-wrap justify-end gap-2 md:hidden">
|
|
{mobileTags.map(tag => (
|
|
<span key={tag} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs">
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
{extraTagCount ? (
|
|
<span className="rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-xs text-soft">
|
|
{extraTagCount} more...
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="hidden flex-wrap justify-end gap-2 md:flex">
|
|
{tags.map(tag => (
|
|
<span key={tag} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs">
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<span className="rounded-full border border-accent-weak px-2 py-0.5 text-xs text-soft">No tags</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
<div className="space-y-2 text-sm text-muted">
|
|
<div>No matching entries.</div>
|
|
{activeFilterCount ? (
|
|
<button type="button" className="rounded-lg btn-outline-accent px-3 py-1 text-xs" onClick={handleClearFilters}>
|
|
Clear filters
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className="text-sm text-muted">No entries yet.</div>
|
|
)}
|
|
</div>
|
|
</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}
|
|
/>
|
|
{filterOpen ? (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={() => setFilterOpen(false)}>
|
|
<div
|
|
className="w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
|
|
onClick={event => event.stopPropagation()}
|
|
onKeyDown={event => {
|
|
if (event.key === "Escape") setFilterOpen(false);
|
|
}}
|
|
role="dialog"
|
|
tabIndex={-1}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold">Filter Entries</h2>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilterOpen(false)}
|
|
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
|
aria-label="Close"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
|
<label className="text-sm text-muted md:col-span-2">
|
|
Amount Range
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
step="0.01"
|
|
className="w-full input-base px-3 py-2 text-sm"
|
|
value={filters.amountMin}
|
|
placeholder="none"
|
|
onChange={e => setFilters(prev => ({ ...prev, amountMin: e.target.value }))}
|
|
/>
|
|
<span className="text-xs text-soft">-</span>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
step="0.01"
|
|
className="w-full input-base px-3 py-2 text-sm"
|
|
value={filters.amountMax}
|
|
placeholder="none"
|
|
onChange={e => setFilters(prev => ({ ...prev, amountMax: e.target.value }))}
|
|
/>
|
|
</div>
|
|
</label>
|
|
<label className="text-sm text-muted md:col-span-2">
|
|
Date Range
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<input
|
|
type="date"
|
|
className="no-date-icon w-full input-base px-3 py-2 text-sm"
|
|
value={filters.dateFrom}
|
|
placeholder="none"
|
|
onChange={e => setFilters(prev => ({ ...prev, dateFrom: e.target.value }))}
|
|
/>
|
|
<span className="text-xs text-soft">-</span>
|
|
<input
|
|
type="date"
|
|
className="no-date-icon w-full input-base px-3 py-2 text-sm"
|
|
value={filters.dateTo}
|
|
placeholder="none"
|
|
onChange={e => setFilters(prev => ({ ...prev, dateTo: e.target.value }))}
|
|
/>
|
|
</div>
|
|
</label>
|
|
<div className="text-sm text-muted">
|
|
<div className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel" role="group" aria-label="Necessity">
|
|
{([
|
|
{ value: "ANY", label: "Any" },
|
|
{ value: "NECESSARY", label: "Necessary" },
|
|
{ value: "BOTH", label: "Both" },
|
|
{ value: "UNNECESSARY", label: "Unnecessary" }
|
|
] as const).map(option => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
className={`rounded-full px-3 py-2 text-xs font-semibold ${filters.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
|
onClick={() => setFilters(prev => ({ ...prev, necessity: prev.necessity === option.value ? "ANY" : option.value }))}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<label className="text-sm text-muted">
|
|
Notes contains
|
|
<input
|
|
type="text"
|
|
className="mt-1 w-full input-base px-3 py-2 text-sm"
|
|
value={filters.notesQuery}
|
|
onChange={e => setFilters(prev => ({ ...prev, notesQuery: e.target.value }))}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="mt-3 space-y-3">
|
|
<TagInput
|
|
label="Tags"
|
|
labelAction={
|
|
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel">
|
|
<button
|
|
type="button"
|
|
className={`rounded-full px-3 py-2 text-xs font-semibold ${filters.tagsMode === "ANY" ? "btn-accent" : "text-muted"}`}
|
|
onClick={() => setFilters(prev => ({ ...prev, tagsMode: prev.tagsMode === "ANY" ? "ALL" : "ANY" }))}
|
|
>
|
|
Any
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`rounded-full px-3 py-2 text-xs font-semibold ${filters.tagsMode === "ALL" ? "btn-accent" : "text-muted"}`}
|
|
onClick={() => setFilters(prev => ({ ...prev, tagsMode: prev.tagsMode === "ALL" ? "ANY" : "ALL" }))}
|
|
>
|
|
All
|
|
</button>
|
|
</div>
|
|
}
|
|
tags={filters.tags}
|
|
suggestions={tagSuggestions}
|
|
allowCustom={false}
|
|
onToggleTag={handleFilterToggleTag}
|
|
onAddTag={handleFilterAddTag}
|
|
emptySuggestionLabel={emptyTagActionLabel}
|
|
emptySuggestionDisabled={!canManageTags}
|
|
onEmptySuggestionClick={handleEmptyTagAction}
|
|
/>
|
|
</div>
|
|
<div className="mt-4 flex items-center justify-between">
|
|
<div className="text-xs text-soft">
|
|
{activeFilterCount ? `${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"} applied` : "No filters applied"}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button type="button" className="rounded-lg btn-outline-accent px-3 py-2 text-xs" onClick={handleClearFilters}>
|
|
Clear Filters
|
|
</button>
|
|
<button type="button" className="rounded-lg btn-accent px-3 py-2 text-xs" onClick={() => setFilterOpen(false)}>
|
|
Apply
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{discardOpen ? (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
|
<div
|
|
className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5 shadow-xl"
|
|
onKeyDown={event => {
|
|
if (event.key === "Escape") handleCancelDiscard();
|
|
}}
|
|
role="dialog"
|
|
tabIndex={-1}
|
|
>
|
|
<div className="text-lg font-semibold">Discard changes?</div>
|
|
<p className="mt-2 text-sm text-muted">You have unsaved changes. Do you want to discard them?</p>
|
|
<div className="mt-4 flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
|
|
onClick={handleCancelDiscard}
|
|
>
|
|
Keep editing
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="flex-1 rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-sm font-semibold text-red-200"
|
|
onClick={handleConfirmDiscard}
|
|
>
|
|
Discard
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<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();
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|