fiddy/apps/web/components/entry-details-modal.tsx
Nico f8e426542d
Some checks failed
Build & Deploy Fiddy (Dokploy) / build (push) Has been cancelled
Build & Deploy Fiddy (Dokploy) / deploy (push) Has been cancelled
feat: implement schedules pivot, scheduler service, and dokploy deploy flow
2026-02-15 17:10:58 -08:00

249 lines
11 KiB
TypeScript

"use client";
import type React from "react";
import { useEffect, useRef } from "react";
import TagInput from "@/components/tag-input";
import ToggleButtonGroup from "@/components/toggle-button-group";
import DatePicker from "@/components/date-picker";
export type EntryDetailsForm = {
amountDollars: string;
occurredAt: string;
necessity: string;
notes: string;
tags: string[];
entryType: "SPENDING" | "INCOME";
};
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-20 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 ? "o" : "<"}</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-20 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 ? "o" : ">"}</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">
<ToggleButtonGroup
value={form.entryType}
onChange={entryType => onChange({ entryType })}
ariaLabel="Entry type"
sizeClassName="px-4 py-2.5 text-xs font-semibold"
options={[
{ value: "SPENDING", label: "Spending" },
{ value: "INCOME", label: "Income" }
]}
/>
</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">
<DatePicker
name="occurredAt"
value={form.occurredAt}
onChange={occurredAt => onChange({ occurredAt })}
required
className={`mt-1 ${dateChanged ? changedInputClass : ""}`}
/>
</div>
<div className="text-sm text-muted">
<ToggleButtonGroup
value={form.necessity}
onChange={necessity => onChange({ necessity })}
ariaLabel="Necessity"
className={`flex items-center gap-2 rounded-full border border-accent-weak bg-panel ${necessityChanged ? changedInputClass : ""}`}
sizeClassName="px-3 py-2.5 text-xs font-semibold"
options={[
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
{ value: "BOTH", label: "Both", className: "flex-1" },
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
]}
/>
</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}
/>
<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 w-full">
<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"
>
R
</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={!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>
<div className="flex-1 w-full" />
<button type="button" onClick={onClose} className="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>
);
}