Add Sheet forms, recurrence UI, all-day fix, LocationPicker
- Sheet component: slide-in panel replacing Dialog for all forms - EventForm: structured recurrence picker, all-day end-date offset fix, LocationPicker with OSM search integration - CalendarPage: scope dialog for editing/deleting recurring events - TodoForm/ReminderForm/LocationForm: migrated to Sheet with 2-col layouts - LocationPicker: debounced search combining local DB + Nominatim results - Backend: /locations/search endpoint with OSM proxy - CSS: slimmer all-day event bars in calendar grid - Types: RecurrenceRule interface, extended CalendarEvent fields Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d89758fedf
commit
d811890509
@ -1,17 +1,84 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, or_
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
import logging
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.location import Location
|
from app.models.location import Location
|
||||||
from app.schemas.location import LocationCreate, LocationUpdate, LocationResponse
|
from app.schemas.location import LocationCreate, LocationUpdate, LocationResponse, LocationSearchResult
|
||||||
from app.routers.auth import get_current_session
|
from app.routers.auth import get_current_session
|
||||||
from app.models.settings import Settings
|
from app.models.settings import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_model=List[LocationSearchResult])
|
||||||
|
async def search_locations(
|
||||||
|
q: str = Query(..., min_length=1),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session),
|
||||||
|
):
|
||||||
|
"""Search locations from local DB and Nominatim OSM."""
|
||||||
|
results: List[LocationSearchResult] = []
|
||||||
|
|
||||||
|
# Local DB search
|
||||||
|
local_query = (
|
||||||
|
select(Location)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
Location.name.ilike(f"%{q}%"),
|
||||||
|
Location.address.ilike(f"%{q}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(5)
|
||||||
|
)
|
||||||
|
local_result = await db.execute(local_query)
|
||||||
|
local_locations = local_result.scalars().all()
|
||||||
|
|
||||||
|
for loc in local_locations:
|
||||||
|
results.append(
|
||||||
|
LocationSearchResult(
|
||||||
|
source="local",
|
||||||
|
location_id=loc.id,
|
||||||
|
name=loc.name,
|
||||||
|
address=loc.address,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nominatim proxy search
|
||||||
|
try:
|
||||||
|
encoded_q = urllib.parse.quote(q)
|
||||||
|
url = f"https://nominatim.openstreetmap.org/search?q={encoded_q}&format=json&limit=5"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={"User-Agent": "UMBRA-LifeManager/1.0"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
osm_data = json.loads(resp.read().decode())
|
||||||
|
for item in osm_data:
|
||||||
|
display_name = item.get("display_name", "")
|
||||||
|
name_parts = display_name.split(",", 1)
|
||||||
|
name = name_parts[0].strip()
|
||||||
|
address = name_parts[1].strip() if len(name_parts) > 1 else display_name
|
||||||
|
results.append(
|
||||||
|
LocationSearchResult(
|
||||||
|
source="nominatim",
|
||||||
|
name=name,
|
||||||
|
address=address,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Nominatim search failed: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[LocationResponse])
|
@router.get("/", response_model=List[LocationResponse])
|
||||||
async def get_locations(
|
async def get_locations(
|
||||||
category: Optional[str] = Query(None),
|
category: Optional[str] = Query(None),
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional, Literal
|
||||||
|
|
||||||
|
|
||||||
|
class LocationSearchResult(BaseModel):
|
||||||
|
source: Literal["local", "nominatim"]
|
||||||
|
location_id: Optional[int] = None
|
||||||
|
name: str
|
||||||
|
address: str
|
||||||
|
|
||||||
|
|
||||||
class LocationCreate(BaseModel):
|
class LocationCreate(BaseModel):
|
||||||
|
|||||||
@ -11,6 +11,13 @@ import api, { getErrorMessage } from '@/lib/api';
|
|||||||
import type { CalendarEvent } from '@/types';
|
import type { CalendarEvent } from '@/types';
|
||||||
import { useCalendars } from '@/hooks/useCalendars';
|
import { useCalendars } from '@/hooks/useCalendars';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import CalendarSidebar from './CalendarSidebar';
|
import CalendarSidebar from './CalendarSidebar';
|
||||||
import EventForm from './EventForm';
|
import EventForm from './EventForm';
|
||||||
|
|
||||||
@ -22,6 +29,8 @@ const viewLabels: Record<CalendarView, string> = {
|
|||||||
timeGridDay: 'Day',
|
timeGridDay: 'Day',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ScopeAction = 'edit' | 'delete';
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const calendarRef = useRef<FullCalendar>(null);
|
const calendarRef = useRef<FullCalendar>(null);
|
||||||
@ -33,6 +42,11 @@ export default function CalendarPage() {
|
|||||||
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
|
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
|
||||||
const [calendarTitle, setCalendarTitle] = useState('');
|
const [calendarTitle, setCalendarTitle] = useState('');
|
||||||
|
|
||||||
|
// Scope dialog state
|
||||||
|
const [scopeDialogOpen, setScopeDialogOpen] = useState(false);
|
||||||
|
const [scopeAction, setScopeAction] = useState<ScopeAction>('edit');
|
||||||
|
const [scopeEvent, setScopeEvent] = useState<CalendarEvent | null>(null);
|
||||||
|
|
||||||
const { data: calendars = [] } = useCalendars();
|
const { data: calendars = [] } = useCalendars();
|
||||||
|
|
||||||
const { data: events = [] } = useQuery({
|
const { data: events = [] } = useQuery({
|
||||||
@ -101,6 +115,23 @@ 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);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to delete event'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const filteredEvents = useMemo(() => {
|
const filteredEvents = useMemo(() => {
|
||||||
if (calendars.length === 0) return events;
|
if (calendars.length === 0) return events;
|
||||||
return events.filter((e) => visibleCalendarIds.has(e.calendar_id));
|
return events.filter((e) => visibleCalendarIds.has(e.calendar_id));
|
||||||
@ -116,9 +147,15 @@ export default function CalendarPage() {
|
|||||||
borderColor: event.calendar_color || 'hsl(var(--accent-color))',
|
borderColor: event.calendar_color || 'hsl(var(--accent-color))',
|
||||||
extendedProps: {
|
extendedProps: {
|
||||||
is_virtual: event.is_virtual,
|
is_virtual: event.is_virtual,
|
||||||
|
is_recurring: event.is_recurring,
|
||||||
|
parent_event_id: event.parent_event_id,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const isRecurring = (event: CalendarEvent): boolean => {
|
||||||
|
return !!(event.is_recurring || event.parent_event_id);
|
||||||
|
};
|
||||||
|
|
||||||
const handleEventClick = (info: EventClickArg) => {
|
const handleEventClick = (info: EventClickArg) => {
|
||||||
const event = events.find((e) => String(e.id) === info.event.id);
|
const event = events.find((e) => String(e.id) === info.event.id);
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
@ -126,8 +163,27 @@ export default function CalendarPage() {
|
|||||||
toast.info(`${event.title} — from People contacts`);
|
toast.info(`${event.title} — from People contacts`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRecurring(event)) {
|
||||||
|
setScopeEvent(event);
|
||||||
|
setScopeAction('edit');
|
||||||
|
setScopeDialogOpen(true);
|
||||||
|
} else {
|
||||||
setEditingEvent(event);
|
setEditingEvent(event);
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScopeChoice = (scope: 'this' | 'this_and_future') => {
|
||||||
|
if (!scopeEvent) return;
|
||||||
|
if (scopeAction === 'edit') {
|
||||||
|
// For edits, open form — the form will send scope on save
|
||||||
|
setEditingEvent({ ...scopeEvent, _editScope: scope } as any);
|
||||||
|
setShowForm(true);
|
||||||
|
setScopeDialogOpen(false);
|
||||||
|
} else if (scopeAction === 'delete') {
|
||||||
|
scopeDeleteMutation.mutate({ id: scopeEvent.id as number, scope });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEventDrop = (info: EventDropArg) => {
|
const handleEventDrop = (info: EventDropArg) => {
|
||||||
@ -270,6 +326,46 @@ export default function CalendarPage() {
|
|||||||
onClose={handleCloseForm}
|
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>
|
||||||
|
<DialogFooter className="flex-col gap-2 sm:flex-col">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => handleScopeChoice('this')}
|
||||||
|
>
|
||||||
|
This event only
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => handleScopeChoice('this_and_future')}
|
||||||
|
>
|
||||||
|
This and all future events
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
setScopeDialogOpen(false);
|
||||||
|
setScopeEvent(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,22 +2,23 @@ import { useState, FormEvent } from 'react';
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import type { CalendarEvent, Location } from '@/types';
|
import type { CalendarEvent, Location, RecurrenceRule } from '@/types';
|
||||||
import { useCalendars } from '@/hooks/useCalendars';
|
import { useCalendars } from '@/hooks/useCalendars';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Sheet,
|
||||||
DialogContent,
|
SheetContent,
|
||||||
DialogHeader,
|
SheetHeader,
|
||||||
DialogTitle,
|
SheetTitle,
|
||||||
DialogFooter,
|
SheetFooter,
|
||||||
DialogClose,
|
SheetClose,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/sheet';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import LocationPicker from '@/components/ui/location-picker';
|
||||||
|
|
||||||
interface EventFormProps {
|
interface EventFormProps {
|
||||||
event: CalendarEvent | null;
|
event: CalendarEvent | null;
|
||||||
@ -43,6 +44,36 @@ function formatForInput(dt: string, allDay: boolean, fallbackTime: string = '09:
|
|||||||
return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime);
|
return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** FullCalendar uses exclusive end dates for all-day events. Subtract 1 day for display. */
|
||||||
|
function adjustAllDayEndForDisplay(dateStr: string): string {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const d = new Date(dateStr.split('T')[0] + 'T12:00:00');
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add 1 day to form end date before sending to API for all-day events. */
|
||||||
|
function adjustAllDayEndForSave(dateStr: string): string {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const d = new Date(dateStr + 'T12:00:00');
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Python weekday: 0=Monday, 6=Sunday
|
||||||
|
const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||||
|
|
||||||
|
function parseRecurrenceRule(raw?: string): RecurrenceRule | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function EventForm({ event, initialStart, initialEnd, initialAllDay, onClose }: EventFormProps) {
|
export default function EventForm({ event, initialStart, initialEnd, initialAllDay, onClose }: EventFormProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: calendars = [] } = useCalendars();
|
const { data: calendars = [] } = useCalendars();
|
||||||
@ -53,18 +84,27 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
|
|||||||
const defaultCalendar = calendars.find((c) => c.is_default);
|
const defaultCalendar = calendars.find((c) => c.is_default);
|
||||||
const initialCalendarId = event?.calendar_id?.toString() || defaultCalendar?.id?.toString() || '';
|
const initialCalendarId = event?.calendar_id?.toString() || defaultCalendar?.id?.toString() || '';
|
||||||
|
|
||||||
|
// For all-day events, adjust end date for display (FullCalendar exclusive end)
|
||||||
|
const displayEnd = isAllDay ? adjustAllDayEndForDisplay(rawEnd) : rawEnd;
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: event?.title || '',
|
title: event?.title || '',
|
||||||
description: event?.description || '',
|
description: event?.description || '',
|
||||||
start_datetime: formatForInput(rawStart, isAllDay, '09:00'),
|
start_datetime: formatForInput(rawStart, isAllDay, '09:00'),
|
||||||
end_datetime: formatForInput(rawEnd, isAllDay, '10:00'),
|
end_datetime: formatForInput(displayEnd, isAllDay, '10:00'),
|
||||||
all_day: isAllDay,
|
all_day: isAllDay,
|
||||||
location_id: event?.location_id?.toString() || '',
|
location_id: event?.location_id?.toString() || '',
|
||||||
calendar_id: initialCalendarId,
|
calendar_id: initialCalendarId,
|
||||||
recurrence_rule: event?.recurrence_rule || '',
|
|
||||||
is_starred: event?.is_starred || false,
|
is_starred: event?.is_starred || false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const existingRule = parseRecurrenceRule(event?.recurrence_rule);
|
||||||
|
const [recurrenceType, setRecurrenceType] = useState<string>(existingRule?.type || '');
|
||||||
|
const [recurrenceInterval, setRecurrenceInterval] = useState(existingRule?.interval || 2);
|
||||||
|
const [recurrenceWeekday, setRecurrenceWeekday] = useState(existingRule?.weekday ?? 1);
|
||||||
|
const [recurrenceWeek, setRecurrenceWeek] = useState(existingRule?.week || 1);
|
||||||
|
const [recurrenceDay, setRecurrenceDay] = useState(existingRule?.day || 1);
|
||||||
|
|
||||||
const { data: locations = [] } = useQuery({
|
const { data: locations = [] } = useQuery({
|
||||||
queryKey: ['locations'],
|
queryKey: ['locations'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@ -73,16 +113,50 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter out system calendars (Birthdays) from the dropdown
|
// Location picker state
|
||||||
|
const existingLocation = locations.find((l) => l.id === event?.location_id);
|
||||||
|
const [locationSearch, setLocationSearch] = useState(existingLocation?.name || '');
|
||||||
|
|
||||||
const selectableCalendars = calendars.filter((c) => !c.is_system);
|
const selectableCalendars = calendars.filter((c) => !c.is_system);
|
||||||
|
|
||||||
|
const buildRecurrenceRule = (): RecurrenceRule | null => {
|
||||||
|
if (!recurrenceType) return null;
|
||||||
|
switch (recurrenceType) {
|
||||||
|
case 'every_n_days':
|
||||||
|
return { type: 'every_n_days', interval: recurrenceInterval };
|
||||||
|
case 'weekly':
|
||||||
|
return { type: 'weekly', weekday: recurrenceWeekday };
|
||||||
|
case 'monthly_nth_weekday':
|
||||||
|
return { type: 'monthly_nth_weekday', week: recurrenceWeek, weekday: recurrenceWeekday };
|
||||||
|
case 'monthly_date':
|
||||||
|
return { type: 'monthly_date', day: recurrenceDay };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (data: typeof formData) => {
|
mutationFn: async (data: typeof formData) => {
|
||||||
|
const rule = buildRecurrenceRule();
|
||||||
|
|
||||||
|
// Adjust end date for all-day events before save
|
||||||
|
let endDt = data.end_datetime;
|
||||||
|
if (data.all_day && endDt) {
|
||||||
|
endDt = adjustAllDayEndForSave(endDt);
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...data,
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
start_datetime: data.start_datetime,
|
||||||
|
end_datetime: endDt,
|
||||||
|
all_day: data.all_day,
|
||||||
location_id: data.location_id ? parseInt(data.location_id) : null,
|
location_id: data.location_id ? parseInt(data.location_id) : null,
|
||||||
calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null,
|
calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null,
|
||||||
|
is_starred: data.is_starred,
|
||||||
|
recurrence_rule: rule,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (event) {
|
if (event) {
|
||||||
const response = await api.put(`/events/${event.id}`, payload);
|
const response = await api.put(`/events/${event.id}`, payload);
|
||||||
return response.data;
|
return response.data;
|
||||||
@ -125,13 +199,14 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
<Sheet open={true} onOpenChange={onClose}>
|
||||||
<DialogContent>
|
<SheetContent>
|
||||||
<DialogClose onClick={onClose} />
|
<SheetClose onClick={onClose} />
|
||||||
<DialogHeader>
|
<SheetHeader>
|
||||||
<DialogTitle>{event ? 'Edit Event' : 'New Event'}</DialogTitle>
|
<SheetTitle>{event ? 'Edit Event' : 'New Event'}</SheetTitle>
|
||||||
</DialogHeader>
|
</SheetHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
|
||||||
|
<div className="px-6 py-5 space-y-4 flex-1">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="title">Title</Label>
|
<Label htmlFor="title">Title</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -148,7 +223,7 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
|
|||||||
id="description"
|
id="description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
rows={2}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -192,6 +267,7 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="calendar">Calendar</Label>
|
<Label htmlFor="calendar">Calendar</Label>
|
||||||
<Select
|
<Select
|
||||||
@ -209,33 +285,124 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="location">Location</Label>
|
<Label htmlFor="location">Location</Label>
|
||||||
<Select
|
<LocationPicker
|
||||||
id="location"
|
id="location"
|
||||||
value={formData.location_id}
|
value={locationSearch}
|
||||||
onChange={(e) => setFormData({ ...formData, location_id: e.target.value })}
|
onChange={(val) => {
|
||||||
|
setLocationSearch(val);
|
||||||
|
if (!val) setFormData({ ...formData, location_id: '' });
|
||||||
|
}}
|
||||||
|
onSelect={async (result) => {
|
||||||
|
if (result.source === 'local' && result.location_id) {
|
||||||
|
setFormData({ ...formData, location_id: result.location_id.toString() });
|
||||||
|
} else if (result.source === 'nominatim') {
|
||||||
|
try {
|
||||||
|
const { data: newLoc } = await api.post('/locations', {
|
||||||
|
name: result.name,
|
||||||
|
address: result.address,
|
||||||
|
category: 'other',
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['locations'] });
|
||||||
|
setFormData({ ...formData, location_id: newLoc.id.toString() });
|
||||||
|
toast.success(`Location "${result.name}" created`);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to create location');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Search for a location..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recurrence picker */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="recurrence_type">Recurrence</Label>
|
||||||
|
<Select
|
||||||
|
id="recurrence_type"
|
||||||
|
value={recurrenceType}
|
||||||
|
onChange={(e) => setRecurrenceType(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{locations.map((loc) => (
|
<option value="every_n_days">Every X days</option>
|
||||||
<option key={loc.id} value={loc.id}>
|
<option value="weekly">Weekly</option>
|
||||||
{loc.name}
|
<option value="monthly_nth_weekday">Monthly (nth weekday)</option>
|
||||||
</option>
|
<option value="monthly_date">Monthly (date)</option>
|
||||||
))}
|
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{recurrenceType === 'every_n_days' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="recurrence">Recurrence</Label>
|
<Label htmlFor="interval">Every how many days?</Label>
|
||||||
|
<Input
|
||||||
|
id="interval"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
value={recurrenceInterval}
|
||||||
|
onChange={(e) => setRecurrenceInterval(parseInt(e.target.value) || 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recurrenceType === 'weekly' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="weekday">Day of week</Label>
|
||||||
<Select
|
<Select
|
||||||
id="recurrence"
|
id="weekday"
|
||||||
value={formData.recurrence_rule}
|
value={recurrenceWeekday.toString()}
|
||||||
onChange={(e) => setFormData({ ...formData, recurrence_rule: e.target.value })}
|
onChange={(e) => setRecurrenceWeekday(parseInt(e.target.value))}
|
||||||
>
|
>
|
||||||
<option value="">None</option>
|
{WEEKDAYS.map((name, i) => (
|
||||||
<option value="daily">Daily</option>
|
<option key={i} value={i}>{name}</option>
|
||||||
<option value="weekly">Weekly</option>
|
))}
|
||||||
<option value="monthly">Monthly</option>
|
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recurrenceType === 'monthly_nth_weekday' && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="week">Week of month</Label>
|
||||||
|
<Select
|
||||||
|
id="week"
|
||||||
|
value={recurrenceWeek.toString()}
|
||||||
|
onChange={(e) => setRecurrenceWeek(parseInt(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="1">1st</option>
|
||||||
|
<option value="2">2nd</option>
|
||||||
|
<option value="3">3rd</option>
|
||||||
|
<option value="4">4th</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="weekday_nth">Day of week</Label>
|
||||||
|
<Select
|
||||||
|
id="weekday_nth"
|
||||||
|
value={recurrenceWeekday.toString()}
|
||||||
|
onChange={(e) => setRecurrenceWeekday(parseInt(e.target.value))}
|
||||||
|
>
|
||||||
|
{WEEKDAYS.map((name, i) => (
|
||||||
|
<option key={i} value={i}>{name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recurrenceType === 'monthly_date' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="day">Day of month</Label>
|
||||||
|
<Input
|
||||||
|
id="day"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={31}
|
||||||
|
value={recurrenceDay}
|
||||||
|
onChange={(e) => setRecurrenceDay(parseInt(e.target.value) || 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@ -245,8 +412,9 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
|
|||||||
/>
|
/>
|
||||||
<Label htmlFor="is_starred">Star this event</Label>
|
<Label htmlFor="is_starred">Star this event</Label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<SheetFooter>
|
||||||
{event && (
|
{event && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -264,9 +432,9 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
|
|||||||
<Button type="submit" disabled={mutation.isPending}>
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
{mutation.isPending ? 'Saving...' : event ? 'Update' : 'Create'}
|
{mutation.isPending ? 'Saving...' : event ? 'Update' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</SheetFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</SheetContent>
|
||||||
</Dialog>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,18 +4,19 @@ import { toast } from 'sonner';
|
|||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import type { Location } from '@/types';
|
import type { Location } from '@/types';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Sheet,
|
||||||
DialogContent,
|
SheetContent,
|
||||||
DialogHeader,
|
SheetHeader,
|
||||||
DialogTitle,
|
SheetTitle,
|
||||||
DialogFooter,
|
SheetFooter,
|
||||||
DialogClose,
|
SheetClose,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/sheet';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import LocationPicker from '@/components/ui/location-picker';
|
||||||
|
|
||||||
interface LocationFormProps {
|
interface LocationFormProps {
|
||||||
location: Location | null;
|
location: Location | null;
|
||||||
@ -57,13 +58,14 @@ export default function LocationForm({ location, onClose }: LocationFormProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
<Sheet open={true} onOpenChange={onClose}>
|
||||||
<DialogContent>
|
<SheetContent>
|
||||||
<DialogClose onClick={onClose} />
|
<SheetClose onClick={onClose} />
|
||||||
<DialogHeader>
|
<SheetHeader>
|
||||||
<DialogTitle>{location ? 'Edit Location' : 'New Location'}</DialogTitle>
|
<SheetTitle>{location ? 'Edit Location' : 'New Location'}</SheetTitle>
|
||||||
</DialogHeader>
|
</SheetHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
|
||||||
|
<div className="px-6 py-5 space-y-4 flex-1">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor="name">Name</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -76,11 +78,18 @@ export default function LocationForm({ location, onClose }: LocationFormProps) {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="address">Address</Label>
|
<Label htmlFor="address">Address</Label>
|
||||||
<Input
|
<LocationPicker
|
||||||
id="address"
|
id="address"
|
||||||
value={formData.address}
|
value={formData.address}
|
||||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
onChange={(val) => setFormData({ ...formData, address: val })}
|
||||||
required
|
onSelect={(result) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
name: formData.name || result.name,
|
||||||
|
address: result.address,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="Search or type an address..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -105,20 +114,21 @@ export default function LocationForm({ location, onClose }: LocationFormProps) {
|
|||||||
id="notes"
|
id="notes"
|
||||||
value={formData.notes}
|
value={formData.notes}
|
||||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||||
rows={3}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<SheetFooter>
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
{mutation.isPending ? 'Saving...' : location ? 'Update' : 'Create'}
|
{mutation.isPending ? 'Saving...' : location ? 'Update' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</SheetFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</SheetContent>
|
||||||
</Dialog>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,13 +4,13 @@ import { toast } from 'sonner';
|
|||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import type { Reminder } from '@/types';
|
import type { Reminder } from '@/types';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Sheet,
|
||||||
DialogContent,
|
SheetContent,
|
||||||
DialogHeader,
|
SheetHeader,
|
||||||
DialogTitle,
|
SheetTitle,
|
||||||
DialogFooter,
|
SheetFooter,
|
||||||
DialogClose,
|
SheetClose,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/sheet';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
@ -59,13 +59,14 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
<Sheet open={true} onOpenChange={onClose}>
|
||||||
<DialogContent>
|
<SheetContent>
|
||||||
<DialogClose onClick={onClose} />
|
<SheetClose onClick={onClose} />
|
||||||
<DialogHeader>
|
<SheetHeader>
|
||||||
<DialogTitle>{reminder ? 'Edit Reminder' : 'New Reminder'}</DialogTitle>
|
<SheetTitle>{reminder ? 'Edit Reminder' : 'New Reminder'}</SheetTitle>
|
||||||
</DialogHeader>
|
</SheetHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
|
||||||
|
<div className="px-6 py-5 space-y-4 flex-1">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="title">Title</Label>
|
<Label htmlFor="title">Title</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -82,10 +83,11 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
|
|||||||
id="description"
|
id="description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
rows={3}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="remind_at">Remind At</Label>
|
<Label htmlFor="remind_at">Remind At</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -110,17 +112,19 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
|
|||||||
<option value="monthly">Monthly</option>
|
<option value="monthly">Monthly</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<SheetFooter>
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
{mutation.isPending ? 'Saving...' : reminder ? 'Update' : 'Create'}
|
{mutation.isPending ? 'Saving...' : reminder ? 'Update' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</SheetFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</SheetContent>
|
||||||
</Dialog>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,13 +4,13 @@ import { toast } from 'sonner';
|
|||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import type { Todo } from '@/types';
|
import type { Todo } from '@/types';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Sheet,
|
||||||
DialogContent,
|
SheetContent,
|
||||||
DialogHeader,
|
SheetHeader,
|
||||||
DialogTitle,
|
SheetTitle,
|
||||||
DialogFooter,
|
SheetFooter,
|
||||||
DialogClose,
|
SheetClose,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/sheet';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
@ -61,13 +61,14 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
<Sheet open={true} onOpenChange={onClose}>
|
||||||
<DialogContent>
|
<SheetContent>
|
||||||
<DialogClose onClick={onClose} />
|
<SheetClose onClick={onClose} />
|
||||||
<DialogHeader>
|
<SheetHeader>
|
||||||
<DialogTitle>{todo ? 'Edit Todo' : 'New Todo'}</DialogTitle>
|
<SheetTitle>{todo ? 'Edit Todo' : 'New Todo'}</SheetTitle>
|
||||||
</DialogHeader>
|
</SheetHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
|
||||||
|
<div className="px-6 py-5 space-y-4 flex-1">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="title">Title</Label>
|
<Label htmlFor="title">Title</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -84,7 +85,7 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
|
|||||||
id="description"
|
id="description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
rows={3}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -113,13 +114,14 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="category">Category</Label>
|
<Label htmlFor="category">Category</Label>
|
||||||
<Input
|
<Input
|
||||||
id="category"
|
id="category"
|
||||||
value={formData.category}
|
value={formData.category}
|
||||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||||
placeholder="e.g., Work, Personal, Shopping"
|
placeholder="e.g., Work, Personal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -136,17 +138,19 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
|
|||||||
<option value="monthly">Monthly</option>
|
<option value="monthly">Monthly</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<SheetFooter>
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
{mutation.isPending ? 'Saving...' : todo ? 'Update' : 'Create'}
|
{mutation.isPending ? 'Saving...' : todo ? 'Update' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</SheetFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</SheetContent>
|
||||||
</Dialog>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
139
frontend/src/components/ui/location-picker.tsx
Normal file
139
frontend/src/components/ui/location-picker.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { MapPin, Globe } from 'lucide-react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
interface LocationSearchResult {
|
||||||
|
source: 'local' | 'nominatim';
|
||||||
|
location_id?: number | null;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationPickerProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSelect: (result: LocationSearchResult) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LocationPicker({ value, onChange, onSelect, placeholder = 'Search locations...', id }: LocationPickerProps) {
|
||||||
|
const [results, setResults] = useState<LocationSearchResult[]>([]);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
|
||||||
|
if (!value || value.length < 2) {
|
||||||
|
setResults([]);
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<LocationSearchResult[]>('/locations/search', {
|
||||||
|
params: { q: value },
|
||||||
|
});
|
||||||
|
setResults(data);
|
||||||
|
setIsOpen(data.length > 0);
|
||||||
|
} catch {
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (result: LocationSearchResult) => {
|
||||||
|
onChange(result.name);
|
||||||
|
onSelect(result);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const localResults = results.filter((r) => r.source === 'local');
|
||||||
|
const nominatimResults = results.filter((r) => r.source === 'nominatim');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onFocus={() => results.length > 0 && setIsOpen(true)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<div className="h-3 w-3 animate-spin rounded-full border border-muted-foreground border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-md border bg-card shadow-lg max-h-60 overflow-y-auto">
|
||||||
|
{localResults.length > 0 && (
|
||||||
|
<>
|
||||||
|
{localResults.map((r, i) => (
|
||||||
|
<button
|
||||||
|
key={`local-${i}`}
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left hover:bg-card-elevated transition-colors"
|
||||||
|
onClick={() => handleSelect(r)}
|
||||||
|
>
|
||||||
|
<MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">{r.name}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{r.address}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{localResults.length > 0 && nominatimResults.length > 0 && (
|
||||||
|
<div className="border-t my-1" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nominatimResults.length > 0 && (
|
||||||
|
<>
|
||||||
|
{nominatimResults.map((r, i) => (
|
||||||
|
<button
|
||||||
|
key={`osm-${i}`}
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left hover:bg-card-elevated transition-colors"
|
||||||
|
onClick={() => handleSelect(r)}
|
||||||
|
>
|
||||||
|
<Globe className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">{r.name}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{r.address}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
frontend/src/components/ui/sheet.tsx
Normal file
128
frontend/src/components/ui/sheet.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface SheetProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Sheet: React.FC<SheetProps> = ({ open, onOpenChange, children }) => {
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
const [visible, setVisible] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMounted(true);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => setVisible(true));
|
||||||
|
});
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
setVisible(false);
|
||||||
|
const timer = setTimeout(() => setMounted(false), 250);
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onOpenChange(false);
|
||||||
|
};
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity duration-250',
|
||||||
|
visible ? 'opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed right-0 top-0 h-full w-full max-w-[540px] transition-transform duration-250 ease-out',
|
||||||
|
visible ? 'translate-x-0' : 'translate-x-full'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col h-full border-l bg-card overflow-y-auto',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
SheetContent.displayName = 'SheetContent';
|
||||||
|
|
||||||
|
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col space-y-1.5 px-6 py-5 border-b shrink-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
SheetHeader.displayName = 'SheetHeader';
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h2
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold leading-none tracking-tight font-heading', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
SheetTitle.displayName = 'SheetTitle';
|
||||||
|
|
||||||
|
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 px-6 py-4 border-t shrink-0 mt-auto',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
SheetFooter.displayName = 'SheetFooter';
|
||||||
|
|
||||||
|
const SheetClose = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none z-10',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
SheetClose.displayName = 'SheetClose';
|
||||||
|
|
||||||
|
export { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter, SheetClose };
|
||||||
@ -174,7 +174,8 @@
|
|||||||
.fc .fc-daygrid-event {
|
.fc .fc-daygrid-event {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 1px 4px;
|
padding: 0px 4px;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc .fc-timegrid-event {
|
.fc .fc-timegrid-event {
|
||||||
|
|||||||
@ -44,6 +44,14 @@ export interface Calendar {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecurrenceRule {
|
||||||
|
type: 'every_n_days' | 'weekly' | 'monthly_nth_weekday' | 'monthly_date';
|
||||||
|
interval?: number;
|
||||||
|
weekday?: number;
|
||||||
|
week?: number;
|
||||||
|
day?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CalendarEvent {
|
export interface CalendarEvent {
|
||||||
id: number | string;
|
id: number | string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -59,6 +67,9 @@ export interface CalendarEvent {
|
|||||||
is_virtual?: boolean;
|
is_virtual?: boolean;
|
||||||
recurrence_rule?: string;
|
recurrence_rule?: string;
|
||||||
is_starred?: boolean;
|
is_starred?: boolean;
|
||||||
|
parent_event_id?: number | null;
|
||||||
|
is_recurring?: boolean;
|
||||||
|
original_start?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user