227 lines
11 KiB
TypeScript
227 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import type React from "react";
|
|
import { useEffect, useRef } from "react";
|
|
import DatePicker from "@/shared/components/forms/date-picker";
|
|
import TagInput from "@/shared/components/forms/tag-input";
|
|
import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group";
|
|
import type { ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
|
|
|
|
export type NewScheduleForm = {
|
|
amountDollars: string;
|
|
startsOn: string;
|
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
|
notes: string;
|
|
tags: string[];
|
|
entryType: "SPENDING" | "INCOME";
|
|
frequency: ScheduleFrequency;
|
|
intervalCount: number;
|
|
endCondition: ScheduleEndCondition;
|
|
endCount: string;
|
|
endDate: string;
|
|
createEntryNow: boolean;
|
|
};
|
|
|
|
type NewScheduleModalProps = {
|
|
isOpen: boolean;
|
|
form: NewScheduleForm;
|
|
error: string;
|
|
onClose: () => void;
|
|
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
|
onChange: (next: Partial<NewScheduleForm>) => void;
|
|
tagSuggestions: string[];
|
|
emptyTagActionLabel?: string;
|
|
emptyTagActionDisabled?: boolean;
|
|
onEmptyTagAction?: () => void;
|
|
};
|
|
|
|
export default function NewScheduleModal({
|
|
isOpen,
|
|
form,
|
|
error,
|
|
onClose,
|
|
onSubmit,
|
|
onChange,
|
|
tagSuggestions,
|
|
emptyTagActionLabel,
|
|
emptyTagActionDisabled = false,
|
|
onEmptyTagAction
|
|
}: NewScheduleModalProps) {
|
|
const formRef = useRef<HTMLFormElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
if (event.key === "Escape") onClose();
|
|
}
|
|
if (!form.startsOn) {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
onChange({ startsOn: today });
|
|
}
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [form.startsOn, 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 Schedule</h2>
|
|
<button type="button" onClick={onClose} className="rounded-lg btn-outline-accent px-2 py-1 text-sm" aria-label="Close">
|
|
x
|
|
</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" }
|
|
]}
|
|
/>
|
|
<ToggleButtonGroup
|
|
value={form.createEntryNow ? "NOW" : "NEXT"}
|
|
onChange={value => onChange({ createEntryNow: value === "NOW" })}
|
|
ariaLabel="Create behavior"
|
|
sizeClassName="px-3 py-2 text-xs font-semibold"
|
|
options={[
|
|
{ value: "NOW", label: "Create Entry Now" },
|
|
{ value: "NEXT", label: "Start Next Schedule" }
|
|
]}
|
|
/>
|
|
</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 ($)
|
|
<input
|
|
name="amountDollars"
|
|
type="number"
|
|
min={0}
|
|
step="0.01"
|
|
className={`mt-1 w-full input-base px-3 py-2 text-sm ${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="startsOn" value={form.startsOn} onChange={startsOn => onChange({ startsOn })} 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>
|
|
<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}
|
|
/>
|
|
<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 ScheduleFrequency })}
|
|
>
|
|
<option value="DAILY">daily</option>
|
|
<option value="WEEKLY">weakly</option>
|
|
<option value="MONTHLY">monthly</option>
|
|
<option value="YEARLY">yearly</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>
|
|
<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">
|
|
Save schedule
|
|
</button>
|
|
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|