388 lines
20 KiB
TypeScript
388 lines
20 KiB
TypeScript
"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>
|
||
);
|
||
}
|