Todo, reminder, project, and task forms now pre-fill date/time fields with today's date and current time when creating new items. Edit mode still uses stored values. DOB fields excluded. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
555 lines
19 KiB
TypeScript
555 lines
19 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { format, parseISO, isToday } from 'date-fns';
|
|
import {
|
|
X, Pencil, Trash2, Save, Clock, Calendar, Flag, Tag, Repeat, CheckSquare, AlertCircle, AlignLeft,
|
|
} from 'lucide-react';
|
|
import api, { getErrorMessage } from '@/lib/api';
|
|
import type { Todo } from '@/types';
|
|
import { isTodoOverdue } from '@/lib/utils';
|
|
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
|
import { formatUpdatedAt } from '@/components/shared/utils';
|
|
import CopyableField from '@/components/shared/CopyableField';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { DatePicker } from '@/components/ui/date-picker';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Select } from '@/components/ui/select';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Badge } from '@/components/ui/badge';
|
|
|
|
// --- Types ---
|
|
|
|
export interface TodoCreateDefaults {
|
|
category?: string;
|
|
}
|
|
|
|
interface TodoDetailPanelProps {
|
|
todo: Todo | null;
|
|
isCreating?: boolean;
|
|
createDefaults?: TodoCreateDefaults | null;
|
|
onClose: () => void;
|
|
onSaved?: () => void;
|
|
onDeleted?: () => void;
|
|
}
|
|
|
|
interface EditState {
|
|
title: string;
|
|
description: string;
|
|
priority: string;
|
|
due_date: string;
|
|
due_time: string;
|
|
category: string;
|
|
recurrence_rule: string;
|
|
}
|
|
|
|
const priorityColors: Record<string, string> = {
|
|
none: 'bg-gray-500/20 text-gray-400',
|
|
low: 'bg-green-500/20 text-green-400',
|
|
medium: 'bg-yellow-500/20 text-yellow-400',
|
|
high: 'bg-red-500/20 text-red-400',
|
|
};
|
|
|
|
const recurrenceLabels: Record<string, string> = {
|
|
daily: 'Daily',
|
|
weekly: 'Weekly',
|
|
monthly: 'Monthly',
|
|
};
|
|
|
|
function todayLocal(): string {
|
|
const d = new Date();
|
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
}
|
|
|
|
function nowTimeLocal(): string {
|
|
const d = new Date();
|
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
const QUERY_KEYS = [['todos'], ['dashboard'], ['upcoming']] as const;
|
|
|
|
function buildEditState(todo: Todo): EditState {
|
|
return {
|
|
title: todo.title,
|
|
description: todo.description || '',
|
|
priority: todo.priority,
|
|
due_date: todo.due_date ? todo.due_date.slice(0, 10) : '',
|
|
due_time: todo.due_time ? todo.due_time.slice(0, 5) : '',
|
|
category: todo.category || '',
|
|
recurrence_rule: todo.recurrence_rule || '',
|
|
};
|
|
}
|
|
|
|
function buildCreateState(defaults?: TodoCreateDefaults | null): EditState {
|
|
return {
|
|
title: '',
|
|
description: '',
|
|
priority: 'medium',
|
|
due_date: todayLocal(),
|
|
due_time: nowTimeLocal(),
|
|
category: defaults?.category || '',
|
|
recurrence_rule: '',
|
|
};
|
|
}
|
|
|
|
// --- Component ---
|
|
|
|
export default function TodoDetailPanel({
|
|
todo,
|
|
isCreating = false,
|
|
createDefaults,
|
|
onClose,
|
|
onSaved,
|
|
onDeleted,
|
|
}: TodoDetailPanelProps) {
|
|
const queryClient = useQueryClient();
|
|
|
|
const [isEditing, setIsEditing] = useState(isCreating);
|
|
const [editState, setEditState] = useState<EditState>(() =>
|
|
isCreating ? buildCreateState(createDefaults) : todo ? buildEditState(todo) : buildCreateState()
|
|
);
|
|
|
|
// Reset state when todo changes
|
|
useEffect(() => {
|
|
setIsEditing(false);
|
|
if (todo) setEditState(buildEditState(todo));
|
|
}, [todo?.id]);
|
|
|
|
// Enter edit mode when creating
|
|
useEffect(() => {
|
|
if (isCreating) {
|
|
setIsEditing(true);
|
|
setEditState(buildCreateState(createDefaults));
|
|
}
|
|
}, [isCreating, createDefaults]);
|
|
|
|
const invalidateAll = useCallback(() => {
|
|
QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
|
|
}, [queryClient]);
|
|
|
|
// --- Mutations ---
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: async (data: EditState) => {
|
|
const payload = {
|
|
title: data.title,
|
|
description: data.description || null,
|
|
priority: data.priority,
|
|
due_date: data.due_date || null,
|
|
due_time: data.due_time || null,
|
|
category: data.category || null,
|
|
recurrence_rule: data.recurrence_rule || null,
|
|
};
|
|
if (todo && !isCreating) {
|
|
return api.put(`/todos/${todo.id}`, payload);
|
|
} else {
|
|
return api.post('/todos', payload);
|
|
}
|
|
},
|
|
onSuccess: () => {
|
|
invalidateAll();
|
|
toast.success(isCreating ? 'Todo created' : 'Todo updated');
|
|
if (isCreating) {
|
|
onClose();
|
|
} else {
|
|
setIsEditing(false);
|
|
}
|
|
onSaved?.();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(getErrorMessage(error, isCreating ? 'Failed to create todo' : 'Failed to update todo'));
|
|
},
|
|
});
|
|
|
|
const toggleMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const { data } = await api.patch(`/todos/${todo!.id}/toggle`);
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
invalidateAll();
|
|
toast.success(todo!.completed ? 'Todo marked incomplete' : 'Todo completed!');
|
|
},
|
|
onError: () => {
|
|
toast.error('Failed to update todo');
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: async () => {
|
|
await api.delete(`/todos/${todo!.id}`);
|
|
},
|
|
onSuccess: () => {
|
|
invalidateAll();
|
|
toast.success('Todo deleted');
|
|
onClose();
|
|
onDeleted?.();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(getErrorMessage(error, 'Failed to delete todo'));
|
|
},
|
|
});
|
|
|
|
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
|
|
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
|
|
|
|
// --- Handlers ---
|
|
|
|
const handleEditStart = () => {
|
|
if (todo) setEditState(buildEditState(todo));
|
|
setIsEditing(true);
|
|
};
|
|
|
|
const handleEditCancel = () => {
|
|
setIsEditing(false);
|
|
if (isCreating) {
|
|
onClose();
|
|
} else if (todo) {
|
|
setEditState(buildEditState(todo));
|
|
}
|
|
};
|
|
|
|
const handleEditSave = () => {
|
|
saveMutation.mutate(editState);
|
|
};
|
|
|
|
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
|
|
setEditState((s) => ({ ...s, [key]: value }));
|
|
};
|
|
|
|
// Empty state
|
|
if (!todo && !isCreating) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
|
<CheckSquare className="h-8 w-8 mb-3 opacity-40" />
|
|
<p className="text-sm">Select a todo to view details</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// View data
|
|
const dueDate = todo?.due_date ? parseISO(todo.due_date) : null;
|
|
const isDueToday = dueDate ? isToday(dueDate) : false;
|
|
const isOverdue = todo ? isTodoOverdue(todo.due_date, todo.completed) : false;
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
|
|
{/* Header */}
|
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
|
<div className="flex items-start justify-between gap-3">
|
|
{isEditing && !isCreating ? (
|
|
<Input
|
|
value={editState.title}
|
|
onChange={(e) => updateField('title', e.target.value)}
|
|
className="h-8 text-base font-semibold flex-1"
|
|
placeholder="Todo title"
|
|
autoFocus
|
|
/>
|
|
) : isCreating ? (
|
|
<h3 className="font-heading text-lg font-semibold">New Todo</h3>
|
|
) : (
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
<span onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox
|
|
checked={todo!.completed}
|
|
onChange={() => toggleMutation.mutate()}
|
|
disabled={toggleMutation.isPending}
|
|
/>
|
|
</span>
|
|
<h3 className={`font-heading text-lg font-semibold truncate ${todo!.completed ? 'line-through text-muted-foreground' : ''}`}>
|
|
{todo!.title}
|
|
</h3>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{(isEditing || isCreating) ? (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-green-400 hover:text-green-300"
|
|
onClick={handleEditSave}
|
|
disabled={saveMutation.isPending}
|
|
title="Save"
|
|
>
|
|
<Save className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={handleEditCancel}
|
|
title="Cancel"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={handleEditStart}
|
|
title="Edit todo"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</Button>
|
|
{confirmingDelete ? (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={handleDeleteClick}
|
|
disabled={deleteMutation.isPending}
|
|
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
|
|
title="Confirm delete"
|
|
>
|
|
Sure?
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
|
onClick={handleDeleteClick}
|
|
disabled={deleteMutation.isPending}
|
|
title="Delete todo"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={onClose}
|
|
title="Close panel"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
|
|
{(isEditing || isCreating) ? (
|
|
/* Edit / Create mode */
|
|
<div className="space-y-4">
|
|
{isCreating && (
|
|
<div className="space-y-1">
|
|
<Label htmlFor="todo-title" required>Title</Label>
|
|
<Input
|
|
id="todo-title"
|
|
value={editState.title}
|
|
onChange={(e) => updateField('title', e.target.value)}
|
|
placeholder="Todo title"
|
|
required
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1">
|
|
<Label htmlFor="todo-desc">Description</Label>
|
|
<Textarea
|
|
id="todo-desc"
|
|
value={editState.description}
|
|
onChange={(e) => updateField('description', e.target.value)}
|
|
placeholder="Add a description..."
|
|
rows={3}
|
|
className="text-sm resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="todo-priority">Priority</Label>
|
|
<Select
|
|
id="todo-priority"
|
|
value={editState.priority}
|
|
onChange={(e) => updateField('priority', e.target.value)}
|
|
className="text-xs"
|
|
>
|
|
<option value="none">None</option>
|
|
<option value="low">Low</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="high">High</option>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="todo-category">Category</Label>
|
|
<Input
|
|
id="todo-category"
|
|
value={editState.category}
|
|
onChange={(e) => updateField('category', e.target.value)}
|
|
placeholder="e.g., Work"
|
|
className="text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="todo-due-date">Due Date</Label>
|
|
<DatePicker
|
|
variant="input"
|
|
id="todo-due-date"
|
|
mode="datetime"
|
|
value={editState.due_date ? (editState.due_date + 'T' + (editState.due_time || '00:00')) : ''}
|
|
onChange={(v) => {
|
|
updateField('due_date', v ? v.slice(0, 10) : '');
|
|
updateField('due_time', v ? v.slice(11, 16) : '');
|
|
}}
|
|
className="text-xs"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="todo-recurrence">Recurrence</Label>
|
|
<Select
|
|
id="todo-recurrence"
|
|
value={editState.recurrence_rule}
|
|
onChange={(e) => updateField('recurrence_rule', e.target.value)}
|
|
className="text-xs"
|
|
>
|
|
<option value="">None</option>
|
|
<option value="daily">Daily</option>
|
|
<option value="weekly">Weekly</option>
|
|
<option value="monthly">Monthly</option>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Save / Cancel at bottom */}
|
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
|
<Button variant="outline" size="sm" onClick={handleEditCancel}>
|
|
Cancel
|
|
</Button>
|
|
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
|
|
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* View mode */
|
|
<>
|
|
{/* 2-column grid: Priority, Category, Due Date, Recurrence */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{/* Priority */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<Flag className="h-3 w-3" />
|
|
Priority
|
|
</div>
|
|
<Badge className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[todo!.priority] ?? ''}`}>
|
|
{todo!.priority}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Category */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<Tag className="h-3 w-3" />
|
|
Category
|
|
</div>
|
|
{todo!.category ? (
|
|
<Badge className="text-[9px] px-1.5 py-0.5 bg-blue-500/15 text-blue-400">
|
|
{todo!.category}
|
|
</Badge>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">—</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Due Date */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
{isOverdue ? <AlertCircle className="h-3 w-3" /> : <Calendar className="h-3 w-3" />}
|
|
Due Date
|
|
</div>
|
|
{dueDate ? (
|
|
<CopyableField
|
|
value={`${isOverdue ? 'Overdue · ' : isDueToday ? 'Today · ' : ''}${format(dueDate, 'EEEE, MMMM d, yyyy')}${todo!.due_time ? ` at ${todo!.due_time.slice(0, 5)}` : ''}`}
|
|
icon={isOverdue ? AlertCircle : Calendar}
|
|
label="Due date"
|
|
/>
|
|
) : todo!.due_time ? (
|
|
<CopyableField value={todo!.due_time.slice(0, 5)} icon={Clock} label="Due time" />
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">—</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recurrence */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<Repeat className="h-3 w-3" />
|
|
Recurrence
|
|
</div>
|
|
{todo!.recurrence_rule ? (
|
|
<p className="text-sm">{recurrenceLabels[todo!.recurrence_rule] || todo!.recurrence_rule}</p>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">—</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Completion status — full width */}
|
|
{todo!.completed && todo!.completed_at && (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<CheckSquare className="h-3 w-3" />
|
|
Completed
|
|
</div>
|
|
<p className="text-sm text-green-400">
|
|
{format(parseISO(todo!.completed_at), 'MMM d, yyyy · h:mm a')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reset info — full width */}
|
|
{todo!.completed && todo!.recurrence_rule && todo!.reset_at && (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<Repeat className="h-3 w-3" />
|
|
Resets
|
|
</div>
|
|
<p className="text-sm text-purple-400">
|
|
{format(parseISO(todo!.reset_at), 'EEE, MMM d')}
|
|
{todo!.next_due_date && ` · Next due ${format(parseISO(todo!.next_due_date), 'MMM d')}`}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Description — full width */}
|
|
{todo!.description && (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<AlignLeft className="h-3 w-3" />
|
|
Description
|
|
</div>
|
|
<p className="text-sm whitespace-pre-wrap text-muted-foreground leading-relaxed">
|
|
{todo!.description}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Updated at */}
|
|
<div className="pt-2 border-t border-border">
|
|
<span className="text-[11px] text-muted-foreground">
|
|
{formatUpdatedAt(todo!.updated_at)}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|