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:
parent
a11fcbcbcc
commit
80f3f3ed10
40
backend/alembic/versions/011_add_event_templates.py
Normal file
40
backend/alembic/versions/011_add_event_templates.py
Normal 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")
|
||||
@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
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
|
||||
@ -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(dashboard.router, prefix="/api", tags=["Dashboard"])
|
||||
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("/")
|
||||
|
||||
25
backend/app/models/event_template.py
Normal file
25
backend/app/models/event_template.py
Normal 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)
|
||||
75
backend/app/routers/event_templates.py
Normal file
75
backend/app/routers/event_templates.py
Normal 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()
|
||||
43
backend/app/schemas/event_template.py
Normal file
43
backend/app/schemas/event_template.py
Normal 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)
|
||||
@ -96,6 +96,7 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Calendar name"
|
||||
required
|
||||
disabled={calendar?.is_system}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ import interactionPlugin from '@fullcalendar/interaction';
|
||||
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type { CalendarEvent } from '@/types';
|
||||
import type { CalendarEvent, EventTemplate } from '@/types';
|
||||
import { useCalendars } from '@/hooks/useCalendars';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -42,6 +42,8 @@ export default function CalendarPage() {
|
||||
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
|
||||
const [calendarTitle, setCalendarTitle] = useState('');
|
||||
|
||||
const [templateEvent, setTemplateEvent] = useState<Partial<CalendarEvent> | null>(null);
|
||||
|
||||
// Scope dialog state
|
||||
const [scopeDialogOpen, setScopeDialogOpen] = useState(false);
|
||||
const [scopeAction, setScopeAction] = useState<ScopeAction>('edit');
|
||||
@ -63,6 +65,26 @@ export default function CalendarPage() {
|
||||
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({
|
||||
queryKey: ['calendar-events'],
|
||||
queryFn: async () => {
|
||||
@ -261,12 +283,27 @@ export default function CalendarPage() {
|
||||
calendarRef.current?.getApi().unselect();
|
||||
setShowForm(false);
|
||||
setEditingEvent(null);
|
||||
setTemplateEvent(null);
|
||||
setActiveEditScope(null);
|
||||
setSelectedStart(null);
|
||||
setSelectedEnd(null);
|
||||
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) => {
|
||||
setCalendarTitle(arg.view.title);
|
||||
setCurrentView(arg.view.type as CalendarView);
|
||||
@ -279,7 +316,7 @@ export default function CalendarPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<CalendarSidebar />
|
||||
<CalendarSidebar onUseTemplate={handleUseTemplate} />
|
||||
|
||||
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Custom toolbar — h-16 matches sidebar header */}
|
||||
@ -348,7 +385,7 @@ export default function CalendarPage() {
|
||||
|
||||
{showForm && (
|
||||
<EventForm
|
||||
event={editingEvent}
|
||||
event={editingEvent || (templateEvent as CalendarEvent | null)}
|
||||
initialStart={selectedStart}
|
||||
initialEnd={selectedEnd}
|
||||
initialAllDay={selectedAllDay}
|
||||
|
||||
@ -1,19 +1,34 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil, Trash2, FileText } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type { Calendar } from '@/types';
|
||||
import type { Calendar, EventTemplate } from '@/types';
|
||||
import { useCalendars } from '@/hooks/useCalendars';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
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 { data: calendars = [] } = useCalendars();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
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({
|
||||
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) => {
|
||||
toggleMutation.mutate({ id: calendar.id, is_visible: !calendar.is_visible });
|
||||
};
|
||||
@ -55,37 +83,91 @@ export default function CalendarSidebar() {
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-0.5">
|
||||
{calendars.map((cal) => (
|
||||
<div
|
||||
key={cal.id}
|
||||
className="group flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150"
|
||||
>
|
||||
<Checkbox
|
||||
checked={cal.is_visible}
|
||||
onChange={() => handleToggle(cal)}
|
||||
className="shrink-0"
|
||||
style={{
|
||||
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="text-sm text-foreground truncate flex-1">{cal.name}</span>
|
||||
{!cal.is_system && (
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
||||
{/* Calendars list */}
|
||||
<div className="space-y-0.5">
|
||||
{calendars.map((cal) => (
|
||||
<div
|
||||
key={cal.id}
|
||||
className="group flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150"
|
||||
>
|
||||
<Checkbox
|
||||
checked={cal.is_visible}
|
||||
onChange={() => handleToggle(cal)}
|
||||
className="shrink-0"
|
||||
style={{
|
||||
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="text-sm text-foreground truncate flex-1">{cal.name}</span>
|
||||
<button
|
||||
onClick={() => handleEdit(cal)}
|
||||
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" />
|
||||
</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>
|
||||
))}
|
||||
{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>
|
||||
|
||||
{showForm && (
|
||||
@ -94,6 +176,13 @@ export default function CalendarSidebar() {
|
||||
onClose={handleCloseForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTemplateForm && (
|
||||
<TemplateForm
|
||||
template={editingTemplate}
|
||||
onClose={() => { setShowTemplateForm(false); setEditingTemplate(null); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
176
frontend/src/components/calendar/TemplateForm.tsx
Normal file
176
frontend/src/components/calendar/TemplateForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -214,3 +214,17 @@ export interface UpcomingResponse {
|
||||
days: number;
|
||||
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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user