1df208dbcc
Allocation bars that have active optimistic overrides (post-drag, awaiting server confirmation) now pulse subtly via animate-pulse. The pending set is derived from the existing optimisticAllocations map keys, requiring no additional state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
304 lines
9.9 KiB
TypeScript
304 lines
9.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
|
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
|
import { DateInput } from "~/components/ui/DateInput.js";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import { toDateInputValue } from "~/lib/format.js";
|
|
|
|
const RECURRENCE_OPTIONS = [
|
|
{ value: "", label: "None" },
|
|
{ value: "daily", label: "Daily" },
|
|
{ value: "weekly", label: "Weekly" },
|
|
{ value: "monthly", label: "Monthly" },
|
|
] as const;
|
|
|
|
interface ReminderModalProps {
|
|
reminder?: {
|
|
id: string;
|
|
title: string;
|
|
body?: string | null;
|
|
remindAt?: string | Date | null;
|
|
recurrence?: string | null;
|
|
link?: string | null;
|
|
} | null;
|
|
onClose: () => void;
|
|
onSuccess: () => void;
|
|
}
|
|
|
|
function toTimeInputValue(date: Date | string | null | undefined): string {
|
|
if (!date) return "09:00";
|
|
const d = typeof date === "string" ? new Date(date) : date;
|
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
}
|
|
|
|
export function ReminderModal({ reminder, onClose, onSuccess }: ReminderModalProps) {
|
|
const isEdit = !!reminder;
|
|
|
|
const [title, setTitle] = useState(reminder?.title ?? "");
|
|
const [body, setBody] = useState(reminder?.body ?? "");
|
|
const [remindDate, setRemindDate] = useState(toDateInputValue(reminder?.remindAt));
|
|
const [remindTime, setRemindTime] = useState(toTimeInputValue(reminder?.remindAt));
|
|
const [recurrence, setRecurrence] = useState(reminder?.recurrence ?? "");
|
|
const [link, setLink] = useState(reminder?.link ?? "");
|
|
const [serverError, setServerError] = useState<string | null>(null);
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
|
|
const utils = trpc.useUtils();
|
|
|
|
const createMutation = trpc.notification.createReminder.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.notification.listReminders.invalidate();
|
|
await utils.notification.list.invalidate();
|
|
onSuccess();
|
|
},
|
|
onError: (err) => setServerError(err.message),
|
|
});
|
|
|
|
const updateMutation = trpc.notification.updateReminder.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.notification.listReminders.invalidate();
|
|
await utils.notification.list.invalidate();
|
|
onSuccess();
|
|
},
|
|
onError: (err) => setServerError(err.message),
|
|
});
|
|
|
|
const deleteMutation = trpc.notification.deleteReminder.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.notification.listReminders.invalidate();
|
|
await utils.notification.list.invalidate();
|
|
onSuccess();
|
|
},
|
|
onError: (err) => setServerError(err.message),
|
|
});
|
|
|
|
const isPending = createMutation.isPending || updateMutation.isPending || deleteMutation.isPending;
|
|
|
|
function buildRemindAt(): Date | null {
|
|
if (!remindDate) return null;
|
|
const [hours, minutes] = remindTime.split(":").map(Number);
|
|
const d = new Date(remindDate + "T00:00:00");
|
|
d.setHours(hours ?? 9, minutes ?? 0, 0, 0);
|
|
return d;
|
|
}
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setServerError(null);
|
|
|
|
const remindAt = buildRemindAt();
|
|
if (!title.trim()) {
|
|
setServerError("Title is required.");
|
|
return;
|
|
}
|
|
if (!remindAt) {
|
|
setServerError("Remind date is required.");
|
|
return;
|
|
}
|
|
|
|
if (isEdit && reminder) {
|
|
updateMutation.mutate({
|
|
id: reminder.id,
|
|
title: title.trim(),
|
|
body: body.trim() || undefined,
|
|
remindAt,
|
|
recurrence: (recurrence || null) as "daily" | "weekly" | "monthly" | null,
|
|
});
|
|
} else {
|
|
createMutation.mutate({
|
|
title: title.trim(),
|
|
body: body.trim() || undefined,
|
|
remindAt,
|
|
...(recurrence ? { recurrence: recurrence as "daily" | "weekly" | "monthly" } : {}),
|
|
...(link.trim() ? { link: link.trim() } : {}),
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleDelete() {
|
|
if (!reminder) return;
|
|
setConfirmDelete(true);
|
|
}
|
|
|
|
const inputClass =
|
|
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
|
|
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
|
|
|
return (
|
|
<>
|
|
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-lg">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
{isEdit ? "Edit Reminder" : "New Reminder"}
|
|
</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors text-xl leading-none"
|
|
aria-label="Close"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
|
{/* Title */}
|
|
<div>
|
|
<label htmlFor="rem-title" className={labelClass}>
|
|
Title <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
id="rem-title"
|
|
type="text"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
maxLength={200}
|
|
className={inputClass}
|
|
required
|
|
placeholder="Reminder title..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div>
|
|
<label htmlFor="rem-body" className={labelClass}>
|
|
Description (optional)
|
|
</label>
|
|
<textarea
|
|
id="rem-body"
|
|
value={body}
|
|
onChange={(e) => setBody(e.target.value)}
|
|
rows={3}
|
|
maxLength={2000}
|
|
className={`${inputClass} resize-none`}
|
|
placeholder="Additional details..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Date + Time */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label htmlFor="rem-date" className={labelClass}>
|
|
Remind Date <span className="text-red-500">*</span>
|
|
</label>
|
|
<DateInput
|
|
id="rem-date"
|
|
value={remindDate}
|
|
onChange={setRemindDate}
|
|
className={inputClass}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="rem-time" className={labelClass}>
|
|
Remind Time <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
id="rem-time"
|
|
type="time"
|
|
value={remindTime}
|
|
onChange={(e) => setRemindTime(e.target.value)}
|
|
className={inputClass}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recurrence */}
|
|
<div>
|
|
<label htmlFor="rem-recurrence" className={labelClass}>
|
|
Recurrence
|
|
</label>
|
|
<select
|
|
id="rem-recurrence"
|
|
value={recurrence}
|
|
onChange={(e) => setRecurrence(e.target.value)}
|
|
className={inputClass}
|
|
>
|
|
{RECURRENCE_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Link */}
|
|
{!isEdit && (
|
|
<div>
|
|
<label htmlFor="rem-link" className={labelClass}>
|
|
Link (optional)
|
|
</label>
|
|
<input
|
|
id="rem-link"
|
|
type="text"
|
|
value={link}
|
|
onChange={(e) => setLink(e.target.value)}
|
|
className={inputClass}
|
|
placeholder="/projects/abc or https://..."
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Server error */}
|
|
{serverError && (
|
|
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:border-red-800 dark:text-red-300">
|
|
{serverError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between pt-2">
|
|
<div>
|
|
{isEdit && (
|
|
<button
|
|
type="button"
|
|
onClick={handleDelete}
|
|
disabled={isPending}
|
|
className="text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50"
|
|
>
|
|
Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
disabled={isPending}
|
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isPending}
|
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
|
>
|
|
{isPending ? "Saving..." : isEdit ? "Update" : "Create Reminder"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</AnimatedModal>
|
|
|
|
{confirmDelete && reminder && (
|
|
<ConfirmDialog
|
|
title="Delete reminder"
|
|
message="Are you sure you want to delete this reminder?"
|
|
confirmLabel="Delete"
|
|
variant="danger"
|
|
onConfirm={() => {
|
|
deleteMutation.mutate({ id: reminder.id });
|
|
setConfirmDelete(false);
|
|
}}
|
|
onCancel={() => setConfirmDelete(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|