Calendar enhancements: scroll navigation, birthday color editing, event templates

- Add wheel scroll navigation in month view (debounced, prevents rapid scrolling)
- Allow editing color on system calendars (Birthdays) - name field disabled
- Event templates: full CRUD backend (model, schema, router, migration 011)
- Event templates: sidebar section with create/edit/delete, click to pre-fill EventForm
- Register event_templates router at /api/event-templates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-22 17:34:16 +08:00
parent a11fcbcbcc
commit 80f3f3ed10
10 changed files with 533 additions and 32 deletions

View File

@ -0,0 +1,40 @@
"""add event_templates table
Revision ID: 011
Revises: 010
Create Date: 2026-02-22
"""
from alembic import op
import sqlalchemy as sa
revision = "011"
down_revision = "010"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"event_templates",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("duration_minutes", sa.Integer(), nullable=False, server_default="60"),
sa.Column("calendar_id", sa.Integer(), nullable=True),
sa.Column("recurrence_rule", sa.Text(), nullable=True),
sa.Column("all_day", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("location_id", sa.Integer(), nullable=True),
sa.Column("is_starred", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["calendar_id"], ["calendars.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["location_id"], ["locations.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_event_templates_id", "event_templates", ["id"])
def downgrade() -> None:
op.drop_index("ix_event_templates_id", table_name="event_templates")
op.drop_table("event_templates")

View File

@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from app.database import engine from app.database import engine
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
@asynccontextmanager @asynccontextmanager
@ -40,6 +40,7 @@ app.include_router(locations.router, prefix="/api/locations", tags=["Locations"]
app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"])
app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"]) app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"])
app.include_router(weather.router, prefix="/api/weather", tags=["Weather"]) app.include_router(weather.router, prefix="/api/weather", tags=["Weather"])
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
@app.get("/") @app.get("/")

View File

@ -0,0 +1,25 @@
from sqlalchemy import String, Text, Integer, Boolean, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from typing import Optional
from app.database import Base
class EventTemplate(Base):
__tablename__ = "event_templates"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
duration_minutes: Mapped[int] = mapped_column(Integer, default=60)
calendar_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("calendars.id", ondelete="SET NULL"), nullable=True
)
recurrence_rule: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
location_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
)
is_starred: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(default=datetime.now)

View File

@ -0,0 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.routers.auth import get_current_session
from app.models.event_template import EventTemplate
from app.schemas.event_template import (
EventTemplateCreate,
EventTemplateUpdate,
EventTemplateResponse,
)
router = APIRouter()
@router.get("/", response_model=list[EventTemplateResponse])
async def list_templates(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_session),
):
result = await db.execute(select(EventTemplate).order_by(EventTemplate.name))
return result.scalars().all()
@router.post("/", response_model=EventTemplateResponse, status_code=status.HTTP_201_CREATED)
async def create_template(
payload: EventTemplateCreate,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_session),
):
template = EventTemplate(**payload.model_dump())
db.add(template)
await db.commit()
await db.refresh(template)
return template
@router.put("/{template_id}", response_model=EventTemplateResponse)
async def update_template(
template_id: int,
payload: EventTemplateUpdate,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_session),
):
result = await db.execute(
select(EventTemplate).where(EventTemplate.id == template_id)
)
template = result.scalar_one_or_none()
if template is None:
raise HTTPException(status_code=404, detail="Template not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(template, field, value)
await db.commit()
await db.refresh(template)
return template
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_template(
template_id: int,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_session),
):
result = await db.execute(
select(EventTemplate).where(EventTemplate.id == template_id)
)
template = result.scalar_one_or_none()
if template is None:
raise HTTPException(status_code=404, detail="Template not found")
await db.delete(template)
await db.commit()

View File

@ -0,0 +1,43 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Optional
class EventTemplateCreate(BaseModel):
name: str
title: str
description: Optional[str] = None
duration_minutes: int = 60
calendar_id: Optional[int] = None
recurrence_rule: Optional[str] = None
all_day: bool = False
location_id: Optional[int] = None
is_starred: bool = False
class EventTemplateUpdate(BaseModel):
name: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
duration_minutes: Optional[int] = None
calendar_id: Optional[int] = None
recurrence_rule: Optional[str] = None
all_day: Optional[bool] = None
location_id: Optional[int] = None
is_starred: Optional[bool] = None
class EventTemplateResponse(BaseModel):
id: int
name: str
title: str
description: Optional[str]
duration_minutes: int
calendar_id: Optional[int]
recurrence_rule: Optional[str]
all_day: bool
location_id: Optional[int]
is_starred: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@ -96,6 +96,7 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="Calendar name" placeholder="Calendar name"
required required
disabled={calendar?.is_system}
/> />
</div> </div>

View File

@ -8,7 +8,7 @@ import interactionPlugin from '@fullcalendar/interaction';
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'; import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
import { ChevronLeft, ChevronRight } from 'lucide-react'; import { ChevronLeft, ChevronRight } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { CalendarEvent } from '@/types'; import type { CalendarEvent, EventTemplate } from '@/types';
import { useCalendars } from '@/hooks/useCalendars'; import { useCalendars } from '@/hooks/useCalendars';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -42,6 +42,8 @@ export default function CalendarPage() {
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth'); const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
const [calendarTitle, setCalendarTitle] = useState(''); const [calendarTitle, setCalendarTitle] = useState('');
const [templateEvent, setTemplateEvent] = useState<Partial<CalendarEvent> | null>(null);
// Scope dialog state // Scope dialog state
const [scopeDialogOpen, setScopeDialogOpen] = useState(false); const [scopeDialogOpen, setScopeDialogOpen] = useState(false);
const [scopeAction, setScopeAction] = useState<ScopeAction>('edit'); const [scopeAction, setScopeAction] = useState<ScopeAction>('edit');
@ -63,6 +65,26 @@ export default function CalendarPage() {
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
// Scroll wheel navigation in month view
useEffect(() => {
const el = calendarContainerRef.current;
if (!el) return;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const handleWheel = (e: WheelEvent) => {
const api = calendarRef.current?.getApi();
if (!api || api.view.type !== 'dayGridMonth') return;
e.preventDefault();
if (debounceTimer) return;
debounceTimer = setTimeout(() => {
debounceTimer = null;
}, 300);
if (e.deltaY > 0) api.next();
else if (e.deltaY < 0) api.prev();
};
el.addEventListener('wheel', handleWheel, { passive: false });
return () => el.removeEventListener('wheel', handleWheel);
}, []);
const { data: events = [] } = useQuery({ const { data: events = [] } = useQuery({
queryKey: ['calendar-events'], queryKey: ['calendar-events'],
queryFn: async () => { queryFn: async () => {
@ -261,12 +283,27 @@ export default function CalendarPage() {
calendarRef.current?.getApi().unselect(); calendarRef.current?.getApi().unselect();
setShowForm(false); setShowForm(false);
setEditingEvent(null); setEditingEvent(null);
setTemplateEvent(null);
setActiveEditScope(null); setActiveEditScope(null);
setSelectedStart(null); setSelectedStart(null);
setSelectedEnd(null); setSelectedEnd(null);
setSelectedAllDay(false); setSelectedAllDay(false);
}; };
const handleUseTemplate = (template: EventTemplate) => {
setTemplateEvent({
title: template.title,
description: template.description || '',
all_day: template.all_day,
calendar_id: template.calendar_id || 0,
location_id: template.location_id || undefined,
is_starred: template.is_starred,
recurrence_rule: template.recurrence_rule || undefined,
} as Partial<CalendarEvent>);
setEditingEvent(null);
setShowForm(true);
};
const handleDatesSet = (arg: DatesSetArg) => { const handleDatesSet = (arg: DatesSetArg) => {
setCalendarTitle(arg.view.title); setCalendarTitle(arg.view.title);
setCurrentView(arg.view.type as CalendarView); setCurrentView(arg.view.type as CalendarView);
@ -279,7 +316,7 @@ export default function CalendarPage() {
return ( return (
<div className="flex h-full overflow-hidden"> <div className="flex h-full overflow-hidden">
<CalendarSidebar /> <CalendarSidebar onUseTemplate={handleUseTemplate} />
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden"> <div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
{/* Custom toolbar — h-16 matches sidebar header */} {/* Custom toolbar — h-16 matches sidebar header */}
@ -348,7 +385,7 @@ export default function CalendarPage() {
{showForm && ( {showForm && (
<EventForm <EventForm
event={editingEvent} event={editingEvent || (templateEvent as CalendarEvent | null)}
initialStart={selectedStart} initialStart={selectedStart}
initialEnd={selectedEnd} initialEnd={selectedEnd}
initialAllDay={selectedAllDay} initialAllDay={selectedAllDay}

View File

@ -1,19 +1,34 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil } from 'lucide-react'; import { Plus, Pencil, Trash2, FileText } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { Calendar } from '@/types'; import type { Calendar, EventTemplate } 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 { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import CalendarForm from './CalendarForm'; import CalendarForm from './CalendarForm';
import TemplateForm from './TemplateForm';
export default function CalendarSidebar() { interface CalendarSidebarProps {
onUseTemplate?: (template: EventTemplate) => void;
}
export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: calendars = [] } = useCalendars(); const { data: calendars = [] } = useCalendars();
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingCalendar, setEditingCalendar] = useState<Calendar | null>(null); const [editingCalendar, setEditingCalendar] = useState<Calendar | null>(null);
const [showTemplateForm, setShowTemplateForm] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<EventTemplate | null>(null);
const { data: templates = [] } = useQuery({
queryKey: ['event-templates'],
queryFn: async () => {
const { data } = await api.get<EventTemplate[]>('/event-templates');
return data;
},
});
const toggleMutation = useMutation({ const toggleMutation = useMutation({
mutationFn: async ({ id, is_visible }: { id: number; is_visible: boolean }) => { mutationFn: async ({ id, is_visible }: { id: number; is_visible: boolean }) => {
@ -28,6 +43,19 @@ export default function CalendarSidebar() {
}, },
}); });
const deleteTemplateMutation = useMutation({
mutationFn: async (id: number) => {
await api.delete(`/event-templates/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-templates'] });
toast.success('Template deleted');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete template'));
},
});
const handleToggle = (calendar: Calendar) => { const handleToggle = (calendar: Calendar) => {
toggleMutation.mutate({ id: calendar.id, is_visible: !calendar.is_visible }); toggleMutation.mutate({ id: calendar.id, is_visible: !calendar.is_visible });
}; };
@ -55,37 +83,91 @@ export default function CalendarSidebar() {
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto p-3 space-y-0.5"> <div className="flex-1 overflow-y-auto p-3 space-y-4">
{calendars.map((cal) => ( {/* Calendars list */}
<div <div className="space-y-0.5">
key={cal.id} {calendars.map((cal) => (
className="group flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150" <div
> key={cal.id}
<Checkbox className="group flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150"
checked={cal.is_visible} >
onChange={() => handleToggle(cal)} <Checkbox
className="shrink-0" checked={cal.is_visible}
style={{ onChange={() => handleToggle(cal)}
accentColor: cal.color, className="shrink-0"
borderColor: cal.is_visible ? cal.color : undefined, style={{
backgroundColor: cal.is_visible ? cal.color : undefined, accentColor: cal.color,
}} borderColor: cal.is_visible ? cal.color : undefined,
/> backgroundColor: cal.is_visible ? cal.color : undefined,
<span }}
className="h-2.5 w-2.5 rounded-full shrink-0" />
style={{ backgroundColor: cal.color }} <span
/> className="h-2.5 w-2.5 rounded-full shrink-0"
<span className="text-sm text-foreground truncate flex-1">{cal.name}</span> style={{ backgroundColor: cal.color }}
{!cal.is_system && ( />
<span className="text-sm text-foreground truncate flex-1">{cal.name}</span>
<button <button
onClick={() => handleEdit(cal)} onClick={() => handleEdit(cal)}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground" className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground"
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</button> </button>
)} </div>
))}
</div>
{/* Templates section */}
<div className="space-y-1.5">
<div className="flex items-center justify-between px-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Templates
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => { setEditingTemplate(null); setShowTemplateForm(true); }}
>
<Plus className="h-3 w-3" />
</Button>
</div> </div>
))} {templates.length === 0 ? (
<p className="text-xs text-muted-foreground px-2">No templates yet</p>
) : (
<div className="space-y-0.5">
{templates.map((tmpl) => (
<div
key={tmpl.id}
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150 cursor-pointer"
onClick={() => onUseTemplate?.(tmpl)}
>
<FileText className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm text-foreground truncate flex-1">{tmpl.name}</span>
<button
onClick={(e) => {
e.stopPropagation();
setEditingTemplate(tmpl);
setShowTemplateForm(true);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (!window.confirm(`Delete template "${tmpl.name}"?`)) return;
deleteTemplateMutation.mutate(tmpl.id);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
</div> </div>
{showForm && ( {showForm && (
@ -94,6 +176,13 @@ export default function CalendarSidebar() {
onClose={handleCloseForm} onClose={handleCloseForm}
/> />
)} )}
{showTemplateForm && (
<TemplateForm
template={editingTemplate}
onClose={() => { setShowTemplateForm(false); setEditingTemplate(null); }}
/>
)}
</div> </div>
); );
} }

View File

@ -0,0 +1,176 @@
import { useState, FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { EventTemplate } from '@/types';
import { useCalendars } from '@/hooks/useCalendars';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
interface TemplateFormProps {
template: EventTemplate | null;
onClose: () => void;
}
export default function TemplateForm({ template, onClose }: TemplateFormProps) {
const queryClient = useQueryClient();
const { data: calendars = [] } = useCalendars();
const selectableCalendars = calendars.filter((c) => !c.is_system);
const [formData, setFormData] = useState({
name: template?.name || '',
title: template?.title || '',
description: template?.description || '',
duration_minutes: template?.duration_minutes?.toString() || '60',
calendar_id: template?.calendar_id?.toString() || '',
all_day: template?.all_day || false,
is_starred: template?.is_starred || false,
});
const mutation = useMutation({
mutationFn: async (data: typeof formData) => {
const payload = {
name: data.name,
title: data.title,
description: data.description || null,
duration_minutes: parseInt(data.duration_minutes) || 60,
calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null,
all_day: data.all_day,
is_starred: data.is_starred,
};
if (template) {
const { data: res } = await api.put(`/event-templates/${template.id}`, payload);
return res;
} else {
const { data: res } = await api.post('/event-templates', payload);
return res;
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-templates'] });
toast.success(template ? 'Template updated' : 'Template created');
onClose();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to save template'));
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!formData.name.trim() || !formData.title.trim()) return;
mutation.mutate(formData);
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{template ? 'Edit Template' : 'New Template'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tmpl-name" required>Template Name</Label>
<Input
id="tmpl-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Weekly standup"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="tmpl-title" required>Event Title</Label>
<Input
id="tmpl-title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="Title for created events"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="tmpl-desc">Description</Label>
<Textarea
id="tmpl-desc"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="min-h-[60px]"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tmpl-duration">Duration (min)</Label>
<Input
id="tmpl-duration"
type="number"
min={5}
max={1440}
value={formData.duration_minutes}
onChange={(e) => setFormData({ ...formData, duration_minutes: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tmpl-calendar">Calendar</Label>
<Select
id="tmpl-calendar"
value={formData.calendar_id}
onChange={(e) => setFormData({ ...formData, calendar_id: e.target.value })}
>
<option value="">Default</option>
{selectableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>{cal.name}</option>
))}
</Select>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="tmpl-allday"
checked={formData.all_day}
onChange={(e) => setFormData({ ...formData, all_day: (e.target as HTMLInputElement).checked })}
/>
<Label htmlFor="tmpl-allday">All day</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="tmpl-starred"
checked={formData.is_starred}
onChange={(e) => setFormData({ ...formData, is_starred: (e.target as HTMLInputElement).checked })}
/>
<Label htmlFor="tmpl-starred">Starred</Label>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : template ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -214,3 +214,17 @@ export interface UpcomingResponse {
days: number; days: number;
cutoff_date: string; cutoff_date: string;
} }
export interface EventTemplate {
id: number;
name: string;
title: string;
description?: string;
duration_minutes: number;
calendar_id?: number;
recurrence_rule?: string;
all_day: boolean;
location_id?: number;
is_starred: boolean;
created_at: string;
}