Add calendar app — shared events calendar with departments, staff tagging, bank holidays and two-way phone sync
Fastify/Postgres backend + React frontend matching the stack's app conventions, plus a hand-rolled RFC 4791 CalDAV server (caldav-adapter turned out Koa-only in practice) so calendars subscribe as genuine two-way sync in Apple/Google/Outlook. v1 scope: multiple colour-coded calendars, department/staff event tagging via live Workforce lookups, month/week/day/list views, dashboard, file attachments, activity log, and an auto-synced UK bank holidays calendar. Verified end-to-end locally against real Postgres: REST CRUD, CalDAV discovery/PROPFIND/REPORT/PUT/sync-collection, all-day date handling, system-calendar write protection, and activity logging across both the web and CalDAV write paths. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
bf5557d277
53 changed files with 12529 additions and 0 deletions
165
frontend/src/pages/CalendarView.tsx
Normal file
165
frontend/src/pages/CalendarView.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'
|
||||
import type { Calendar, EventSummary } from '../types'
|
||||
import { can } from '../types'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import { fetchCalendars, fetchEvents } from '../api'
|
||||
import CalendarToggleList from '../components/CalendarToggleList'
|
||||
import ViewSwitcher, { type CalendarViewKey } from '../components/ViewSwitcher'
|
||||
import EventForm from '../components/EventForm'
|
||||
import MonthGrid from '../components/views/MonthGrid'
|
||||
import WeekGrid from '../components/views/WeekGrid'
|
||||
import DayGrid from '../components/views/DayGrid'
|
||||
import AgendaList from '../components/views/AgendaList'
|
||||
import {
|
||||
addDays, addMonths, monthGridDays, startOfWeek, toISODate,
|
||||
formatMonthLabel, formatWeekLabel, formatDateLabel,
|
||||
} from '../dateUtils'
|
||||
|
||||
function rangeFor(view: CalendarViewKey, date: Date): { from: Date; to: Date } {
|
||||
if (view === 'month') {
|
||||
const days = monthGridDays(date)
|
||||
return { from: days[0], to: days[days.length - 1] }
|
||||
}
|
||||
if (view === 'week') {
|
||||
const start = startOfWeek(date)
|
||||
return { from: start, to: addDays(start, 6) }
|
||||
}
|
||||
if (view === 'day') {
|
||||
return { from: date, to: date }
|
||||
}
|
||||
// list/agenda — rolling 30-day window from the current date
|
||||
return { from: date, to: addDays(date, 30) }
|
||||
}
|
||||
|
||||
function labelFor(view: CalendarViewKey, date: Date): string {
|
||||
if (view === 'month') return formatMonthLabel(date)
|
||||
if (view === 'week') return formatWeekLabel(date)
|
||||
if (view === 'day') return formatDateLabel(date)
|
||||
return `Next 30 days from ${formatDateLabel(date)}`
|
||||
}
|
||||
|
||||
function shiftDate(view: CalendarViewKey, date: Date, dir: 1 | -1): Date {
|
||||
if (view === 'month') return addMonths(date, dir)
|
||||
if (view === 'week') return addDays(date, 7 * dir)
|
||||
return addDays(date, dir)
|
||||
}
|
||||
|
||||
export default function CalendarView() {
|
||||
const { user } = useAuth()
|
||||
const [view, setView] = useState<CalendarViewKey>('month')
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
const [calendars, setCalendars] = useState<Calendar[]>([])
|
||||
const [visibleIds, setVisibleIds] = useState<Set<number>>(new Set())
|
||||
const [events, setEvents] = useState<EventSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [formState, setFormState] = useState<{ open: boolean; eventId?: number; initialDate?: Date }>({ open: false })
|
||||
|
||||
useEffect(() => {
|
||||
fetchCalendars().then(cals => {
|
||||
setCalendars(cals)
|
||||
setVisibleIds(new Set(cals.map(c => c.id)))
|
||||
}).catch(err => setError(err.message))
|
||||
}, [])
|
||||
|
||||
const reload = useCallback(() => {
|
||||
const { from, to } = rangeFor(view, currentDate)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetchEvents({ from: toISODate(from), to: toISODate(to) })
|
||||
.then(setEvents)
|
||||
.catch(err => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [view, currentDate])
|
||||
|
||||
useEffect(() => { reload() }, [reload])
|
||||
|
||||
function toggleCalendar(id: number) {
|
||||
setVisibleIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const visibleEvents = events.filter(ev => visibleIds.has(ev.calendar_id))
|
||||
|
||||
function openCreate(date: Date) {
|
||||
if (!can(user, 'create')) return
|
||||
setFormState({ open: true, initialDate: date })
|
||||
}
|
||||
function openEdit(id: number) {
|
||||
setFormState({ open: true, eventId: id })
|
||||
}
|
||||
function closeForm() {
|
||||
setFormState({ open: false })
|
||||
}
|
||||
|
||||
const viewProps = {
|
||||
events: visibleEvents,
|
||||
date: currentDate,
|
||||
onSelectDate: openCreate,
|
||||
onSelectEvent: openEdit,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page" style={{ maxWidth: 1300 }}>
|
||||
<div className="page-header">
|
||||
<h1>Calendar</h1>
|
||||
{can(user, 'create') && (
|
||||
<button className="btn btn-primary" onClick={() => openCreate(currentDate)}>
|
||||
<Plus size={14} strokeWidth={1.75} />
|
||||
New event
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div className="card" style={{ width: 220, flexShrink: 0 }}>
|
||||
<div className="section-title" style={{ marginTop: 0 }}>Calendars</div>
|
||||
<CalendarToggleList calendars={calendars} visibleIds={visibleIds} onToggle={toggleCalendar} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="filter-row" style={{ marginBottom: 12, alignItems: 'center' }}>
|
||||
<button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, -1))}>
|
||||
<ChevronLeft size={14} strokeWidth={1.75} />
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => setCurrentDate(new Date())}>Today</button>
|
||||
<button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, 1))}>
|
||||
<ChevronRight size={14} strokeWidth={1.75} />
|
||||
</button>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, flex: 1 }}>{labelFor(view, currentDate)}</div>
|
||||
<ViewSwitcher view={view} onChange={setView} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state">Loading…</div>
|
||||
) : view === 'month' ? (
|
||||
<MonthGrid {...viewProps} />
|
||||
) : view === 'week' ? (
|
||||
<WeekGrid {...viewProps} />
|
||||
) : view === 'day' ? (
|
||||
<DayGrid {...viewProps} />
|
||||
) : (
|
||||
<AgendaList {...viewProps} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formState.open && (
|
||||
<EventForm
|
||||
eventId={formState.eventId}
|
||||
initialDate={formState.initialDate}
|
||||
onClose={closeForm}
|
||||
onSaved={reload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue