Replace Sheet overlays with inline detail panels across all pages
- Calendar: move view selector left, inline EventDetailPanel with view/edit/create modes, fix resize on panel close, remove all Sheet/Dialog usage - Todos: add TodoDetailPanel with inline view/edit/create, replace CategoryFilterBar with shared component (drag-and-drop categories), 55/45 split layout - Reminders: add ReminderDetailPanel with inline view/edit/create, 55/45 split layout - Dashboard: all widget items now deep-link to destination page AND open the relevant item's detail panel (events, todos, reminders) - Fix TS errors: unused imports, undefined→null coalescing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
898ecc407a
commit
8945295e2a
@ -14,15 +14,9 @@ import { useCalendars } from '@/hooks/useCalendars';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import CalendarSidebar from './CalendarSidebar';
|
||||
import EventForm from './EventForm';
|
||||
import EventDetailPanel from './EventDetailPanel';
|
||||
import type { CreateDefaults } from './EventDetailPanel';
|
||||
|
||||
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
|
||||
|
||||
@ -32,32 +26,20 @@ const viewLabels: Record<CalendarView, string> = {
|
||||
timeGridDay: 'Day',
|
||||
};
|
||||
|
||||
type ScopeAction = 'edit' | 'delete';
|
||||
|
||||
export default function CalendarPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const calendarRef = useRef<FullCalendar>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null);
|
||||
const [selectedStart, setSelectedStart] = useState<string | null>(null);
|
||||
const [selectedEnd, setSelectedEnd] = useState<string | null>(null);
|
||||
const [selectedAllDay, setSelectedAllDay] = useState(false);
|
||||
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
|
||||
const [calendarTitle, setCalendarTitle] = useState('');
|
||||
|
||||
const [templateEvent, setTemplateEvent] = useState<Partial<CalendarEvent> | null>(null);
|
||||
const [templateName, setTemplateName] = useState<string | null>(null);
|
||||
|
||||
const [eventSearch, setEventSearch] = useState('');
|
||||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
const [selectedEventId, setSelectedEventId] = useState<number | string | null>(null);
|
||||
|
||||
// Scope dialog state
|
||||
const [scopeDialogOpen, setScopeDialogOpen] = useState(false);
|
||||
const [scopeAction, setScopeAction] = useState<ScopeAction>('edit');
|
||||
const [scopeEvent, setScopeEvent] = useState<CalendarEvent | null>(null);
|
||||
const [activeEditScope, setActiveEditScope] = useState<'this' | 'this_and_future' | null>(null);
|
||||
// Panel state
|
||||
const [selectedEventId, setSelectedEventId] = useState<number | string | null>(null);
|
||||
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
|
||||
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
|
||||
|
||||
const { settings } = useSettings();
|
||||
const { data: calendars = [] } = useCalendars();
|
||||
@ -81,13 +63,17 @@ export default function CalendarPage() {
|
||||
|
||||
// Handle navigation state from dashboard
|
||||
useEffect(() => {
|
||||
const state = location.state as { date?: string; view?: string } | null;
|
||||
if (!state?.date) return;
|
||||
const state = location.state as { date?: string; view?: string; eventId?: number } | null;
|
||||
if (!state) return;
|
||||
const calApi = calendarRef.current?.getApi();
|
||||
if (!calApi) return;
|
||||
calApi.gotoDate(state.date);
|
||||
if (state.view) calApi.changeView(state.view);
|
||||
// Clear state to prevent re-triggering
|
||||
if (state.date && calApi) {
|
||||
calApi.gotoDate(state.date);
|
||||
if (state.view) calApi.changeView(state.view);
|
||||
}
|
||||
if (state.eventId) {
|
||||
setSelectedEventId(state.eventId);
|
||||
setPanelMode('view');
|
||||
}
|
||||
window.history.replaceState({}, '');
|
||||
}, [location.state]);
|
||||
|
||||
@ -102,6 +88,16 @@ export default function CalendarPage() {
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const panelOpen = panelMode !== 'closed';
|
||||
|
||||
// Resize calendar when panel opens/closes (CSS transition won't trigger ResizeObserver on inner div)
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
calendarRef.current?.getApi().updateSize();
|
||||
}, 320);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [panelOpen]);
|
||||
|
||||
// Scroll wheel navigation in month view
|
||||
useEffect(() => {
|
||||
const el = calendarContainerRef.current;
|
||||
@ -130,7 +126,6 @@ export default function CalendarPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const panelOpen = selectedEventId !== null;
|
||||
const selectedEvent = useMemo(
|
||||
() => events.find((e) => e.id === selectedEventId) ?? null,
|
||||
[selectedEventId, events],
|
||||
@ -140,7 +135,7 @@ export default function CalendarPage() {
|
||||
useEffect(() => {
|
||||
if (!panelOpen) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setSelectedEventId(null);
|
||||
if (e.key === 'Escape') handlePanelClose();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
@ -204,24 +199,6 @@ export default function CalendarPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const scopeDeleteMutation = useMutation({
|
||||
mutationFn: async ({ id, scope }: { id: number; scope: string }) => {
|
||||
await api.delete(`/events/${id}?scope=${scope}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
||||
toast.success('Event(s) deleted');
|
||||
setScopeDialogOpen(false);
|
||||
setScopeEvent(null);
|
||||
setSelectedEventId(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, 'Failed to delete event'));
|
||||
},
|
||||
});
|
||||
|
||||
const filteredEvents = useMemo(() => {
|
||||
if (calendars.length === 0) return events;
|
||||
return events.filter((e) => visibleCalendarIds.has(e.calendar_id));
|
||||
@ -236,17 +213,22 @@ export default function CalendarPage() {
|
||||
}, [filteredEvents, eventSearch]);
|
||||
|
||||
const handleSearchSelect = (event: CalendarEvent) => {
|
||||
const api = calendarRef.current?.getApi();
|
||||
if (!api) return;
|
||||
const calApi = calendarRef.current?.getApi();
|
||||
if (!calApi) return;
|
||||
const startDate = new Date(event.start_datetime);
|
||||
api.gotoDate(startDate);
|
||||
calApi.gotoDate(startDate);
|
||||
if (event.all_day) {
|
||||
api.changeView('dayGridMonth');
|
||||
calApi.changeView('dayGridMonth');
|
||||
} else {
|
||||
api.changeView('timeGridDay');
|
||||
calApi.changeView('timeGridDay');
|
||||
}
|
||||
setEventSearch('');
|
||||
setSearchFocused(false);
|
||||
// Also open the event in the panel
|
||||
if (!event.is_virtual) {
|
||||
setSelectedEventId(event.id);
|
||||
setPanelMode('view');
|
||||
}
|
||||
};
|
||||
|
||||
const calendarEvents = filteredEvents.map((event) => ({
|
||||
@ -264,10 +246,6 @@ export default function CalendarPage() {
|
||||
},
|
||||
}));
|
||||
|
||||
const isRecurring = (event: CalendarEvent): boolean => {
|
||||
return !!(event.is_recurring || event.parent_event_id);
|
||||
};
|
||||
|
||||
const handleEventClick = (info: EventClickArg) => {
|
||||
const event = events.find((e) => String(e.id) === info.event.id);
|
||||
if (!event) return;
|
||||
@ -276,18 +254,7 @@ export default function CalendarPage() {
|
||||
return;
|
||||
}
|
||||
setSelectedEventId(event.id);
|
||||
};
|
||||
|
||||
const handleScopeChoice = (scope: 'this' | 'this_and_future') => {
|
||||
if (!scopeEvent) return;
|
||||
if (scopeAction === 'edit') {
|
||||
setEditingEvent(scopeEvent);
|
||||
setActiveEditScope(scope);
|
||||
setShowForm(true);
|
||||
setScopeDialogOpen(false);
|
||||
} else if (scopeAction === 'delete') {
|
||||
scopeDeleteMutation.mutate({ id: scopeEvent.id as number, scope });
|
||||
}
|
||||
setPanelMode('view');
|
||||
};
|
||||
|
||||
const handleEventDrop = (info: EventDropArg) => {
|
||||
@ -295,7 +262,6 @@ export default function CalendarPage() {
|
||||
info.revert();
|
||||
return;
|
||||
}
|
||||
// Prevent drag-drop on recurring events — user must use scope dialog via click
|
||||
if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) {
|
||||
info.revert();
|
||||
toast.info('Click the event to edit recurring events');
|
||||
@ -320,7 +286,6 @@ export default function CalendarPage() {
|
||||
info.revert();
|
||||
return;
|
||||
}
|
||||
// Prevent resize on recurring events — user must use scope dialog via click
|
||||
if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) {
|
||||
info.revert();
|
||||
toast.info('Click the event to edit recurring events');
|
||||
@ -341,37 +306,43 @@ export default function CalendarPage() {
|
||||
};
|
||||
|
||||
const handleDateSelect = (selectInfo: DateSelectArg) => {
|
||||
setSelectedStart(selectInfo.startStr);
|
||||
setSelectedEnd(selectInfo.endStr);
|
||||
setSelectedAllDay(selectInfo.allDay);
|
||||
setShowForm(true);
|
||||
setSelectedEventId(null);
|
||||
setPanelMode('create');
|
||||
setCreateDefaults({
|
||||
start: selectInfo.startStr,
|
||||
end: selectInfo.endStr,
|
||||
allDay: selectInfo.allDay,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
const handleCreateNew = () => {
|
||||
setSelectedEventId(null);
|
||||
setPanelMode('create');
|
||||
setCreateDefaults(null);
|
||||
};
|
||||
|
||||
const handlePanelClose = () => {
|
||||
calendarRef.current?.getApi().unselect();
|
||||
setShowForm(false);
|
||||
setEditingEvent(null);
|
||||
setTemplateEvent(null);
|
||||
setTemplateName(null);
|
||||
setActiveEditScope(null);
|
||||
setSelectedStart(null);
|
||||
setSelectedEnd(null);
|
||||
setSelectedAllDay(false);
|
||||
setPanelMode('closed');
|
||||
setSelectedEventId(null);
|
||||
setCreateDefaults(null);
|
||||
};
|
||||
|
||||
const handleUseTemplate = (template: EventTemplate) => {
|
||||
setTemplateEvent({
|
||||
title: template.title,
|
||||
description: template.description || '',
|
||||
all_day: template.all_day,
|
||||
calendar_id: template.calendar_id ?? undefined,
|
||||
location_id: template.location_id || undefined,
|
||||
is_starred: template.is_starred,
|
||||
recurrence_rule: template.recurrence_rule || undefined,
|
||||
} as Partial<CalendarEvent>);
|
||||
setTemplateName(template.name);
|
||||
setEditingEvent(null);
|
||||
setShowForm(true);
|
||||
setSelectedEventId(null);
|
||||
setPanelMode('create');
|
||||
setCreateDefaults({
|
||||
templateData: {
|
||||
title: template.title,
|
||||
description: template.description || '',
|
||||
all_day: template.all_day,
|
||||
calendar_id: template.calendar_id ?? undefined,
|
||||
location_id: template.location_id || undefined,
|
||||
is_starred: template.is_starred,
|
||||
recurrence_rule: template.recurrence_rule || undefined,
|
||||
} as Partial<CalendarEvent>,
|
||||
templateName: template.name,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDatesSet = (arg: DatesSetArg) => {
|
||||
@ -379,46 +350,6 @@ export default function CalendarPage() {
|
||||
setCurrentView(arg.view.type as CalendarView);
|
||||
};
|
||||
|
||||
// Panel actions
|
||||
const handlePanelEdit = () => {
|
||||
if (!selectedEvent) return;
|
||||
if (isRecurring(selectedEvent)) {
|
||||
setScopeEvent(selectedEvent);
|
||||
setScopeAction('edit');
|
||||
setScopeDialogOpen(true);
|
||||
} else {
|
||||
setEditingEvent(selectedEvent);
|
||||
setShowForm(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePanelDelete = () => {
|
||||
if (!selectedEvent) return;
|
||||
if (isRecurring(selectedEvent)) {
|
||||
setScopeEvent(selectedEvent);
|
||||
setScopeAction('delete');
|
||||
setScopeDialogOpen(true);
|
||||
} else {
|
||||
panelDeleteMutation.mutate(selectedEvent.id as number);
|
||||
}
|
||||
};
|
||||
|
||||
const panelDeleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await api.delete(`/events/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
||||
toast.success('Event deleted');
|
||||
setSelectedEventId(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, 'Failed to delete event'));
|
||||
},
|
||||
});
|
||||
|
||||
const navigatePrev = () => calendarRef.current?.getApi().prev();
|
||||
const navigateNext = () => calendarRef.current?.getApi().next();
|
||||
const navigateToday = () => calendarRef.current?.getApi().today();
|
||||
@ -429,7 +360,7 @@ export default function CalendarPage() {
|
||||
<CalendarSidebar onUseTemplate={handleUseTemplate} />
|
||||
|
||||
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Custom toolbar — h-16 matches sidebar header */}
|
||||
{/* Custom toolbar */}
|
||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
|
||||
@ -442,6 +373,27 @@ export default function CalendarPage() {
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={navigateToday}>
|
||||
Today
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
||||
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
|
||||
<button
|
||||
key={view}
|
||||
onClick={() => changeView(view)}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
|
||||
currentView === view
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: currentView === view ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||
color: currentView === view ? 'hsl(var(--accent-color))' : undefined,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-semibold font-heading">{calendarTitle}</h2>
|
||||
|
||||
<div className="flex-1" />
|
||||
@ -483,30 +435,10 @@ export default function CalendarPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button size="sm" onClick={() => setShowForm(true)}>
|
||||
<Button size="sm" onClick={handleCreateNew}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Event
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
||||
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
|
||||
<button
|
||||
key={view}
|
||||
onClick={() => changeView(view)}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
|
||||
currentView === view
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: currentView === view ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||
color: currentView === view ? 'hsl(var(--accent-color))' : undefined,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar grid + event detail panel */}
|
||||
@ -549,11 +481,11 @@ export default function CalendarPage() {
|
||||
}`}
|
||||
>
|
||||
<EventDetailPanel
|
||||
event={selectedEvent}
|
||||
onEdit={handlePanelEdit}
|
||||
onDelete={handlePanelDelete}
|
||||
deleteLoading={panelDeleteMutation.isPending}
|
||||
onClose={() => setSelectedEventId(null)}
|
||||
event={panelMode === 'view' ? selectedEvent : null}
|
||||
isCreating={panelMode === 'create'}
|
||||
createDefaults={createDefaults}
|
||||
onClose={handlePanelClose}
|
||||
onSaved={handlePanelClose}
|
||||
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
||||
/>
|
||||
</div>
|
||||
@ -561,79 +493,26 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
|
||||
{/* Mobile detail panel overlay */}
|
||||
{panelOpen && selectedEvent && (
|
||||
{panelOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||
onClick={() => setSelectedEventId(null)}
|
||||
onClick={handlePanelClose}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EventDetailPanel
|
||||
event={selectedEvent}
|
||||
onEdit={handlePanelEdit}
|
||||
onDelete={handlePanelDelete}
|
||||
deleteLoading={panelDeleteMutation.isPending}
|
||||
onClose={() => setSelectedEventId(null)}
|
||||
event={panelMode === 'view' ? selectedEvent : null}
|
||||
isCreating={panelMode === 'create'}
|
||||
createDefaults={createDefaults}
|
||||
onClose={handlePanelClose}
|
||||
onSaved={handlePanelClose}
|
||||
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<EventForm
|
||||
event={editingEvent}
|
||||
templateData={templateEvent}
|
||||
templateName={templateName}
|
||||
initialStart={selectedStart}
|
||||
initialEnd={selectedEnd}
|
||||
initialAllDay={selectedAllDay}
|
||||
editScope={activeEditScope}
|
||||
onClose={handleCloseForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Recurring event scope dialog */}
|
||||
<Dialog open={scopeDialogOpen} onOpenChange={setScopeDialogOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{scopeAction === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This is a recurring event. How would you like to proceed?
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center"
|
||||
onClick={() => handleScopeChoice('this')}
|
||||
>
|
||||
This event only
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center"
|
||||
onClick={() => handleScopeChoice('this_and_future')}
|
||||
>
|
||||
This and all future events
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-center"
|
||||
onClick={() => {
|
||||
setScopeDialogOpen(false);
|
||||
setScopeEvent(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -41,7 +41,7 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={() => navigate('/calendar', { state: { date: todayStr, view: 'timeGridDay' } })}
|
||||
onClick={() => navigate('/calendar', { state: { date: todayStr, view: 'timeGridDay', eventId: event.id } })}
|
||||
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
|
||||
>
|
||||
<div
|
||||
|
||||
@ -24,7 +24,7 @@ export default function CountdownWidget({ events }: CountdownWidgetProps) {
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={() => navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay' } })}
|
||||
onClick={() => navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay', eventId: event.id } })}
|
||||
className="flex items-center gap-2 cursor-pointer hover:bg-amber-500/10 rounded px-1 -mx-1 transition-colors duration-150"
|
||||
>
|
||||
<Star className="h-3 w-3 text-amber-400 fill-amber-400 shrink-0" />
|
||||
|
||||
@ -236,7 +236,7 @@ export default function DashboardPage() {
|
||||
{futureReminders.map((reminder) => (
|
||||
<div
|
||||
key={reminder.id}
|
||||
onClick={() => navigate('/reminders')}
|
||||
onClick={() => navigate('/reminders', { state: { reminderId: reminder.id } })}
|
||||
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
|
||||
>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-orange-400 shrink-0" />
|
||||
|
||||
@ -54,7 +54,7 @@ export default function TodoWidget({ todos }: TodoWidgetProps) {
|
||||
return (
|
||||
<div
|
||||
key={todo.id}
|
||||
onClick={() => navigate('/todos')}
|
||||
onClick={() => navigate('/todos', { state: { todoId: todo.id } })}
|
||||
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
|
||||
>
|
||||
<div className={cn('w-1.5 h-1.5 rounded-full shrink-0', dotColors[todo.priority] || dotColors.medium)} />
|
||||
|
||||
@ -26,14 +26,14 @@ export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps)
|
||||
const dateStr = item.datetime
|
||||
? format(new Date(item.datetime), 'yyyy-MM-dd')
|
||||
: item.date;
|
||||
navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay' } });
|
||||
navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay', eventId: item.id } });
|
||||
break;
|
||||
}
|
||||
case 'todo':
|
||||
navigate('/todos');
|
||||
navigate('/todos', { state: { todoId: item.id } });
|
||||
break;
|
||||
case 'reminder':
|
||||
navigate('/reminders');
|
||||
navigate('/reminders', { state: { reminderId: item.id } });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
467
frontend/src/components/reminders/ReminderDetailPanel.tsx
Normal file
467
frontend/src/components/reminders/ReminderDetailPanel.tsx
Normal file
@ -0,0 +1,467 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { format, parseISO, isPast, isToday } from 'date-fns';
|
||||
import {
|
||||
X, Pencil, Trash2, Save, Bell, BellOff, Clock, Repeat, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type { Reminder } from '@/types';
|
||||
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 { Textarea } from '@/components/ui/textarea';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface ReminderDetailPanelProps {
|
||||
reminder: Reminder | null;
|
||||
isCreating?: boolean;
|
||||
onClose: () => void;
|
||||
onSaved?: () => void;
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
interface EditState {
|
||||
title: string;
|
||||
description: string;
|
||||
remind_at: string;
|
||||
recurrence_rule: string;
|
||||
}
|
||||
|
||||
const recurrenceLabels: Record<string, string> = {
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
};
|
||||
|
||||
const QUERY_KEYS = [['reminders'], ['dashboard'], ['upcoming']] as const;
|
||||
|
||||
function buildEditState(reminder: Reminder): EditState {
|
||||
return {
|
||||
title: reminder.title,
|
||||
description: reminder.description || '',
|
||||
remind_at: reminder.remind_at ? reminder.remind_at.slice(0, 16) : '',
|
||||
recurrence_rule: reminder.recurrence_rule || '',
|
||||
};
|
||||
}
|
||||
|
||||
function buildCreateState(): EditState {
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
remind_at: '',
|
||||
recurrence_rule: '',
|
||||
};
|
||||
}
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export default function ReminderDetailPanel({
|
||||
reminder,
|
||||
isCreating = false,
|
||||
onClose,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: ReminderDetailPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editState, setEditState] = useState<EditState>(() =>
|
||||
isCreating ? buildCreateState() : reminder ? buildEditState(reminder) : buildCreateState()
|
||||
);
|
||||
|
||||
// Reset state when reminder changes
|
||||
useEffect(() => {
|
||||
setIsEditing(false);
|
||||
if (reminder) setEditState(buildEditState(reminder));
|
||||
}, [reminder?.id]);
|
||||
|
||||
// Enter edit mode when creating
|
||||
useEffect(() => {
|
||||
if (isCreating) {
|
||||
setIsEditing(true);
|
||||
setEditState(buildCreateState());
|
||||
}
|
||||
}, [isCreating]);
|
||||
|
||||
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,
|
||||
remind_at: data.remind_at || null,
|
||||
recurrence_rule: data.recurrence_rule || null,
|
||||
};
|
||||
if (reminder && !isCreating) {
|
||||
return api.put(`/reminders/${reminder.id}`, payload);
|
||||
} else {
|
||||
return api.post('/reminders', payload);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateAll();
|
||||
toast.success(isCreating ? 'Reminder created' : 'Reminder updated');
|
||||
if (isCreating) {
|
||||
onClose();
|
||||
} else {
|
||||
setIsEditing(false);
|
||||
}
|
||||
onSaved?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, isCreating ? 'Failed to create reminder' : 'Failed to update reminder'));
|
||||
},
|
||||
});
|
||||
|
||||
const dismissMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await api.patch(`/reminders/${reminder!.id}/dismiss`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateAll();
|
||||
toast.success('Reminder dismissed');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to dismiss reminder');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await api.delete(`/reminders/${reminder!.id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateAll();
|
||||
toast.success('Reminder deleted');
|
||||
onClose();
|
||||
onDeleted?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, 'Failed to delete reminder'));
|
||||
},
|
||||
});
|
||||
|
||||
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
|
||||
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
const handleEditStart = () => {
|
||||
if (reminder) setEditState(buildEditState(reminder));
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleEditCancel = () => {
|
||||
setIsEditing(false);
|
||||
if (isCreating) {
|
||||
onClose();
|
||||
} else if (reminder) {
|
||||
setEditState(buildEditState(reminder));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditSave = () => {
|
||||
saveMutation.mutate(editState);
|
||||
};
|
||||
|
||||
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
|
||||
setEditState((s) => ({ ...s, [key]: value }));
|
||||
};
|
||||
|
||||
// Empty state
|
||||
if (!reminder && !isCreating) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Bell className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">Select a reminder to view details</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// View data
|
||||
const remindDate = reminder?.remind_at ? parseISO(reminder.remind_at) : null;
|
||||
const isOverdue = !reminder?.is_dismissed && remindDate && isPast(remindDate) && !isToday(remindDate);
|
||||
const isDueToday = remindDate ? isToday(remindDate) : 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="Reminder title"
|
||||
autoFocus
|
||||
/>
|
||||
) : isCreating ? (
|
||||
<h3 className="font-heading text-lg font-semibold">New Reminder</h3>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<Bell
|
||||
className={`h-4 w-4 shrink-0 ${
|
||||
isOverdue ? 'text-red-400' : reminder!.is_dismissed ? 'text-muted-foreground' : 'text-orange-400'
|
||||
}`}
|
||||
/>
|
||||
<h3 className={`font-heading text-lg font-semibold truncate ${reminder!.is_dismissed ? 'line-through text-muted-foreground' : ''}`}>
|
||||
{reminder!.title}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!reminder!.is_dismissed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:bg-orange-500/10 hover:text-orange-400"
|
||||
onClick={() => dismissMutation.mutate()}
|
||||
disabled={dismissMutation.isPending}
|
||||
title="Dismiss reminder"
|
||||
>
|
||||
<BellOff className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleEditStart}
|
||||
title="Edit reminder"
|
||||
>
|
||||
<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 reminder"
|
||||
>
|
||||
<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 ? (
|
||||
/* Edit / Create mode */
|
||||
<div className="space-y-4">
|
||||
{isCreating && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="reminder-title" required>Title</Label>
|
||||
<Input
|
||||
id="reminder-title"
|
||||
value={editState.title}
|
||||
onChange={(e) => updateField('title', e.target.value)}
|
||||
placeholder="Reminder title"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="reminder-desc">Description</Label>
|
||||
<Textarea
|
||||
id="reminder-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="reminder-at">Remind At</Label>
|
||||
<Input
|
||||
id="reminder-at"
|
||||
type="datetime-local"
|
||||
value={editState.remind_at}
|
||||
onChange={(e) => updateField('remind_at', e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="reminder-recurrence">Recurrence</Label>
|
||||
<Select
|
||||
id="reminder-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 */
|
||||
<>
|
||||
{/* Status */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Status</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{reminder!.is_dismissed ? (
|
||||
<>
|
||||
<BellOff className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">Dismissed</span>
|
||||
</>
|
||||
) : isOverdue ? (
|
||||
<>
|
||||
<AlertCircle className="h-3.5 w-3.5 text-red-400 shrink-0" />
|
||||
<span className="text-sm text-red-400">Overdue</span>
|
||||
</>
|
||||
) : isDueToday ? (
|
||||
<>
|
||||
<Bell className="h-3.5 w-3.5 text-yellow-400 shrink-0" />
|
||||
<span className="text-sm text-yellow-400">Due today</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bell className="h-3.5 w-3.5 text-orange-400 shrink-0" />
|
||||
<span className="text-sm text-orange-400">Active</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remind At */}
|
||||
{remindDate && (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Remind At</p>
|
||||
<CopyableField
|
||||
value={format(remindDate, 'EEEE, MMMM d, yyyy · h:mm a')}
|
||||
icon={Clock}
|
||||
label="Remind at"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snoozed */}
|
||||
{reminder!.snoozed_until && (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Snoozed Until</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm">
|
||||
{format(parseISO(reminder!.snoozed_until), 'MMM d, h:mm a')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurrence */}
|
||||
{reminder!.recurrence_rule && (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Recurrence</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Repeat className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm">
|
||||
{recurrenceLabels[reminder!.recurrence_rule] || reminder!.recurrence_rule}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{reminder!.description && (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Description</p>
|
||||
<p className="text-sm whitespace-pre-wrap text-muted-foreground leading-relaxed">
|
||||
{reminder!.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Updated at */}
|
||||
<div className="pt-2 border-t border-border">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatUpdatedAt(reminder!.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { isPast, isToday, parseISO } from 'date-fns';
|
||||
@ -9,7 +10,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { ListSkeleton } from '@/components/ui/skeleton';
|
||||
import ReminderList from './ReminderList';
|
||||
import ReminderForm from './ReminderForm';
|
||||
import ReminderDetailPanel from './ReminderDetailPanel';
|
||||
|
||||
const statusFilters = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
@ -20,11 +21,25 @@ const statusFilters = [
|
||||
type StatusFilter = (typeof statusFilters)[number]['value'];
|
||||
|
||||
export default function RemindersPage() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
|
||||
const location = useLocation();
|
||||
|
||||
// Panel state
|
||||
const [selectedReminderId, setSelectedReminderId] = useState<number | null>(null);
|
||||
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
|
||||
|
||||
const [filter, setFilter] = useState<StatusFilter>('active');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Handle navigation state from dashboard
|
||||
useEffect(() => {
|
||||
const state = location.state as { reminderId?: number } | null;
|
||||
if (state?.reminderId) {
|
||||
setSelectedReminderId(state.reminderId);
|
||||
setPanelMode('view');
|
||||
window.history.replaceState({}, '');
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
const { data: reminders = [], isLoading } = useQuery({
|
||||
queryKey: ['reminders'],
|
||||
queryFn: async () => {
|
||||
@ -50,16 +65,37 @@ export default function RemindersPage() {
|
||||
).length;
|
||||
const dismissedCount = reminders.filter((r) => r.is_dismissed).length;
|
||||
|
||||
const handleEdit = (reminder: Reminder) => {
|
||||
setEditingReminder(reminder);
|
||||
setShowForm(true);
|
||||
const panelOpen = panelMode !== 'closed';
|
||||
const selectedReminder = useMemo(
|
||||
() => reminders.find((r) => r.id === selectedReminderId) ?? null,
|
||||
[selectedReminderId, reminders],
|
||||
);
|
||||
|
||||
const handleSelect = (reminder: Reminder) => {
|
||||
setSelectedReminderId(reminder.id);
|
||||
setPanelMode('view');
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingReminder(null);
|
||||
const handleCreateNew = () => {
|
||||
setSelectedReminderId(null);
|
||||
setPanelMode('create');
|
||||
};
|
||||
|
||||
const handlePanelClose = () => {
|
||||
setPanelMode('closed');
|
||||
setSelectedReminderId(null);
|
||||
};
|
||||
|
||||
// Escape key closes panel
|
||||
useEffect(() => {
|
||||
if (!panelOpen) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handlePanelClose();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [panelOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Header */}
|
||||
@ -99,70 +135,111 @@ export default function RemindersPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setShowForm(true)} size="sm">
|
||||
<Button onClick={handleCreateNew} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Reminder
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
{/* Summary stats */}
|
||||
{!isLoading && reminders.length > 0 && (
|
||||
<div className="grid gap-2.5 grid-cols-3 mb-5">
|
||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-orange-500/10">
|
||||
<Bell className="h-4 w-4 text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
||||
Active
|
||||
</p>
|
||||
<p className="font-heading text-xl font-bold tabular-nums">{activeCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-red-500/10">
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
||||
Overdue
|
||||
</p>
|
||||
<p className="font-heading text-xl font-bold tabular-nums">{overdueCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-gray-500/10">
|
||||
<BellOff className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
||||
Dismissed
|
||||
</p>
|
||||
<p className="font-heading text-xl font-bold tabular-nums">{dismissedCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
{/* Main content — list + detail panel */}
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
<div
|
||||
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
|
||||
}`}
|
||||
>
|
||||
<div className="px-6 py-5">
|
||||
{/* Summary stats */}
|
||||
{!isLoading && reminders.length > 0 && (
|
||||
<div className="grid gap-2.5 grid-cols-3 mb-5">
|
||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-orange-500/10">
|
||||
<Bell className="h-4 w-4 text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
||||
Active
|
||||
</p>
|
||||
<p className="font-heading text-xl font-bold tabular-nums">{activeCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-red-500/10">
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
||||
Overdue
|
||||
</p>
|
||||
<p className="font-heading text-xl font-bold tabular-nums">{overdueCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-gray-500/10">
|
||||
<BellOff className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
||||
Dismissed
|
||||
</p>
|
||||
<p className="font-heading text-xl font-bold tabular-nums">{dismissedCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<ListSkeleton rows={6} />
|
||||
) : (
|
||||
<ReminderList
|
||||
reminders={filteredReminders}
|
||||
onEdit={handleEdit}
|
||||
onAdd={() => setShowForm(true)}
|
||||
{isLoading ? (
|
||||
<ListSkeleton rows={6} />
|
||||
) : (
|
||||
<ReminderList
|
||||
reminders={filteredReminders}
|
||||
onEdit={handleSelect}
|
||||
onAdd={handleCreateNew}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail panel (desktop) */}
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<ReminderDetailPanel
|
||||
reminder={panelMode === 'view' ? selectedReminder : null}
|
||||
isCreating={panelMode === 'create'}
|
||||
onClose={handlePanelClose}
|
||||
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showForm && <ReminderForm reminder={editingReminder} onClose={handleCloseForm} />}
|
||||
{/* Mobile detail panel overlay */}
|
||||
{panelOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||
onClick={handlePanelClose}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ReminderDetailPanel
|
||||
reminder={panelMode === 'view' ? selectedReminder : null}
|
||||
isCreating={panelMode === 'create'}
|
||||
onClose={handlePanelClose}
|
||||
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
539
frontend/src/components/todos/TodoDetailPanel.tsx
Normal file
539
frontend/src/components/todos/TodoDetailPanel.tsx
Normal file
@ -0,0 +1,539 @@
|
||||
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,
|
||||
} 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 { 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',
|
||||
};
|
||||
|
||||
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: '',
|
||||
due_time: '',
|
||||
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(false);
|
||||
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 ? (
|
||||
<>
|
||||
<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 ? (
|
||||
/* 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>
|
||||
<Input
|
||||
id="todo-due-date"
|
||||
type="date"
|
||||
value={editState.due_date}
|
||||
onChange={(e) => updateField('due_date', e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="todo-due-time">Due Time</Label>
|
||||
<Input
|
||||
id="todo-due-time"
|
||||
type="time"
|
||||
value={editState.due_time}
|
||||
onChange={(e) => updateField('due_time', e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* 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 */
|
||||
<>
|
||||
{/* Priority */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Priority</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Flag className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<Badge className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[todo!.priority] ?? ''}`}>
|
||||
{todo!.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
{todo!.category && (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Category</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<Badge className="text-[9px] px-1.5 py-0.5 bg-blue-500/15 text-blue-400">
|
||||
{todo!.category}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Due Date */}
|
||||
{dueDate && (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Due Date</p>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Due Time (if no date but has time) */}
|
||||
{!dueDate && todo!.due_time && (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Due Time</p>
|
||||
<CopyableField value={todo!.due_time.slice(0, 5)} icon={Clock} label="Due time" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurrence */}
|
||||
{todo!.recurrence_rule && (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Recurrence</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Repeat className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm">{recurrenceLabels[todo!.recurrence_rule] || todo!.recurrence_rule}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{todo!.description && (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Description</p>
|
||||
<p className="text-sm whitespace-pre-wrap text-muted-foreground leading-relaxed">
|
||||
{todo!.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completion status */}
|
||||
{todo!.completed && todo!.completed_at && (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Completed</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckSquare className="h-3.5 w-3.5 text-green-400 shrink-0" />
|
||||
<span className="text-sm text-green-400">
|
||||
{format(parseISO(todo!.completed_at), 'MMM d, yyyy · h:mm a')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset info for recurring */}
|
||||
{todo!.completed && todo!.recurrence_rule && todo!.reset_at && (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Resets</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Repeat className="h-3.5 w-3.5 text-purple-400 shrink-0" />
|
||||
<span 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')}`}
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -1,15 +1,17 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Plus, CheckSquare, CheckCircle2, AlertCircle, Search } from 'lucide-react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import type { Todo } from '@/types';
|
||||
import { isTodoOverdue } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { ListSkeleton } from '@/components/ui/skeleton';
|
||||
import { CategoryFilterBar } from '@/components/shared';
|
||||
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
|
||||
import TodoList from './TodoList';
|
||||
import TodoForm from './TodoForm';
|
||||
import TodoDetailPanel from './TodoDetailPanel';
|
||||
|
||||
const priorityFilters = [
|
||||
{ value: '', label: 'All' },
|
||||
@ -20,14 +22,27 @@ const priorityFilters = [
|
||||
] as const;
|
||||
|
||||
export default function TodosPage() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
|
||||
const [filters, setFilters] = useState({
|
||||
priority: '',
|
||||
category: '',
|
||||
showCompleted: true,
|
||||
search: '',
|
||||
});
|
||||
const location = useLocation();
|
||||
|
||||
// Panel state
|
||||
const [selectedTodoId, setSelectedTodoId] = useState<number | null>(null);
|
||||
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
|
||||
|
||||
// Filters
|
||||
const [priorityFilter, setPriorityFilter] = useState('');
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||
const [showCompleted, setShowCompleted] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Handle navigation state from dashboard
|
||||
useEffect(() => {
|
||||
const state = location.state as { todoId?: number } | null;
|
||||
if (state?.todoId) {
|
||||
setSelectedTodoId(state.todoId);
|
||||
setPanelMode('view');
|
||||
window.history.replaceState({}, '');
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
const { data: todos = [], isLoading } = useQuery({
|
||||
queryKey: ['todos'],
|
||||
@ -37,7 +52,7 @@ export default function TodosPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const allCategories = useMemo(() => {
|
||||
const cats = new Set<string>();
|
||||
todos.forEach((t) => {
|
||||
if (t.category) cats.add(t.category);
|
||||
@ -45,54 +60,92 @@ export default function TodosPage() {
|
||||
return Array.from(cats).sort();
|
||||
}, [todos]);
|
||||
|
||||
const { orderedCategories, reorder: reorderCategories } = useCategoryOrder('todos', allCategories);
|
||||
|
||||
const filteredTodos = useMemo(
|
||||
() =>
|
||||
todos.filter((todo) => {
|
||||
if (filters.priority && todo.priority !== filters.priority) return false;
|
||||
if (filters.category && todo.category?.toLowerCase() !== filters.category.toLowerCase())
|
||||
return false;
|
||||
if (!filters.showCompleted && todo.completed) return false;
|
||||
if (filters.search && !todo.title.toLowerCase().includes(filters.search.toLowerCase()))
|
||||
if (priorityFilter && todo.priority !== priorityFilter) return false;
|
||||
if (activeFilters.length > 0) {
|
||||
if (!todo.category || !activeFilters.includes(todo.category)) return false;
|
||||
}
|
||||
if (!showCompleted && todo.completed) return false;
|
||||
if (search && !todo.title.toLowerCase().includes(search.toLowerCase()))
|
||||
return false;
|
||||
return true;
|
||||
}),
|
||||
[todos, filters]
|
||||
[todos, priorityFilter, activeFilters, showCompleted, search]
|
||||
);
|
||||
|
||||
const totalCount = filteredTodos.filter((t) => !t.completed).length;
|
||||
const completedCount = filteredTodos.filter((t) => t.completed).length;
|
||||
const overdueCount = filteredTodos.filter((t) => isTodoOverdue(t.due_date, t.completed)).length;
|
||||
|
||||
const handleEdit = (todo: Todo) => {
|
||||
setEditingTodo(todo);
|
||||
setShowForm(true);
|
||||
const panelOpen = panelMode !== 'closed';
|
||||
const selectedTodo = useMemo(
|
||||
() => todos.find((t) => t.id === selectedTodoId) ?? null,
|
||||
[selectedTodoId, todos],
|
||||
);
|
||||
|
||||
const handleSelect = (todo: Todo) => {
|
||||
setSelectedTodoId(todo.id);
|
||||
setPanelMode('view');
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingTodo(null);
|
||||
const handleCreateNew = () => {
|
||||
setSelectedTodoId(null);
|
||||
setPanelMode('create');
|
||||
};
|
||||
|
||||
const handlePanelClose = () => {
|
||||
setPanelMode('closed');
|
||||
setSelectedTodoId(null);
|
||||
};
|
||||
|
||||
// CategoryFilterBar handlers
|
||||
const toggleAll = () => setActiveFilters([]);
|
||||
const toggleCompleted = () => setShowCompleted((p) => !p);
|
||||
const toggleCategory = (cat: string) => {
|
||||
setActiveFilters((prev) =>
|
||||
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
|
||||
);
|
||||
};
|
||||
const selectAllCategories = () => {
|
||||
const allSelected = orderedCategories.every((c) => activeFilters.includes(c));
|
||||
setActiveFilters(allSelected ? [] : [...orderedCategories]);
|
||||
};
|
||||
|
||||
// Escape key closes panel
|
||||
useEffect(() => {
|
||||
if (!panelOpen) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handlePanelClose();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [panelOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0 flex-wrap">
|
||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight">Todos</h1>
|
||||
|
||||
{/* Priority filter */}
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
|
||||
{priorityFilters.map((pf) => (
|
||||
<button
|
||||
key={pf.value}
|
||||
onClick={() => setFilters({ ...filters, priority: pf.value })}
|
||||
onClick={() => setPriorityFilter(pf.value)}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
|
||||
filters.priority === pf.value
|
||||
priorityFilter === pf.value
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
filters.priority === pf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||
color: filters.priority === pf.value ? 'hsl(var(--accent-color))' : undefined,
|
||||
priorityFilter === pf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||
color: priorityFilter === pf.value ? 'hsl(var(--accent-color))' : undefined,
|
||||
}}
|
||||
>
|
||||
{pf.label}
|
||||
@ -100,134 +153,128 @@ export default function TodosPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Category pills */}
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, category: '' })}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
|
||||
!filters.category
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: !filters.category ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||
color: !filters.category ? 'hsl(var(--accent-color))' : undefined,
|
||||
}}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setFilters({ ...filters, category: cat })}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
|
||||
filters.category === cat
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: filters.category === cat ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||
color: filters.category === cat ? 'hsl(var(--accent-color))' : undefined,
|
||||
}}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Completed toggle */}
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, showCompleted: !filters.showCompleted })}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-md border transition-colors duration-150 ${
|
||||
filters.showCompleted
|
||||
? 'border-accent/30 bg-accent/15 text-accent'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: filters.showCompleted ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||
color: filters.showCompleted ? 'hsl(var(--accent-color))' : undefined,
|
||||
borderColor: filters.showCompleted ? 'hsl(var(--accent-color) / 0.3)' : undefined,
|
||||
}}
|
||||
>
|
||||
Completed
|
||||
</button>
|
||||
{/* Category filter bar (All + Completed + Categories with drag) */}
|
||||
<CategoryFilterBar
|
||||
activeFilters={activeFilters}
|
||||
pinnedLabel="Completed"
|
||||
showPinned={showCompleted}
|
||||
categories={orderedCategories}
|
||||
onToggleAll={toggleAll}
|
||||
onTogglePinned={toggleCompleted}
|
||||
onToggleCategory={toggleCategory}
|
||||
onSelectAllCategories={selectAllCategories}
|
||||
onReorderCategories={reorderCategories}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
/>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-52 h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setShowForm(true)} size="sm">
|
||||
<Button onClick={handleCreateNew} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Todo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
{/* Summary stats */}
|
||||
{!isLoading && todos.length > 0 && (
|
||||
<div className="grid gap-2.5 grid-cols-3 mb-5">
|
||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-blue-500/10">
|
||||
<CheckSquare className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
||||
Open
|
||||
</p>
|
||||
<p className="font-heading text-xl font-bold tabular-nums">{totalCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-green-500/10">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
||||
Completed
|
||||
</p>
|
||||
<p className="font-heading text-xl font-bold tabular-nums">{completedCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-red-500/10">
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
||||
Overdue
|
||||
</p>
|
||||
<p className="font-heading text-xl font-bold tabular-nums">{overdueCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
{/* Main content — list + detail panel */}
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
<div
|
||||
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
|
||||
}`}
|
||||
>
|
||||
<div className="px-6 py-5">
|
||||
{/* Summary stats */}
|
||||
{!isLoading && todos.length > 0 && (
|
||||
<div className="grid gap-2.5 grid-cols-3 mb-5">
|
||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-blue-500/10">
|
||||
<CheckSquare className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
||||
Open
|
||||
</p>
|
||||
<p className="font-heading text-xl font-bold tabular-nums">{totalCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-green-500/10">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
||||
Completed
|
||||
</p>
|
||||
<p className="font-heading text-xl font-bold tabular-nums">{completedCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-red-500/10">
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
||||
Overdue
|
||||
</p>
|
||||
<p className="font-heading text-xl font-bold tabular-nums">{overdueCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<ListSkeleton rows={6} />
|
||||
) : (
|
||||
<TodoList
|
||||
todos={filteredTodos}
|
||||
onEdit={handleEdit}
|
||||
onAdd={() => setShowForm(true)}
|
||||
{isLoading ? (
|
||||
<ListSkeleton rows={6} />
|
||||
) : (
|
||||
<TodoList
|
||||
todos={filteredTodos}
|
||||
onEdit={handleSelect}
|
||||
onAdd={handleCreateNew}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail panel (desktop) */}
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<TodoDetailPanel
|
||||
todo={panelMode === 'view' ? selectedTodo : null}
|
||||
isCreating={panelMode === 'create'}
|
||||
onClose={handlePanelClose}
|
||||
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showForm && <TodoForm todo={editingTodo} onClose={handleCloseForm} />}
|
||||
{/* Mobile detail panel overlay */}
|
||||
{panelOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||
onClick={handlePanelClose}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TodoDetailPanel
|
||||
todo={panelMode === 'view' ? selectedTodo : null}
|
||||
isCreating={panelMode === 'create'}
|
||||
onClose={handlePanelClose}
|
||||
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user