fiddy/apps/web/components/entry-details-modal.tsx
2026-02-11 23:45:15 -08:00

388 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import type React from "react";
import { useEffect, useRef } from "react";
import TagInput from "@/components/tag-input";
export type EntryDetailsForm = {
amountDollars: string;
occurredAt: string;
necessity: string;
notes: string;
tags: string[];
entryType: "SPENDING" | "INCOME";
isRecurring: boolean;
frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY";
intervalCount: number;
endCondition: "NEVER" | "AFTER_COUNT" | "BY_DATE";
endCount: string;
endDate: string;
};
type EntryDetailsModalProps = {
isOpen: boolean;
form: EntryDetailsForm;
originalForm: EntryDetailsForm | null;
isDirty: boolean;
error: string;
onClose: () => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onRequestDelete: () => void;
onRevert: () => void;
onChange: (next: Partial<EntryDetailsForm>) => void;
onAddTag: (tag: string) => void;
onToggleTag: (tag: string) => void;
removedTags: string[];
tagSuggestions: string[];
emptyTagActionLabel?: string;
emptyTagActionDisabled?: boolean;
onEmptyTagAction?: () => void;
onPrev: () => void;
onNext: () => void;
loopHintPrev: string;
loopHintNext: string;
canNavigate: boolean;
};
export default function EntryDetailsModal({
isOpen,
form,
originalForm,
isDirty,
error,
onClose,
onSubmit,
onRequestDelete,
onRevert,
onChange,
onAddTag,
onToggleTag,
removedTags,
tagSuggestions,
emptyTagActionLabel,
emptyTagActionDisabled = false,
onEmptyTagAction,
onPrev,
onNext,
loopHintPrev,
loopHintNext,
canNavigate
}: EntryDetailsModalProps) {
const baseline = originalForm ?? form;
const removedSet = new Set(removedTags.map(tag => tag.toLowerCase()));
const currentTags = form.tags.filter(tag => !removedSet.has(tag.toLowerCase()));
const normalizeTags = (tags: string[]) => tags.map(tag => tag.toLowerCase()).sort().join("|");
const baselineTags = baseline.tags || [];
const tagsChanged = normalizeTags(currentTags) !== normalizeTags(baselineTags);
const addedTags = currentTags.filter(tag => !baselineTags.some(base => base.toLowerCase() === tag.toLowerCase()));
const amountChanged = form.amountDollars !== baseline.amountDollars;
const dateChanged = form.occurredAt !== baseline.occurredAt;
const necessityChanged = form.necessity !== baseline.necessity;
const notesChanged = form.notes !== baseline.notes;
const changedInputClass = "border-2 border-[color:var(--color-accent)]";
const formRef = useRef<HTMLFormElement | null>(null);
const touchStartX = useRef<number | null>(null);
const touchDeltaX = useRef(0);
function handleTouchStart(event: React.TouchEvent<HTMLDivElement>) {
touchStartX.current = event.touches[0]?.clientX ?? null;
touchDeltaX.current = 0;
}
function handleTouchMove(event: React.TouchEvent<HTMLDivElement>) {
if (touchStartX.current === null) return;
touchDeltaX.current = event.touches[0]?.clientX - touchStartX.current;
}
function handleTouchEnd() {
if (!canNavigate) return;
const delta = touchDeltaX.current;
touchStartX.current = null;
touchDeltaX.current = 0;
if (Math.abs(delta) < 60) return;
if (delta > 0) onPrev();
else onNext();
}
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0"
onClick={onClose}
>
<div
className="relative w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
onClick={event => event.stopPropagation()}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className="grid grid-cols-3 items-center gap-3">
<button
type="button"
onClick={onPrev}
className="flex w-24 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50"
disabled={!canNavigate}
aria-label="Previous entry"
>
<span aria-hidden>{loopHintPrev ? "↺" : "←"}</span>
<span className="text-xs text-soft">{loopHintPrev ? "To Last" : "Prev"}</span>
</button>
<h2 className="text-center text-lg font-semibold">Entry details</h2>
<button
type="button"
onClick={onNext}
className="ml-auto flex w-24 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50"
disabled={!canNavigate}
aria-label="Next entry"
>
<span className="text-xs text-soft">{loopHintNext ? "To First" : "Next"}</span>
<span aria-hidden>{loopHintNext ? "↻" : "→"}</span>
</button>
</div>
<form
ref={formRef}
onSubmit={onSubmit}
onKeyDown={event => {
if (event.key !== "Enter" || event.defaultPrevented || event.shiftKey) return;
const target = event.target as HTMLElement;
if (target?.tagName === "TEXTAREA") return;
event.preventDefault();
formRef.current?.requestSubmit();
}}
className="mt-3 grid gap-3 md:grid-cols-2"
>
<div className="md:col-span-2 flex flex-wrap items-center gap-2">
<button
type="button"
className={`flex h-9 w-9 items-center justify-center rounded-full border text-lg ${form.isRecurring ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
onClick={() => onChange({ isRecurring: !form.isRecurring })}
title="Toggle Recurring Entry"
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
>
<span aria-hidden></span>
</button>
<div className="flex items-center gap-2 rounded-full border border-accent-weak bg-panel">
<button
type="button"
className={`rounded-full px-4 py-2.5 text-xs font-semibold ${form.entryType === "SPENDING" ? "btn-accent" : "text-muted"}`}
onClick={() => onChange({ entryType: form.entryType === "SPENDING" ? "INCOME" : "SPENDING" })}
>
Spending
</button>
<button
type="button"
className={`rounded-full px-4 py-2.5 text-xs font-semibold ${form.entryType === "INCOME" ? "btn-accent" : "text-muted"}`}
onClick={() => onChange({ entryType: form.entryType === "INCOME" ? "SPENDING" : "INCOME" })}
>
Income
</button>
</div>
</div>
<label className="text-sm text-muted">
Amount ($)
<input
name="amountDollars"
type="number"
min={0}
step="0.01"
className={`mt-1 w-full input-base px-3 py-2 text-sm ${amountChanged ? changedInputClass : ""} ${form.amountDollars ? "" : "border-red-400/70"}`}
value={form.amountDollars}
onChange={e => onChange({ amountDollars: e.target.value })}
required
/>
</label>
<div className="text-sm text-muted">
<input
name="occurredAt"
type="date"
className={`no-date-icon mt-6 w-full input-base px-3 py-2 text-sm ${dateChanged ? changedInputClass : ""} ${form.occurredAt ? "" : "border-red-400/70"}`}
value={form.occurredAt}
onChange={e => onChange({ occurredAt: e.target.value })}
required
/>
</div>
<div className="text-sm text-muted">
<div className={`mt-6 flex items-center gap-2 rounded-full border border-accent-weak bg-panel ${necessityChanged ? changedInputClass : ""}`} role="group" aria-label="Necessity">
{([
{ value: "NECESSARY", label: "Necessary" },
{ value: "BOTH", label: "Both" },
{ value: "UNNECESSARY", label: "Unnecessary" }
] as const).map(option => (
<button
key={option.value}
type="button"
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
onClick={() => onChange({ necessity: option.value })}
>
{option.label}
</button>
))}
</div>
</div>
<TagInput
label="Tags"
tags={form.tags}
removedTags={removedTags}
highlightTags={addedTags}
suggestions={tagSuggestions}
allowCustom={false}
chipsBelow
onToggleTag={onToggleTag}
onAddTag={onAddTag}
emptySuggestionLabel={emptyTagActionLabel}
emptySuggestionDisabled={emptyTagActionDisabled}
onEmptySuggestionClick={onEmptyTagAction}
invalid={!currentTags.length}
/>
{form.isRecurring ? (
<div className="md:col-span-2">
<div className="text-sm text-muted">Frequency Conditions</div>
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
<input
type="number"
min={1}
className="w-20 input-base px-3 py-2 text-center text-sm"
value={form.intervalCount}
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
/>
<select
className="min-w-[120px] input-base px-3 py-2 text-center text-sm"
value={form.frequency}
onChange={e => onChange({ frequency: e.target.value as EntryDetailsForm["frequency"] })}
>
<option value="DAILY">day(s)</option>
<option value="WEEKLY">week(s)</option>
<option value="BIWEEKLY">biweekly</option>
<option value="MONTHLY">month(s)</option>
<option value="QUARTERLY">quarter(s)</option>
<option value="YEARLY">year(s)</option>
</select>
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel" role="group" aria-label="End condition">
{([
{ value: "NEVER", label: "Forever" },
{ value: "BY_DATE", label: "Until" },
{ value: "AFTER_COUNT", label: "After" }
] as const).map(option => (
<button
key={option.value}
type="button"
className={`rounded-full px-3 py-2 text-xs font-semibold ${form.endCondition === option.value ? "btn-accent" : "text-muted"}`}
onClick={() => onChange({ endCondition: option.value })}
>
{option.label}
</button>
))}
</div>
{form.endCondition === "AFTER_COUNT" ? (
<input
type="number"
min={1}
className="w-24 input-base px-3 py-2 text-center text-sm"
value={form.endCount}
placeholder="Count"
onChange={e => onChange({ endCount: e.target.value })}
/>
) : null}
{form.endCondition === "BY_DATE" ? (
<div className={`inline-flex items-center overflow-hidden rounded-full border ${form.endDate ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
if (Number.isNaN(base.getTime())) return;
base.setDate(base.getDate() - 1);
onChange({ endDate: base.toISOString().slice(0, 10) });
}}></button>
<input
type="date"
className="no-date-icon h-11 w-40 bg-transparent px-3 text-center text-sm text-[color:var(--color-text)] outline-none"
value={form.endDate}
onChange={e => onChange({ endDate: e.target.value })}
/>
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
if (Number.isNaN(base.getTime())) return;
base.setDate(base.getDate() + 1);
onChange({ endDate: base.toISOString().slice(0, 10) });
}}></button>
</div>
) : null}
</div>
</div>
) : null}
<label className="text-sm text-muted md:col-span-2">
Notes
<textarea
name="notes"
className={`mt-1 w-full input-base px-3 py-2 text-sm ${notesChanged ? changedInputClass : ""}`}
rows={3}
value={form.notes}
onChange={e => onChange({ notes: e.target.value })}
placeholder="Optional"
/>
</label>
<div className="md:col-span-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<button
type="button"
onClick={onRevert}
disabled={!isDirty}
aria-label="Revert changes"
className="flex h-9 w-9 items-center justify-center rounded-lg border border-accent-weak bg-panel text-sm hover:border-accent disabled:opacity-40 disabled:cursor-not-allowed"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 12a9 9 0 1 0 3-6.7" />
<path d="M3 4v4h4" />
</svg>
</button>
<button
type="submit"
className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold disabled:opacity-35 disabled:cursor-not-allowed disabled:grayscale disabled:shadow-none"
disabled={!isDirty}
>
Save changes
</button>
<button
type="button"
className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold"
onClick={onRequestDelete}
>
Delete
</button>
<button
type="button"
onClick={onClose}
className="right rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold"
aria-label="Close"
>
Close
</button>
</div>
{error ? <div className="text-sm text-red-400">{error}</div> : null}
</div>
</form>
</div>
</div>
);
}