429 lines
16 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|