fiddy/apps/web/components/new-entry-modal.tsx

256 lines
12 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";
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]);
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">
<ToggleButtonGroup
value={form.entryType}
onChange={entryType => onChange({ entryType })}
ariaLabel="Entry type"
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel"
sizeClassName="px-4 py-2.5 text-xs font-semibold"
options={[
{ value: "SPENDING", label: "Spending", className: "mr-[-10px]" },
{ value: "INCOME", label: "Income" }
]}
/>
<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 className="font-bold text-[25px]"
style={{ transform: `translateY(-2px)` }}
></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">
<DatePicker
name="occurredAt"
value={form.occurredAt}
onChange={occurredAt => onChange({ occurredAt })}
required
className="mt-1"
/>
</div>
<div className="text-sm text-muted">
<ToggleButtonGroup
value={form.necessity}
onChange={necessity => onChange({ necessity })}
ariaLabel="Necessity"
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
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>
{/* 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="mt-2 flex flex-wrap items-center justify-center gap-y-2">
<div className="text-sm text-muted mr-2">Every</div>
<input
type="number"
min={1}
className="mr-1 w-12 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-[100px] 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>
<ToggleButtonGroup
value={form.endCondition}
onChange={endCondition => onChange({ endCondition })}
ariaLabel="End condition"
className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel"
sizeClassName="px-3 py-3 text-xs font-semibold"
options={[
{ value: "NEVER", label: "Forever" },
{ value: "BY_DATE", label: "Until" },
{ value: "AFTER_COUNT", label: "After" }
]}
/>
{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" ? (
<DatePicker
value={form.endDate}
onChange={endDate => onChange({ endDate })}
showWeekButtons={false}
centerInput
/>
) : 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"
>
{form.isRecurring ? "Set schedule and add entry" : "Add entry"}
</button>
{error ? <div className="text-sm text-red-400">{error}</div> : null}
</div>
</form>
</div>
</div >
);
}