Organize/components/scheduled/scheduled-todo-dialog.tsx

429 lines
16 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
import {
createScheduledTodo,
deleteScheduledTodo,
getScheduledTodoForEdit,
updateScheduledTodo,
} from "@/lib/actions/scheduled-todos";
import type { RecurrenceInput } from "@/lib/validation/scheduled-todo";
const TITLE_MAX = 100;
const WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const MONTH_LABELS = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const FREQUENCY_UNIT: Record<RecurrenceInput["frequency"], string> = {
DAILY: "day(s)",
WEEKLY: "week(s)",
MONTHLY: "month(s)",
YEARLY: "year(s)",
};
/** "YYYY-MM-DD" for today in the browser's own local calendar -- just a
* sensible default for the date input, which the user can change anyway. */
function todayDateKey(): string {
const d = new Date();
const localMidnight = new Date(d.getTime() - d.getTimezoneOffset() * 60_000);
return localMidnight.toISOString().slice(0, 10);
}
export function ScheduledTodoDialog({
open,
onOpenChange,
projectId,
scheduledTodoId,
onSaved,
onDeleted,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: string | null;
// null = creating a new one; set = editing an existing one.
scheduledTodoId: string | null;
onSaved: () => void;
onDeleted: () => void;
}) {
const [title, setTitle] = useState("");
const [details, setDetails] = useState("");
const [startDate, setStartDate] = useState(todayDateKey());
const [isRecurring, setIsRecurring] = useState(false);
const [frequency, setFrequency] = useState<RecurrenceInput["frequency"]>("WEEKLY");
const [interval, setInterval] = useState(1);
const [daysOfWeek, setDaysOfWeek] = useState<number[]>([]);
const [dayOfMonth, setDayOfMonth] = useState(1);
const [month, setMonth] = useState(1);
const [endType, setEndType] = useState<"never" | "until" | "count">("never");
const [endDate, setEndDate] = useState("");
const [endCount, setEndCount] = useState(10);
const [loading, setLoading] = useState(false);
const [pending, setPending] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
useEffect(() => {
if (!open) return;
if (!scheduledTodoId) {
setTitle("");
setDetails("");
setStartDate(todayDateKey());
setIsRecurring(false);
setFrequency("WEEKLY");
setInterval(1);
setDaysOfWeek([]);
setDayOfMonth(1);
setMonth(1);
setEndType("never");
setEndDate("");
setEndCount(10);
return;
}
setLoading(true);
getScheduledTodoForEdit(scheduledTodoId)
.then((todo) => {
setTitle(todo.title);
setDetails(todo.details ?? "");
setStartDate(todo.startDate);
const recurrence = todo.recurrence;
setIsRecurring(!!recurrence);
setFrequency(recurrence?.frequency ?? "WEEKLY");
setInterval(recurrence?.interval ?? 1);
setDaysOfWeek(recurrence?.daysOfWeek ?? []);
setDayOfMonth(recurrence?.dayOfMonth ?? 1);
setMonth(recurrence?.month ?? 1);
setEndType(recurrence?.end.type ?? "never");
setEndDate(recurrence?.end.type === "until" ? recurrence.end.date : "");
setEndCount(recurrence?.end.type === "count" ? recurrence.end.count : 10);
})
.catch(() => toast.error("Couldn't load scheduled to-do."))
.finally(() => setLoading(false));
}, [open, scheduledTodoId]);
function toggleDayOfWeek(day: number) {
setDaysOfWeek((prev) => (prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day].sort()));
}
function buildRecurrence(): RecurrenceInput | undefined {
if (!isRecurring) return undefined;
return {
frequency,
interval,
daysOfWeek: frequency === "WEEKLY" ? daysOfWeek : undefined,
dayOfMonth: frequency === "MONTHLY" || frequency === "YEARLY" ? dayOfMonth : undefined,
month: frequency === "YEARLY" ? month : undefined,
end:
endType === "until"
? { type: "until", date: endDate }
: endType === "count"
? { type: "count", count: endCount }
: { type: "never" },
};
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle) return;
if (isRecurring && frequency === "WEEKLY" && daysOfWeek.length === 0) {
toast.error("Pick at least one day of the week.");
return;
}
if (isRecurring && endType === "until" && !endDate) {
toast.error("Pick an end date.");
return;
}
setPending(true);
try {
if (scheduledTodoId) {
await updateScheduledTodo(scheduledTodoId, {
title: trimmedTitle,
details: details.trim() || null,
startDate,
recurrence: buildRecurrence() ?? null,
});
} else {
await createScheduledTodo(
{ projectId },
{ title: trimmedTitle, details: details.trim() || undefined, startDate, recurrence: buildRecurrence() }
);
}
onSaved();
onOpenChange(false);
} catch {
toast.error(`Couldn't ${scheduledTodoId ? "update" : "add"} scheduled to-do. Try again.`);
} finally {
setPending(false);
}
}
async function handleDelete() {
if (!scheduledTodoId) return;
setPending(true);
try {
await deleteScheduledTodo(scheduledTodoId);
onDeleted();
onOpenChange(false);
} catch {
toast.error("Couldn't delete scheduled to-do. Try again.");
} finally {
setPending(false);
}
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{scheduledTodoId ? "Edit scheduled to-do" : "Add scheduled to-do"}</DialogTitle>
</DialogHeader>
{loading ? (
<p className="p-6 text-center text-sm text-muted-foreground">Loading</p>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="scheduled-title">Title</Label>
<Input
id="scheduled-title"
value={title}
maxLength={TITLE_MAX}
autoFocus
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Take out the trash"
/>
</div>
<div className="space-y-2">
<Label htmlFor="scheduled-details">Details (optional)</Label>
<Textarea
id="scheduled-details"
value={details}
onChange={(e) => setDetails(e.target.value)}
rows={2}
className="resize-none"
/>
</div>
<div className="space-y-2">
<Label htmlFor="scheduled-date">
{isRecurring ? "Starts on" : "Date"}
</Label>
<Input
id="scheduled-date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
/>
</div>
<label className="flex items-center gap-2 text-sm font-medium">
<Checkbox checked={isRecurring} onCheckedChange={(c) => setIsRecurring(!!c)} />
Repeat on a recurring schedule
</label>
{isRecurring && (
<div className="space-y-4 rounded-lg border p-3">
<div className="flex items-end gap-2">
<div className="space-y-2">
<Label>Every</Label>
<Input
type="number"
min={1}
max={365}
value={interval}
onChange={(e) => setInterval(Math.max(1, Number(e.target.value) || 1))}
className="w-20"
/>
</div>
<div className="space-y-2">
<Label className="sr-only">Frequency</Label>
<Select
value={frequency}
onValueChange={(v) => v && setFrequency(v as RecurrenceInput["frequency"])}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DAILY">{FREQUENCY_UNIT.DAILY}</SelectItem>
<SelectItem value="WEEKLY">{FREQUENCY_UNIT.WEEKLY}</SelectItem>
<SelectItem value="MONTHLY">{FREQUENCY_UNIT.MONTHLY}</SelectItem>
<SelectItem value="YEARLY">{FREQUENCY_UNIT.YEARLY}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{frequency === "WEEKLY" && (
<div className="space-y-2">
<Label>On these days</Label>
<div className="flex gap-1">
{WEEKDAY_LABELS.map((label, day) => (
<button
key={day}
type="button"
aria-pressed={daysOfWeek.includes(day)}
onClick={() => toggleDayOfWeek(day)}
className={cn(
"flex size-8 items-center justify-center rounded-md border text-xs font-medium transition-colors",
daysOfWeek.includes(day)
? "border-primary bg-primary text-primary-foreground"
: "border-input text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{label}
</button>
))}
</div>
</div>
)}
{(frequency === "MONTHLY" || frequency === "YEARLY") && (
<div className="flex items-end gap-2">
{frequency === "YEARLY" && (
<div className="space-y-2">
<Label className="sr-only">Month</Label>
<Select
value={String(month)}
onValueChange={(v) => v && setMonth(Number(v))}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MONTH_LABELS.map((label, i) => (
<SelectItem key={label} value={String(i + 1)}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label>Day</Label>
<Input
type="number"
min={1}
max={31}
value={dayOfMonth}
onChange={(e) =>
setDayOfMonth(Math.min(31, Math.max(1, Number(e.target.value) || 1)))
}
className="w-20"
/>
</div>
</div>
)}
<div className="space-y-2">
<Label>Ends</Label>
<RadioGroup
value={endType}
onValueChange={(v) => v && setEndType(v as "never" | "until" | "count")}
>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="never" />
Never
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="until" />
On date
{endType === "until" && (
<Input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="ml-1 w-auto"
/>
)}
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="count" />
After
{endType === "count" && (
<Input
type="number"
min={1}
max={999}
value={endCount}
onChange={(e) => setEndCount(Math.max(1, Number(e.target.value) || 1))}
className="ml-1 w-20"
/>
)}
occurrence(s)
</label>
</RadioGroup>
</div>
</div>
)}
<DialogFooter className={cn(scheduledTodoId && "sm:justify-between")}>
{scheduledTodoId && (
<Button
type="button"
variant="ghost"
className="text-destructive hover:text-destructive gap-2"
onClick={() => setConfirmDeleteOpen(true)}
disabled={pending}
>
<Trash2 className="size-4" />
Delete
</Button>
)}
<Button type="submit" disabled={pending || !title.trim()}>
{pending ? "Saving…" : scheduledTodoId ? "Save" : "Add scheduled to-do"}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
<ConfirmDeleteDialog
open={confirmDeleteOpen}
onOpenChange={setConfirmDeleteOpen}
title="Delete scheduled to-do?"
description={
isRecurring
? `Delete "${title}" and all of its remaining occurrences? This can't be undone.`
: `Delete "${title}"? This can't be undone.`
}
onConfirm={handleDelete}
/>
</>
);
}