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 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("/")
|
||||||
|
|||||||
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)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Calendar name"
|
placeholder="Calendar name"
|
||||||
required
|
required
|
||||||
|
disabled={calendar?.is_system}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user