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
113
frontend/src/pages/ActivityLog.tsx
Normal file
113
frontend/src/pages/ActivityLog.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import type { ActivityLogEntry, Calendar } from '../types'
|
||||
import { fetchActivity, fetchCalendars } from '../api'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export default function ActivityLog() {
|
||||
const [calendars, setCalendars] = useState<Calendar[]>([])
|
||||
const [entries, setEntries] = useState<ActivityLogEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
|
||||
const [calendarId, setCalendarId] = useState('')
|
||||
const [actorEmail, setActorEmail] = useState('')
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchCalendars().then(setCalendars).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const load = useCallback((nextOffset: number, append: boolean) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetchActivity({
|
||||
calendar_id: calendarId ? Number(calendarId) : undefined,
|
||||
actor_email: actorEmail || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset: nextOffset,
|
||||
})
|
||||
.then(rows => {
|
||||
setEntries(prev => append ? [...prev, ...rows] : rows)
|
||||
setHasMore(rows.length === PAGE_SIZE)
|
||||
setOffset(nextOffset)
|
||||
})
|
||||
.catch(err => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [calendarId, actorEmail, from, to])
|
||||
|
||||
useEffect(() => { load(0, false) }, [load])
|
||||
|
||||
return (
|
||||
<div className="page" style={{ maxWidth: 1100 }}>
|
||||
<div className="page-header">
|
||||
<h1>Activity Log</h1>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="filter-row" style={{ flexWrap: 'wrap', marginBottom: 14 }}>
|
||||
<div className="field" style={{ marginBottom: 0, minWidth: 160 }}>
|
||||
<label>Calendar</label>
|
||||
<select value={calendarId} onChange={e => setCalendarId(e.target.value)}>
|
||||
<option value="">All calendars</option>
|
||||
{calendars.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field" style={{ marginBottom: 0, minWidth: 160 }}>
|
||||
<label>Actor email</label>
|
||||
<input type="text" value={actorEmail} onChange={e => setActorEmail(e.target.value)} placeholder="name@hotel..." />
|
||||
</div>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label>From</label>
|
||||
<input type="date" value={from} onChange={e => setFrom(e.target.value)} />
|
||||
</div>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label>To</label>
|
||||
<input type="date" value={to} onChange={e => setTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Actor</th>
|
||||
<th>Action</th>
|
||||
<th>Entity</th>
|
||||
<th>Summary</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(e => (
|
||||
<tr key={e.id}>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{new Date(e.created_at).toLocaleString()}</td>
|
||||
<td>{e.actor_name || e.actor_email}</td>
|
||||
<td>{e.action}</td>
|
||||
<td>{e.entity_type} #{e.entity_id}</td>
|
||||
<td>{e.summary}</td>
|
||||
</tr>
|
||||
))}
|
||||
{entries.length === 0 && !loading && (
|
||||
<tr><td colSpan={5} className="empty-state">No activity found.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{loading && <div className="empty-state">Loading…</div>}
|
||||
|
||||
{hasMore && !loading && entries.length > 0 && (
|
||||
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
||||
<button className="btn" onClick={() => load(offset + PAGE_SIZE, true)}>Load more</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
174
frontend/src/pages/CalDavSetup.tsx
Normal file
174
frontend/src/pages/CalDavSetup.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Copy, Plus, Smartphone, Trash2, TriangleAlert } from 'lucide-react'
|
||||
import type { CaldavCredential, CaldavCredentialCreated } from '../types'
|
||||
import { fetchCaldavCredentials, createCaldavCredential, deleteCaldavCredential } from '../api'
|
||||
|
||||
export default function CalDavSetup() {
|
||||
const [credentials, setCredentials] = useState<CaldavCredential[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [label, setLabel] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [revealed, setRevealed] = useState<CaldavCredentialCreated | null>(null)
|
||||
|
||||
const caldavUrl = `${window.location.origin}/calendar/caldav/`
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetchCaldavCredentials().then(setCredentials).catch(err => setError(err.message)).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { reload() }, [reload])
|
||||
|
||||
async function handleCreate() {
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
try {
|
||||
const created = await createCaldavCredential(label.trim() || undefined)
|
||||
setRevealed(created)
|
||||
setLabel('')
|
||||
reload()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create credential')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(id: number) {
|
||||
if (!confirm('Revoke this device? It will stop syncing immediately.')) return
|
||||
try {
|
||||
await deleteCaldavCredential(id)
|
||||
if (revealed?.id === id) setRevealed(null)
|
||||
reload()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to revoke credential')
|
||||
}
|
||||
}
|
||||
|
||||
function copy(text: string) {
|
||||
navigator.clipboard?.writeText(text).catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page" style={{ maxWidth: 760 }}>
|
||||
<div className="page-header">
|
||||
<h1>Phone Sync</h1>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Subscribe to this calendar from your phone or computer's own calendar app (Apple Calendar,
|
||||
Google Calendar, Outlook, …) using <strong>CalDAV</strong>. Once set up, events created here
|
||||
show up on your device automatically, and new device-created events sync back — no separate app
|
||||
needed.
|
||||
</p>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
Each device needs its own generated username and password below — never share your normal
|
||||
hotel login for this.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="section-title">Your CalDAV devices</div>
|
||||
{loading ? (
|
||||
<div className="empty-state">Loading…</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className="empty-state">No devices set up yet.</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Label</th>
|
||||
<th>Username</th>
|
||||
<th>Created</th>
|
||||
<th>Last used</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{credentials.map(c => (
|
||||
<tr key={c.id}>
|
||||
<td>{c.label || '—'}</td>
|
||||
<td>{c.username}</td>
|
||||
<td>{new Date(c.created_at).toLocaleDateString()}</td>
|
||||
<td>{c.last_used_at ? new Date(c.last_used_at).toLocaleString() : 'Never'}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button className="btn-ghost-sm btn-ghost-danger" onClick={() => handleRevoke(c.id)}>
|
||||
<Trash2 size={13} strokeWidth={1.75} /> Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field-row" style={{ alignItems: 'flex-end', marginTop: 12 }}>
|
||||
<div className="field" style={{ flex: 1 }}>
|
||||
<label>Device label (optional)</label>
|
||||
<input type="text" value={label} onChange={e => setLabel(e.target.value)} placeholder="e.g. Sarah's iPhone" />
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleCreate} disabled={creating} style={{ marginBottom: 12 }}>
|
||||
<Plus size={14} strokeWidth={1.75} />
|
||||
{creating ? 'Generating…' : 'Generate new'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{revealed && (
|
||||
<div className="card" style={{ borderColor: 'var(--gold)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--danger)', fontWeight: 600, marginBottom: 8 }}>
|
||||
<TriangleAlert size={16} strokeWidth={1.75} />
|
||||
Save this now — the password won't be shown again.
|
||||
</div>
|
||||
<div className="cal-credential-box">
|
||||
<div>Username: {revealed.username} <Copy size={13} strokeWidth={1.75} style={{ cursor: 'pointer', verticalAlign: 'middle' }} onClick={() => copy(revealed.username)} /></div>
|
||||
<div>Password: {revealed.password} <Copy size={13} strokeWidth={1.75} style={{ cursor: 'pointer', verticalAlign: 'middle' }} onClick={() => copy(revealed.password)} /></div>
|
||||
<div>Server URL: {caldavUrl} <Copy size={13} strokeWidth={1.75} style={{ cursor: 'pointer', verticalAlign: 'middle' }} onClick={() => copy(caldavUrl)} /></div>
|
||||
</div>
|
||||
<button className="btn btn-sm" onClick={() => setRevealed(null)}>I've saved it, hide this</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section-title">Set-up instructions</div>
|
||||
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Smartphone size={15} strokeWidth={1.75} /> Apple Calendar (iPhone / Mac)
|
||||
</div>
|
||||
<ol style={{ marginTop: 0, paddingLeft: 20 }}>
|
||||
<li>Settings → Calendar → Accounts → Add Account → Other → Add CalDAV Account.</li>
|
||||
<li>Server: <code>{caldavUrl}</code></li>
|
||||
<li>User Name / Password: the credentials generated above.</li>
|
||||
<li>Tap Next, then Save.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Smartphone size={15} strokeWidth={1.75} /> Google Calendar
|
||||
</div>
|
||||
<ol style={{ marginTop: 0, paddingLeft: 20 }}>
|
||||
<li>Google Calendar doesn't support direct CalDAV subscriptions on the free tier — easiest is to
|
||||
use a CalDAV-sync app such as "CalDAV-Sync" (Android) with the server URL and credentials above.</li>
|
||||
<li>Alternatively, on desktop, add it as a "secondary" calendar in a CalDAV-aware client and it
|
||||
will appear alongside Google Calendar.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Smartphone size={15} strokeWidth={1.75} /> Outlook
|
||||
</div>
|
||||
<ol style={{ marginTop: 0, paddingLeft: 20 }}>
|
||||
<li>Outlook (desktop): File → Account Settings → Internet Calendars → New, then paste <code>{caldavUrl}</code>.</li>
|
||||
<li>When prompted, enter the username and password generated above.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
200
frontend/src/pages/CalendarSettings.tsx
Normal file
200
frontend/src/pages/CalendarSettings.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Lock, Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||
import type { Calendar } from '../types'
|
||||
import { fetchCalendars, createCalendar, updateCalendar, deleteCalendar } from '../api'
|
||||
|
||||
// Curated swatch — similarly saturated hues that read well against the navy/gold theme.
|
||||
const SWATCHES = [
|
||||
'#c9a84c', '#2563eb', '#16a34a', '#dc2626', '#7c3aed',
|
||||
'#0d9488', '#d97706', '#db2777', '#4f46e5', '#64748b',
|
||||
]
|
||||
|
||||
export default function CalendarSettings() {
|
||||
const [calendars, setCalendars] = useState<Calendar[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editColor, setEditColor] = useState(SWATCHES[0])
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newColor, setNewColor] = useState(SWATCHES[0])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetchCalendars().then(setCalendars).catch(err => setError(err.message)).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { reload() }, [reload])
|
||||
|
||||
function startEdit(cal: Calendar) {
|
||||
setEditingId(cal.id)
|
||||
setEditName(cal.name)
|
||||
setEditColor(cal.color)
|
||||
}
|
||||
|
||||
async function saveEdit(id: number) {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateCalendar(id, { name: editName.trim(), color: editColor })
|
||||
setEditingId(null)
|
||||
reload()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Update failed')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(cal: Calendar) {
|
||||
if (!confirm(`Delete calendar "${cal.name}"? Events on it will also be removed.`)) return
|
||||
try {
|
||||
await deleteCalendar(cal.id)
|
||||
reload()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Delete failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!newName.trim()) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await createCalendar({ name: newName.trim(), color: newColor })
|
||||
setNewName('')
|
||||
setNewColor(SWATCHES[0])
|
||||
setNewOpen(false)
|
||||
reload()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Create failed')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Calendars</h1>
|
||||
<button className="btn btn-primary" onClick={() => setNewOpen(o => !o)}>
|
||||
{newOpen ? <X size={14} strokeWidth={1.75} /> : <Plus size={14} strokeWidth={1.75} />}
|
||||
{newOpen ? 'Cancel' : 'New calendar'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{newOpen && (
|
||||
<div className="card">
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="field">
|
||||
<label>Name</label>
|
||||
<input type="text" value={newName} onChange={e => setNewName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Colour</label>
|
||||
<div className="cal-swatch-row">
|
||||
{SWATCHES.map(sw => (
|
||||
<span
|
||||
key={sw}
|
||||
className={`cal-swatch ${newColor === sw ? 'active' : ''}`}
|
||||
style={{ background: sw }}
|
||||
onClick={() => setNewColor(sw)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Saving…' : 'Create'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state">Loading…</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{calendars.map(cal => (
|
||||
<tr key={cal.id}>
|
||||
{editingId === cal.id ? (
|
||||
<>
|
||||
<td style={{ width: 40 }}>
|
||||
<div className="cal-swatch-row" style={{ margin: 0 }}>
|
||||
{SWATCHES.map(sw => (
|
||||
<span
|
||||
key={sw}
|
||||
className={`cal-swatch ${editColor === sw ? 'active' : ''}`}
|
||||
style={{ background: sw, width: 18, height: 18 }}
|
||||
onClick={() => setEditColor(sw)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" value={editName} onChange={e => setEditName(e.target.value)} />
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => saveEdit(cal.id)} disabled={saving}>Save</button>{' '}
|
||||
<button className="btn btn-sm" onClick={() => setEditingId(null)}>Cancel</button>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td style={{ width: 40 }}>
|
||||
<span className="cal-dot" style={{ background: cal.color, width: 14, height: 14 }} />
|
||||
</td>
|
||||
<td>
|
||||
{cal.name}
|
||||
{cal.is_system && (
|
||||
<span className="badge badge-outline" style={{ marginLeft: 8 }}>
|
||||
<Lock size={11} strokeWidth={1.75} /> system
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button
|
||||
className="btn-ghost-sm"
|
||||
onClick={() => startEdit(cal)}
|
||||
disabled={cal.is_system}
|
||||
>
|
||||
<Pencil size={13} strokeWidth={1.75} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost-sm btn-ghost-danger"
|
||||
onClick={() => handleDelete(cal)}
|
||||
disabled={cal.is_system}
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={1.75} />
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{calendars.length === 0 && (
|
||||
<tr><td colSpan={3} className="empty-state">No calendars yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
89
frontend/src/pages/Dashboard.tsx
Normal file
89
frontend/src/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { CalendarClock, Users2 } from 'lucide-react'
|
||||
import type { Department, EventSummary } from '../types'
|
||||
import { fetchMyUpcoming, fetchMyDepartments } from '../api'
|
||||
import EventForm from '../components/EventForm'
|
||||
import { formatDayHeader, formatTime } from '../dateUtils'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [upcoming, setUpcoming] = useState<EventSummary[]>([])
|
||||
const [departments, setDepartments] = useState<Department[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [openEventId, setOpenEventId] = useState<number | null>(null)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
Promise.all([fetchMyUpcoming(7), fetchMyDepartments()])
|
||||
.then(([ev, depts]) => { setUpcoming(ev); setDepartments(depts) })
|
||||
.catch(err => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { reload() }, [reload])
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="stats-strip">
|
||||
<div className="stat-box">
|
||||
<div className="stat-value">{upcoming.length}</div>
|
||||
<div className="stat-label">Upcoming (7 days)</div>
|
||||
</div>
|
||||
<div className="stat-box">
|
||||
<div className="stat-value">{departments.length}</div>
|
||||
<div className="stat-label">My departments</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-title" style={{ marginTop: 0 }}>Your departments</div>
|
||||
{departments.length === 0 ? (
|
||||
<div className="empty-state">You're not assigned to any departments.</div>
|
||||
) : (
|
||||
<div className="chip-bar" style={{ marginBottom: 8 }}>
|
||||
{departments.map(d => (
|
||||
<span key={d.id} className="badge badge-outline">
|
||||
<Users2 size={12} strokeWidth={1.75} />
|
||||
{d.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section-title">Upcoming events</div>
|
||||
{loading ? (
|
||||
<div className="empty-state">Loading…</div>
|
||||
) : upcoming.length === 0 ? (
|
||||
<div className="empty-state">Nothing on your calendar in the next 7 days.</div>
|
||||
) : (
|
||||
upcoming.map(ev => (
|
||||
<div key={ev.id} className="card task-card" onClick={() => setOpenEventId(ev.id)}>
|
||||
<CalendarClock size={16} strokeWidth={1.75} color={ev.calendar_color} style={{ marginTop: 2 }} />
|
||||
<div className="task-card-main">
|
||||
<div className="task-card-title">{ev.title}</div>
|
||||
<div className="task-card-meta">
|
||||
<span>{formatDayHeader(new Date(ev.start_at))}{!ev.all_day && ` · ${formatTime(ev.start_at)}`}</span>
|
||||
{ev.location && <span>{ev.location}</span>}
|
||||
<span className="badge-outline badge">{ev.calendar_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{openEventId !== null && (
|
||||
<EventForm
|
||||
eventId={openEventId}
|
||||
onClose={() => setOpenEventId(null)}
|
||||
onSaved={reload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue