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

298 lines
16 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";
type NewEntryForm = {
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 NewEntryModalProps = {
isOpen: boolean;
form: NewEntryForm;
error: string;
onClose: () => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onChange: (next: Partial<NewEntryForm>) => void;
tagSuggestions: string[];
emptyTagActionLabel?: string;
emptyTagActionDisabled?: boolean;
onEmptyTagAction?: () => void;
amountInputRef?: React.Ref<HTMLInputElement>;
tagsInputRef?: React.Ref<HTMLInputElement>;
};
export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit, onChange, tagSuggestions, emptyTagActionLabel, emptyTagActionDisabled = false, onEmptyTagAction, amountInputRef, tagsInputRef }: NewEntryModalProps) {
const recurrenceLabel = form.isRecurring ? "Recurring" : "One-Time";
const typeLabel = form.entryType === "INCOME" ? "Income" : "Expense";
const formRef = useRef<HTMLFormElement | null>(null);
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
if (!form.occurredAt) {
const today = new Date().toISOString().slice(0, 10);
onChange({ occurredAt: today, endDate: form.endDate || today });
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [form.endDate, form.occurredAt, isOpen, onChange, onClose]);
function shiftDate(days: number) {
const base = form.occurredAt ? new Date(form.occurredAt) : new Date();
if (Number.isNaN(base.getTime())) return;
base.setDate(base.getDate() + days);
onChange({ occurredAt: base.toISOString().slice(0, 10) });
}
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="w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
onClick={event => event.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">New {recurrenceLabel} {typeLabel} Entry</h2>
<button
type="button"
onClick={onClose}
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
aria-label="Close"
>
</button>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<div
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel">
<button
type="button"
className={`rounded-full mr-[-10px] 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>
<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>
<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"
>
<label className="text-sm text-muted">
{/* Amount ($) */}
<div className="relative mt-1">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-soft">
{form.entryType === "INCOME" ? "🤑 $" : "😭 $"}
</span>
<input
ref={amountInputRef}
name="amountDollars"
type="number"
min={0}
step="0.01"
className={`w-full input-base px-12 py-2 text-sm ${form.amountDollars ? "" : "border-red-400/70"}`}
value={form.amountDollars}
onChange={e => onChange({ amountDollars: e.target.value })}
required
/>
</div>
</label>
<div className="text-sm text-muted">
<div className={`mt-1 inline-flex w-full items-center overflow-hidden rounded-full border ${form.occurredAt ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(-7)}>«</button>
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(-1)}></button>
<input
name="occurredAt"
type="date"
className="no-date-icon h-11 w-40 bg-transparent px-3 text-sm text-[color:var(--color-text)] outline-none"
value={form.occurredAt}
onChange={e => onChange({ occurredAt: e.target.value })}
required
/>
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(1)}></button>
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(7)}>»</button>
</div>
</div>
<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: "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>
{/* TAGS */}
<TagInput
label="Tags"
tags={form.tags}
suggestions={tagSuggestions}
allowCustom={false}
onToggleTag={tag => onChange({ tags: form.tags.filter(item => item !== tag) })}
onAddTag={tag => onChange({ tags: [...form.tags, tag] })}
emptySuggestionLabel={emptyTagActionLabel}
emptySuggestionDisabled={emptyTagActionDisabled}
onEmptySuggestionClick={onEmptyTagAction}
invalid={!form.tags.length}
inputRef={tagsInputRef}
/>
{/* RECURRING OPTIONS */}
{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-14 input-base px-3 py-2 text-center text-sm"
value={form.intervalCount}
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
/>
<select
className="w-20 min-w-[110px] input-base px-3 py-2 text-center text-sm"
value={form.frequency}
onChange={e => onChange({ frequency: e.target.value as NewEntryForm["frequency"] })}
>
<option value="DAILY">day(s)</option>
<option value="WEEKLY">week(s)</option>
<option value="MONTHLY">month(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-3 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"
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">
<button
type="submit"
className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold"
>
Add entry
</button>
{error ? <div className="text-sm text-red-400">{error}</div> : null}
</div>
</form>
</div>
</div >
);
}