Add recurring events, push notifications, config sync — and mobile layout fixes
Recurring event editing (rrule) with this/all occurrence scope, web push subscriptions (VAPID) with a cached config layer for notification settings, and email delivery via nodemailer. Also fixes the calendar view never actually stacking on mobile: the Calendars filter column used flex:1 with minWidth:0 on its sibling, so flex-wrap never triggered regardless of viewport width, squeezing the grid and view switcher into a sliver next to a fixed 220px sidebar. Adds a proper mobile breakpoint that stacks the layout, scrolls the week grid horizontally instead of compressing it, and enlarges touch targets.
This commit is contained in:
parent
dad3cc472c
commit
ca0dc9b070
28 changed files with 1517 additions and 155 deletions
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
|
|
@ -11,7 +11,8 @@
|
|||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
"react-router-dom": "^6.28.0",
|
||||
"rrule": "^2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
|
|
@ -5047,6 +5048,15 @@
|
|||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rrule": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz",
|
||||
"integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
|
||||
|
|
@ -5548,6 +5558,12 @@
|
|||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
"react-router-dom": "^6.28.0",
|
||||
"rrule": "^2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type {
|
||||
Calendar, EventSummary, EventDetail, EventAttachment,
|
||||
ActivityLogEntry, Department, AuthUser, CaldavCredential, CaldavCredentialCreated,
|
||||
AppConfig,
|
||||
} from './types'
|
||||
|
||||
const BASE = '/calendar/api'
|
||||
|
|
@ -53,8 +54,11 @@ export function fetchEvents(filters: EventFilters = {}): Promise<EventSummary[]>
|
|||
const qs = params.toString()
|
||||
return request(`/events${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
export function fetchEvent(id: number): Promise<EventDetail> {
|
||||
return request(`/events/${id}`)
|
||||
// occurrence_start: pass when fetching the effective (merged master+override)
|
||||
// fields for one occurrence of a recurring series rather than the master row.
|
||||
export function fetchEvent(id: number, occurrence_start?: string): Promise<EventDetail> {
|
||||
const qs = occurrence_start ? `?occurrence_start=${encodeURIComponent(occurrence_start)}` : ''
|
||||
return request(`/events/${id}${qs}`)
|
||||
}
|
||||
export interface EventBody {
|
||||
calendar_id: number
|
||||
|
|
@ -66,15 +70,32 @@ export interface EventBody {
|
|||
all_day: boolean
|
||||
departments?: { id: string; name: string }[]
|
||||
assignees?: { email: string; name: string }[]
|
||||
// RFC 5545 RRULE property value, e.g. "FREQ=WEEKLY;BYDAY=MO,WE;COUNT=10" —
|
||||
// bare value, no "RRULE:" prefix and no DTSTART line.
|
||||
rrule?: string | null
|
||||
}
|
||||
export interface EventScopeOptions {
|
||||
// Present only when scope is 'this' — identifies which occurrence.
|
||||
occurrence_start?: string
|
||||
// 'this' edits/deletes just that occurrence (creates/updates a per-occurrence
|
||||
// override); 'all' (default) edits/deletes the whole series (the master row).
|
||||
scope?: 'this' | 'all'
|
||||
}
|
||||
export function createEvent(body: EventBody): Promise<EventDetail> {
|
||||
return request('/events', { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
export function updateEvent(id: number, body: Partial<EventBody>): Promise<EventDetail> {
|
||||
return request(`/events/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
|
||||
export function updateEvent(id: number, body: Partial<EventBody>, opts: EventScopeOptions = {}): Promise<EventDetail> {
|
||||
const payload: Partial<EventBody> & EventScopeOptions = { ...body }
|
||||
if (opts.occurrence_start) payload.occurrence_start = opts.occurrence_start
|
||||
if (opts.scope) payload.scope = opts.scope
|
||||
return request(`/events/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
|
||||
}
|
||||
export function deleteEvent(id: number): Promise<{ ok: boolean }> {
|
||||
return request(`/events/${id}`, { method: 'DELETE' })
|
||||
export function deleteEvent(id: number, opts: EventScopeOptions = {}): Promise<{ ok: boolean }> {
|
||||
const params = new URLSearchParams()
|
||||
if (opts.occurrence_start) params.set('occurrence_start', opts.occurrence_start)
|
||||
if (opts.scope) params.set('scope', opts.scope)
|
||||
const qs = params.toString()
|
||||
return request(`/events/${id}${qs ? `?${qs}` : ''}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// Attachments — multipart, so no JSON content-type header
|
||||
|
|
@ -141,3 +162,22 @@ export async function fetchAssignableUsers(): Promise<AuthUser[]> {
|
|||
if (!res.ok) throw new Error(`Failed to load users: ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// App config — notification settings, admin-editable
|
||||
export function fetchConfig(): Promise<AppConfig> {
|
||||
return request('/config')
|
||||
}
|
||||
export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean }> {
|
||||
return request(`/config/${key}`, { method: 'PATCH', body: JSON.stringify({ value }) })
|
||||
}
|
||||
|
||||
// Push notifications
|
||||
export function fetchVapidKey(): Promise<{ publicKey: string }> {
|
||||
return request('/push/vapid-key')
|
||||
}
|
||||
export function subscribePush(subscription: PushSubscriptionJSON): Promise<{ ok: boolean }> {
|
||||
return request('/push/subscribe', { method: 'POST', body: JSON.stringify(subscription) })
|
||||
}
|
||||
export function unsubscribePush(endpoint?: string): Promise<{ ok: boolean }> {
|
||||
return request('/push/unsubscribe', { method: 'DELETE', body: JSON.stringify(endpoint ? { endpoint } : {}) })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState, createContext, useContext } from 'react'
|
||||
import type { User } from '../types'
|
||||
import { usePushSubscription } from '../hooks/usePushSubscription'
|
||||
|
||||
interface AuthCtx { user: User }
|
||||
const Ctx = createContext<AuthCtx | null>(null)
|
||||
|
|
@ -10,6 +11,11 @@ export function useAuth() {
|
|||
return ctx
|
||||
}
|
||||
|
||||
function PushSubscriber({ user }: { user: User }) {
|
||||
usePushSubscription(user)
|
||||
return null
|
||||
}
|
||||
|
||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
|
||||
|
|
@ -39,6 +45,7 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
|||
|
||||
return (
|
||||
<Ctx.Provider value={{ user }}>
|
||||
<PushSubscriber user={user} />
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { X, Trash2, ChevronDown, ChevronRight, Lock } from 'lucide-react'
|
||||
import { X, Trash2, ChevronDown, ChevronRight, Lock, Repeat } from 'lucide-react'
|
||||
import type { Calendar, Department, EventAttachment, AuthUser, ActivityLogEntry } from '../types'
|
||||
import { can } from '../types'
|
||||
import { useAuth } from './AuthGate'
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from '../api'
|
||||
import type { EventBody } from '../api'
|
||||
import AttachmentList from './AttachmentList'
|
||||
import RecurrenceEditor from './RecurrenceEditor'
|
||||
import { toISODate, toTimeInput } from '../dateUtils'
|
||||
|
||||
interface Assignee { email: string; name: string }
|
||||
|
|
@ -16,14 +17,23 @@ interface Assignee { email: string; name: string }
|
|||
interface EventFormProps {
|
||||
eventId?: number
|
||||
initialDate?: Date
|
||||
// Set (by the caller, after an OccurrenceScopePrompt choice) when the
|
||||
// clicked event is an occurrence of a recurring series.
|
||||
occurrenceStart?: string
|
||||
scope?: 'this' | 'all'
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
export default function EventForm({ eventId, initialDate, onClose, onSaved }: EventFormProps) {
|
||||
export default function EventForm({ eventId, initialDate, occurrenceStart, scope: scopeProp, onClose, onSaved }: EventFormProps) {
|
||||
const { user } = useAuth()
|
||||
const editing = eventId != null
|
||||
const permitted = editing ? can(user, 'edit') : can(user, 'create')
|
||||
const scope: 'this' | 'all' = scopeProp ?? 'all'
|
||||
// Editing a single occurrence — departments/assignees/attachments/repeat
|
||||
// rules are series-level only and can't be touched from this path (see
|
||||
// "deliberate scope limit" in the REST contract).
|
||||
const editingOccurrence = editing && scope === 'this'
|
||||
|
||||
const [loading, setLoading] = useState(editing)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
|
@ -49,6 +59,8 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
|||
const [selectedAssignees, setSelectedAssignees] = useState<Assignee[]>([])
|
||||
const [attachments, setAttachments] = useState<EventAttachment[]>([])
|
||||
const [isSystem, setIsSystem] = useState(false)
|
||||
const [rrule, setRrule] = useState<string | null>(null)
|
||||
const [isRecurring, setIsRecurring] = useState(false)
|
||||
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [history, setHistory] = useState<ActivityLogEntry[] | null>(null)
|
||||
|
|
@ -70,7 +82,9 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
|||
useEffect(() => {
|
||||
if (!editing || !eventId) return
|
||||
setLoading(true)
|
||||
fetchEvent(eventId)
|
||||
// For a 'this'-scoped edit, fetch the merged (master + override) fields
|
||||
// for that one occurrence rather than the master row's own fields.
|
||||
fetchEvent(eventId, editingOccurrence ? occurrenceStart : undefined)
|
||||
.then(ev => {
|
||||
setCalendarId(ev.calendar_id)
|
||||
setTitle(ev.title)
|
||||
|
|
@ -87,9 +101,15 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
|||
setSelectedAssignees(ev.assignees)
|
||||
setAttachments(ev.attachments)
|
||||
setIsSystem(ev.calendar.is_system)
|
||||
setRrule(ev.rrule ?? null)
|
||||
setIsRecurring(!!ev.is_recurring)
|
||||
})
|
||||
.catch(err => setError(err instanceof Error ? err.message : 'Failed to load event'))
|
||||
.finally(() => setLoading(false))
|
||||
// editingOccurrence/occurrenceStart are fixed for the lifetime of a given
|
||||
// EventForm mount (a fresh instance is mounted per open), so this only
|
||||
// ever runs once per event.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventId, editing])
|
||||
|
||||
function toggleDept(dept: Department) {
|
||||
|
|
@ -106,30 +126,41 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
|||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!calendarId) { setError('Choose a calendar'); return }
|
||||
if (!editingOccurrence && !calendarId) { setError('Choose a calendar'); return }
|
||||
if (!title.trim()) { setError('Title is required'); return }
|
||||
|
||||
const start = allDay ? new Date(`${startDate}T00:00:00`) : new Date(`${startDate}T${startTime}:00`)
|
||||
const end = allDay ? new Date(`${endDate}T23:59:00`) : new Date(`${endDate}T${endTime}:00`)
|
||||
if (end.getTime() < start.getTime()) { setError('End must be after start'); return }
|
||||
|
||||
const body: EventBody = {
|
||||
calendar_id: calendarId,
|
||||
const body: Partial<EventBody> = {
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
location: location.trim() || null,
|
||||
start_at: start.toISOString(),
|
||||
end_at: end.toISOString(),
|
||||
all_day: allDay,
|
||||
departments: selectedDepts,
|
||||
assignees: selectedAssignees,
|
||||
}
|
||||
// A 'this'-scoped edit never touches calendar/departments/assignees/rrule
|
||||
// — those are series-level, only changeable via a scope:'all' edit.
|
||||
if (!editingOccurrence) {
|
||||
body.calendar_id = calendarId!
|
||||
body.departments = selectedDepts
|
||||
body.assignees = selectedAssignees
|
||||
body.rrule = rrule
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (editing && eventId) await updateEvent(eventId, body)
|
||||
else await createEvent(body)
|
||||
if (editing && eventId) {
|
||||
await updateEvent(eventId, body, {
|
||||
occurrence_start: editingOccurrence ? occurrenceStart : undefined,
|
||||
scope,
|
||||
})
|
||||
} else {
|
||||
await createEvent(body as EventBody)
|
||||
}
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
|
|
@ -141,11 +172,19 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
|||
|
||||
async function handleDelete() {
|
||||
if (!eventId) return
|
||||
if (!confirm('Delete this event? This cannot be undone.')) return
|
||||
const msg = editingOccurrence
|
||||
? 'Delete this occurrence? Only this date is removed — the rest of the series is unaffected.'
|
||||
: isRecurring
|
||||
? 'Delete this event and every occurrence in its series? This cannot be undone.'
|
||||
: 'Delete this event? This cannot be undone.'
|
||||
if (!confirm(msg)) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await deleteEvent(eventId)
|
||||
await deleteEvent(eventId, {
|
||||
occurrence_start: editingOccurrence ? occurrenceStart : undefined,
|
||||
scope,
|
||||
})
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
|
|
@ -192,6 +231,14 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
|||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{editingOccurrence && (
|
||||
<div className="field-hint" style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12 }}>
|
||||
<Repeat size={13} strokeWidth={1.75} />
|
||||
Editing this occurrence only — calendar, departments, assignees and the repeat rule are
|
||||
set for the whole series and can only be changed there.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>Title</label>
|
||||
<input type="text" value={title} onChange={e => setTitle(e.target.value)} disabled={!permitted} required />
|
||||
|
|
@ -199,12 +246,16 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
|||
|
||||
<div className="field">
|
||||
<label>Calendar</label>
|
||||
<select value={calendarId ?? ''} onChange={e => setCalendarId(Number(e.target.value))} disabled={!permitted}>
|
||||
<option value="" disabled>Choose a calendar…</option>
|
||||
{editableCalendars.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{editingOccurrence ? (
|
||||
<div className="field-hint">{calendars.find(c => c.id === calendarId)?.name ?? '—'}</div>
|
||||
) : (
|
||||
<select value={calendarId ?? ''} onChange={e => setCalendarId(Number(e.target.value))} disabled={!permitted}>
|
||||
<option value="" disabled>Choose a calendar…</option>
|
||||
{editableCalendars.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
|
|
@ -247,47 +298,65 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
|||
)}
|
||||
</div>
|
||||
|
||||
{!editingOccurrence && (
|
||||
<RecurrenceEditor initialRrule={rrule} onChange={setRrule} disabled={!permitted} />
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>Departments</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{departments.map(dept => (
|
||||
<label key={dept.id} className="field-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDepts.some(d => d.id === dept.id)}
|
||||
onChange={() => toggleDept(dept)}
|
||||
disabled={!permitted}
|
||||
/>
|
||||
{dept.name}
|
||||
</label>
|
||||
))}
|
||||
{departments.length === 0 && <span className="field-hint">No departments configured.</span>}
|
||||
</div>
|
||||
{editingOccurrence ? (
|
||||
<div className="chip-bar" style={{ margin: 0 }}>
|
||||
{selectedDepts.length === 0 && <span className="field-hint">None</span>}
|
||||
{selectedDepts.map(d => <span key={d.id} className="badge badge-outline">{d.name}</span>)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{departments.map(dept => (
|
||||
<label key={dept.id} className="field-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDepts.some(d => d.id === dept.id)}
|
||||
onChange={() => toggleDept(dept)}
|
||||
disabled={!permitted}
|
||||
/>
|
||||
{dept.name}
|
||||
</label>
|
||||
))}
|
||||
{departments.length === 0 && <span className="field-hint">No departments configured.</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Assignees</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{users.map(u => (
|
||||
<label key={u.email} className="field-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAssignees.some(a => a.email === u.email)}
|
||||
onChange={() => toggleAssignee(u)}
|
||||
disabled={!permitted}
|
||||
/>
|
||||
{u.name}
|
||||
</label>
|
||||
))}
|
||||
{users.length === 0 && <span className="field-hint">No staff available.</span>}
|
||||
</div>
|
||||
{editingOccurrence ? (
|
||||
<div className="chip-bar" style={{ margin: 0 }}>
|
||||
{selectedAssignees.length === 0 && <span className="field-hint">None</span>}
|
||||
{selectedAssignees.map(a => <span key={a.email} className="badge badge-outline">{a.name}</span>)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{users.map(u => (
|
||||
<label key={u.email} className="field-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAssignees.some(a => a.email === u.email)}
|
||||
onChange={() => toggleAssignee(u)}
|
||||
disabled={!permitted}
|
||||
/>
|
||||
{u.name}
|
||||
</label>
|
||||
))}
|
||||
{users.length === 0 && <span className="field-hint">No staff available.</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing && eventId && (
|
||||
<AttachmentList
|
||||
eventId={eventId}
|
||||
attachments={attachments}
|
||||
canEdit={permitted}
|
||||
canEdit={permitted && !editingOccurrence}
|
||||
onChange={setAttachments}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
32
frontend/src/components/OccurrenceScopePrompt.tsx
Normal file
32
frontend/src/components/OccurrenceScopePrompt.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Repeat } from 'lucide-react'
|
||||
|
||||
// Shown when the user tries to edit/delete an event that's an expanded
|
||||
// occurrence of a recurring series (EventSummary.occurrence_start != null).
|
||||
// Deliberately not a full modal (per spec) — just a small confirm-style
|
||||
// overlay reusing .modal/.btn. There is no "this and future" option.
|
||||
export default function OccurrenceScopePrompt({ title, onChoose, onCancel }: {
|
||||
title: string
|
||||
onChoose: (scope: 'this' | 'all') => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal" style={{ maxWidth: 380 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<Repeat size={16} strokeWidth={1.75} style={{ marginTop: 2 }} />
|
||||
<h2>Recurring event</h2>
|
||||
</div>
|
||||
<p style={{ marginTop: 0, fontSize: 13.5, color: 'var(--text-mid)' }}>
|
||||
"{title}" is part of a recurring series. What would you like to change?
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 }}>
|
||||
<button className="btn" onClick={() => onChoose('this')}>This event</button>
|
||||
<button className="btn btn-primary" onClick={() => onChoose('all')}>All events in the series</button>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-sm" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
204
frontend/src/components/RecurrenceEditor.tsx
Normal file
204
frontend/src/components/RecurrenceEditor.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { RRule } from 'rrule'
|
||||
import { WEEKDAY_LABELS, toISODate } from '../dateUtils'
|
||||
|
||||
// RRule.js weekday constants (MO..SU) — their .weekday numeric value is
|
||||
// 0=Mon..6=Sun, which matches WEEKDAY_LABELS' Monday-first ordering.
|
||||
const RRULE_WEEKDAYS = [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR, RRule.SA, RRule.SU]
|
||||
|
||||
type Freq = 'DAILY' | 'WEEKLY' | 'MONTHLY'
|
||||
type EndType = 'never' | 'count' | 'until'
|
||||
|
||||
interface BuilderState {
|
||||
enabled: boolean
|
||||
freq: Freq
|
||||
interval: number
|
||||
byweekday: Set<number>
|
||||
endType: EndType
|
||||
count: number
|
||||
until: string // ISO date (yyyy-mm-dd)
|
||||
}
|
||||
|
||||
const FREQ_CODE: Record<Freq, number> = { DAILY: RRule.DAILY, WEEKLY: RRule.WEEKLY, MONTHLY: RRule.MONTHLY }
|
||||
|
||||
function parseInitial(rrule: string | null): BuilderState {
|
||||
const fallback: BuilderState = {
|
||||
enabled: false, freq: 'WEEKLY', interval: 1, byweekday: new Set(), endType: 'never', count: 10, until: '',
|
||||
}
|
||||
if (!rrule) return fallback
|
||||
try {
|
||||
const r = RRule.fromString(rrule)
|
||||
const o = r.options
|
||||
const freq: Freq = o.freq === RRule.DAILY ? 'DAILY' : o.freq === RRule.MONTHLY ? 'MONTHLY' : 'WEEKLY'
|
||||
const byweekday = new Set<number>(Array.isArray(o.byweekday) ? o.byweekday : [])
|
||||
const endType: EndType = o.count != null ? 'count' : o.until ? 'until' : 'never'
|
||||
return {
|
||||
enabled: true,
|
||||
freq,
|
||||
interval: o.interval || 1,
|
||||
byweekday,
|
||||
endType,
|
||||
count: o.count ?? 10,
|
||||
until: o.until ? toISODate(new Date(o.until)) : '',
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the bare RRULE property value (no "RRULE:" prefix, no DTSTART line)
|
||||
// that the backend expects — see EventForm.tsx for the contract note.
|
||||
function buildRruleString(s: BuilderState): string | null {
|
||||
if (!s.enabled) return null
|
||||
const opts: ConstructorParameters<typeof RRule>[0] = {
|
||||
freq: FREQ_CODE[s.freq],
|
||||
interval: Math.max(1, s.interval || 1),
|
||||
}
|
||||
if (s.freq === 'WEEKLY' && s.byweekday.size > 0) {
|
||||
opts.byweekday = [...s.byweekday].sort().map(i => RRULE_WEEKDAYS[i])
|
||||
}
|
||||
if (s.endType === 'count') opts.count = Math.max(1, s.count || 1)
|
||||
if (s.endType === 'until' && s.until) opts.until = new Date(`${s.until}T23:59:59Z`)
|
||||
return new RRule(opts).toString().replace(/^RRULE:/, '')
|
||||
}
|
||||
|
||||
export default function RecurrenceEditor({ initialRrule, onChange, disabled }: {
|
||||
initialRrule: string | null
|
||||
onChange: (rrule: string | null) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
// Lazy-initialised once from the loaded event's rrule — this component is
|
||||
// only ever mounted after the event's data has finished loading (or, for a
|
||||
// new event, with initialRrule=null), so it never needs to re-sync later.
|
||||
const [state, setState] = useState<BuilderState>(() => parseInitial(initialRrule))
|
||||
|
||||
useEffect(() => {
|
||||
onChange(buildRruleString(state))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.enabled, state.freq, state.interval, state.endType, state.count, state.until, state.byweekday])
|
||||
|
||||
function toggleDay(i: number) {
|
||||
setState(s => {
|
||||
const next = new Set(s.byweekday)
|
||||
if (next.has(i)) next.delete(i)
|
||||
else next.add(i)
|
||||
return { ...s, byweekday: next }
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="field-check" style={{ marginBottom: state.enabled ? 8 : 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.enabled}
|
||||
onChange={e => setState(s => ({ ...s, enabled: e.target.checked }))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
Repeat
|
||||
</label>
|
||||
|
||||
{state.enabled && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, paddingLeft: 2 }}>
|
||||
<div className="field-row">
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label>Frequency</label>
|
||||
<select
|
||||
value={state.freq}
|
||||
onChange={e => setState(s => ({ ...s, freq: e.target.value as Freq }))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="DAILY">Daily</option>
|
||||
<option value="WEEKLY">Weekly</option>
|
||||
<option value="MONTHLY">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label>Every</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={state.interval}
|
||||
onChange={e => setState(s => ({ ...s, interval: parseInt(e.target.value) || 1 }))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="field-hint">
|
||||
{state.freq === 'DAILY' ? 'day(s)' : state.freq === 'WEEKLY' ? 'week(s)' : 'month(s)'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.freq === 'WEEKLY' && (
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label>On days</label>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{WEEKDAY_LABELS.map((lbl, i) => (
|
||||
<span
|
||||
key={lbl}
|
||||
className={`chip ${state.byweekday.has(i) ? 'active' : ''}`}
|
||||
onClick={() => !disabled && toggleDay(i)}
|
||||
style={{ cursor: disabled ? 'default' : 'pointer' }}
|
||||
>
|
||||
{lbl}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label>Ends</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label className="field-check">
|
||||
<input
|
||||
type="radio"
|
||||
name="rrule-end"
|
||||
checked={state.endType === 'never'}
|
||||
onChange={() => setState(s => ({ ...s, endType: 'never' }))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
Never
|
||||
</label>
|
||||
<label className="field-check">
|
||||
<input
|
||||
type="radio"
|
||||
name="rrule-end"
|
||||
checked={state.endType === 'count'}
|
||||
onChange={() => setState(s => ({ ...s, endType: 'count' }))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
After
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={state.count}
|
||||
onChange={e => setState(s => ({ ...s, endType: 'count', count: parseInt(e.target.value) || 1 }))}
|
||||
disabled={disabled || state.endType !== 'count'}
|
||||
style={{ width: 64 }}
|
||||
/>
|
||||
occurrences
|
||||
</label>
|
||||
<label className="field-check">
|
||||
<input
|
||||
type="radio"
|
||||
name="rrule-end"
|
||||
checked={state.endType === 'until'}
|
||||
onChange={() => setState(s => ({ ...s, endType: 'until' }))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
On date
|
||||
<input
|
||||
type="date"
|
||||
value={state.until}
|
||||
onChange={e => setState(s => ({ ...s, endType: 'until', until: e.target.value }))}
|
||||
disabled={disabled || state.endType !== 'until'}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ export default function AgendaList({ events, onSelectEvent }: {
|
|||
events: EventSummary[]
|
||||
date: Date
|
||||
onSelectDate?: (d: Date) => void
|
||||
onSelectEvent: (id: number) => void
|
||||
onSelectEvent: (ev: EventSummary) => void
|
||||
}) {
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ export default function AgendaList({ events, onSelectEvent }: {
|
|||
<div key={dayKey}>
|
||||
<div className="section-title">{formatDayHeader(new Date(dayKey))}</div>
|
||||
{dayEvents.map(ev => (
|
||||
<div key={ev.id} className="cal-agenda-item" onClick={() => onSelectEvent(ev.id)}>
|
||||
<div key={ev.id} className="cal-agenda-item" onClick={() => onSelectEvent(ev)}>
|
||||
<div className="cal-agenda-date">{ev.all_day ? 'All day' : formatTime(ev.start_at)}</div>
|
||||
<div className="cal-agenda-main">
|
||||
<div className="cal-agenda-title">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export default function DayGrid({ events, date, onSelectDate, onSelectEvent }: {
|
|||
events: EventSummary[]
|
||||
date: Date
|
||||
onSelectDate?: (d: Date) => void
|
||||
onSelectEvent: (id: number) => void
|
||||
onSelectEvent: (ev: EventSummary) => void
|
||||
}) {
|
||||
const allDayEvents = events.filter(ev => ev.all_day && eventOccursOnDay(ev, date))
|
||||
const timedEvents = events.filter(ev => !ev.all_day && eventOccursOnDay(ev, date))
|
||||
|
|
@ -28,7 +28,7 @@ export default function DayGrid({ events, date, onSelectDate, onSelectEvent }: {
|
|||
key={ev.id}
|
||||
className="cal-event-chip"
|
||||
style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }}
|
||||
onClick={() => onSelectEvent(ev.id)}
|
||||
onClick={() => onSelectEvent(ev)}
|
||||
>
|
||||
{ev.title}
|
||||
</span>
|
||||
|
|
@ -53,7 +53,7 @@ export default function DayGrid({ events, date, onSelectDate, onSelectEvent }: {
|
|||
key={ev.id}
|
||||
className="cal-week-event"
|
||||
style={{ top, height, background: ev.calendar_color, color: '#fff' }}
|
||||
onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }}
|
||||
onClick={e => { e.stopPropagation(); onSelectEvent(ev) }}
|
||||
title={ev.title}
|
||||
>
|
||||
{formatTime(ev.start_at)} {ev.title}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export default function MonthGrid({ events, date, onSelectDate, onSelectEvent }:
|
|||
events: EventSummary[]
|
||||
date: Date
|
||||
onSelectDate?: (d: Date) => void
|
||||
onSelectEvent: (id: number) => void
|
||||
onSelectEvent: (ev: EventSummary) => void
|
||||
}) {
|
||||
const days = monthGridDays(date)
|
||||
const today = new Date()
|
||||
|
|
@ -35,7 +35,7 @@ export default function MonthGrid({ events, date, onSelectDate, onSelectEvent }:
|
|||
className="cal-event-chip"
|
||||
style={{ background: ev.calendar_color, color: '#fff' }}
|
||||
title={ev.title}
|
||||
onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }}
|
||||
onClick={e => { e.stopPropagation(); onSelectEvent(ev) }}
|
||||
>
|
||||
{!ev.all_day && `${formatTime(ev.start_at)} `}{ev.title}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export default function WeekGrid({ events, date, onSelectDate, onSelectEvent }:
|
|||
events: EventSummary[]
|
||||
date: Date
|
||||
onSelectDate?: (d: Date) => void
|
||||
onSelectEvent: (id: number) => void
|
||||
onSelectEvent: (ev: EventSummary) => void
|
||||
}) {
|
||||
const weekStart = startOfWeek(date)
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
|
||||
|
|
@ -18,64 +18,66 @@ export default function WeekGrid({ events, date, onSelectDate, onSelectEvent }:
|
|||
const timedByDay = days.map(day => events.filter(ev => !ev.all_day && eventOccursOnDay(ev, day)))
|
||||
|
||||
return (
|
||||
<div className="cal-week-grid">
|
||||
<div className="cal-week-head-cell" />
|
||||
{days.map(day => (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className={`cal-week-head-cell${isSameDay(day, today) ? ' today' : ''}`}
|
||||
onClick={() => onSelectDate?.(day)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{formatDayHeader(day)}
|
||||
</div>
|
||||
))}
|
||||
<div className="cal-week-scroll">
|
||||
<div className="cal-week-grid">
|
||||
<div className="cal-week-head-cell" />
|
||||
{days.map(day => (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className={`cal-week-head-cell${isSameDay(day, today) ? ' today' : ''}`}
|
||||
onClick={() => onSelectDate?.(day)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{formatDayHeader(day)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div />
|
||||
{days.map((day, i) => (
|
||||
<div key={`allday-${day.toISOString()}`} style={{ borderLeft: '1px solid var(--card-border)', padding: '3px' }}>
|
||||
{allDayByDay[i].map(ev => (
|
||||
<span
|
||||
key={ev.id}
|
||||
className="cal-event-chip"
|
||||
style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }}
|
||||
onClick={() => onSelectEvent(ev.id)}
|
||||
>
|
||||
{ev.title}
|
||||
</span>
|
||||
<div />
|
||||
{days.map((day, i) => (
|
||||
<div key={`allday-${day.toISOString()}`} style={{ borderLeft: '1px solid var(--card-border)', padding: '3px' }}>
|
||||
{allDayByDay[i].map(ev => (
|
||||
<span
|
||||
key={ev.id}
|
||||
className="cal-event-chip"
|
||||
style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }}
|
||||
onClick={() => onSelectEvent(ev)}
|
||||
>
|
||||
{ev.title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="cal-time-gutter">
|
||||
{HOURS.map(h => (
|
||||
<div key={h} className="cal-time-row">{h === 0 ? '' : `${h}:00`}</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="cal-time-gutter">
|
||||
{HOURS.map(h => (
|
||||
<div key={h} className="cal-time-row">{h === 0 ? '' : `${h}:00`}</div>
|
||||
{days.map((day, i) => (
|
||||
<div
|
||||
key={`col-${day.toISOString()}`}
|
||||
className="cal-day-col"
|
||||
style={{ height: HOURS.length * HOUR_PX }}
|
||||
onClick={() => onSelectDate?.(day)}
|
||||
>
|
||||
{HOURS.map(h => <div key={h} className="cal-day-col-slot" />)}
|
||||
{timedByDay[i].map(ev => {
|
||||
const { top, height } = timedLayout(ev, day)
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="cal-week-event"
|
||||
style={{ top, height, background: ev.calendar_color, color: '#fff' }}
|
||||
onClick={e => { e.stopPropagation(); onSelectEvent(ev) }}
|
||||
title={ev.title}
|
||||
>
|
||||
{formatTime(ev.start_at)} {ev.title}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{days.map((day, i) => (
|
||||
<div
|
||||
key={`col-${day.toISOString()}`}
|
||||
className="cal-day-col"
|
||||
style={{ height: HOURS.length * HOUR_PX }}
|
||||
onClick={() => onSelectDate?.(day)}
|
||||
>
|
||||
{HOURS.map(h => <div key={h} className="cal-day-col-slot" />)}
|
||||
{timedByDay[i].map(ev => {
|
||||
const { top, height } = timedLayout(ev, day)
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="cal-week-event"
|
||||
style={{ top, height, background: ev.calendar_color, color: '#fff' }}
|
||||
onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }}
|
||||
title={ev.title}
|
||||
>
|
||||
{formatTime(ev.start_at)} {ev.title}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
57
frontend/src/hooks/usePushSubscription.ts
Normal file
57
frontend/src/hooks/usePushSubscription.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useEffect } from 'react'
|
||||
import type { User } from '../types'
|
||||
|
||||
const BASE = '/calendar/api'
|
||||
|
||||
function urlBase64ToUint8Array(base64: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
|
||||
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const raw = atob(b64)
|
||||
const buf = new Uint8Array(raw.length)
|
||||
for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i)
|
||||
return buf.buffer
|
||||
}
|
||||
|
||||
export function usePushSubscription(user: User) {
|
||||
useEffect(() => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return
|
||||
if (Notification.permission === 'denied') return
|
||||
|
||||
async function subscribe() {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
|
||||
const keyRes = await fetch(`${BASE}/push/vapid-key`, { credentials: 'include' })
|
||||
if (!keyRes.ok) return
|
||||
const { publicKey } = await keyRes.json() as { publicKey: string }
|
||||
|
||||
let sub = await reg.pushManager.getSubscription()
|
||||
|
||||
if (!sub) {
|
||||
if (Notification.permission === 'default') {
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') return
|
||||
} else if (Notification.permission !== 'granted') {
|
||||
return
|
||||
}
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
})
|
||||
}
|
||||
|
||||
// Always re-POST so subscription is tied to current user (handles re-login)
|
||||
await fetch(`${BASE}/push/subscribe`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[push] subscription failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
subscribe()
|
||||
}, [user.email])
|
||||
}
|
||||
|
|
@ -121,6 +121,21 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
|
|||
.top-bar { display: flex; }
|
||||
.app-shell { flex-direction: column; }
|
||||
.field-row { flex-direction: column; }
|
||||
|
||||
/* Calendar page: stack the Calendars filter above the grid instead of
|
||||
squeezing both into a shared row — flex-basis:0 on .cal-main means
|
||||
flex-wrap alone never triggers here, so force it with flex-direction. */
|
||||
.cal-layout { flex-direction: column; }
|
||||
.cal-sidebar-card { width: 100%; }
|
||||
|
||||
/* Bigger tap targets for controls staff hit repeatedly on a phone */
|
||||
.btn-sm { padding: 8px 12px; font-size: 13px; }
|
||||
.chip { padding: 8px 14px; }
|
||||
.cal-event-chip { padding: 4px 8px; }
|
||||
|
||||
/* Let the week grid keep usable column widths and scroll horizontally
|
||||
instead of compressing 7 days into an unreadable strip */
|
||||
.cal-week-scroll .cal-week-grid:not(.cal-day-view) { min-width: 640px; }
|
||||
}
|
||||
|
||||
.menu-backdrop {
|
||||
|
|
@ -401,6 +416,14 @@ table.data tr.clickable:hover td { background: var(--body-bg); }
|
|||
Calendar-specific components
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Calendar page layout ──────────────────────────────────────── */
|
||||
.cal-layout { display: flex; gap: 16px; align-items: flex-start; }
|
||||
.cal-sidebar-card { width: 220px; flex-shrink: 0; }
|
||||
.cal-main { flex: 1; min-width: 0; }
|
||||
|
||||
.cal-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.cal-toolbar-label { font-weight: 600; font-size: 14px; flex: 1; min-width: 140px; }
|
||||
|
||||
/* ── View switcher (reuses .chip/.chip.active) ────────────────── */
|
||||
.cal-view-switcher { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
|
|
@ -488,6 +511,7 @@ table.data tr.clickable:hover td { background: var(--body-bg); }
|
|||
.cal-event-more { font-size: 11px; color: var(--text-mid); padding: 1px 6px; cursor: pointer; }
|
||||
|
||||
/* ── Week / day grid ───────────────────────────────────────────── */
|
||||
.cal-week-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.cal-week-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 56px repeat(7, 1fr);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Copy, Plus, Smartphone, Trash2, TriangleAlert } from 'lucide-react'
|
||||
import { Bell, BellOff, Copy, Plus, Smartphone, Trash2, TriangleAlert } from 'lucide-react'
|
||||
import type { CaldavCredential, CaldavCredentialCreated } from '../types'
|
||||
import { fetchCaldavCredentials, createCaldavCredential, deleteCaldavCredential } from '../api'
|
||||
import { fetchCaldavCredentials, createCaldavCredential, deleteCaldavCredential, fetchVapidKey, subscribePush, unsubscribePush } from '../api'
|
||||
|
||||
function urlBase64ToUint8Array(base64: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
|
||||
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const raw = atob(b64)
|
||||
const buf = new Uint8Array(raw.length)
|
||||
for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i)
|
||||
return buf.buffer
|
||||
}
|
||||
|
||||
type PushState = 'unsupported' | 'denied' | 'off' | 'on' | 'checking'
|
||||
|
||||
export default function CalDavSetup() {
|
||||
const [credentials, setCredentials] = useState<CaldavCredential[]>([])
|
||||
|
|
@ -11,6 +22,74 @@ export default function CalDavSetup() {
|
|||
const [creating, setCreating] = useState(false)
|
||||
const [revealed, setRevealed] = useState<CaldavCredentialCreated | null>(null)
|
||||
|
||||
const [pushState, setPushState] = useState<PushState>('checking')
|
||||
const [pushBusy, setPushBusy] = useState(false)
|
||||
const [pushError, setPushError] = useState<string | null>(null)
|
||||
|
||||
const refreshPushState = useCallback(async () => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
setPushState('unsupported')
|
||||
return
|
||||
}
|
||||
if (Notification.permission === 'denied') {
|
||||
setPushState('denied')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
const sub = await reg.pushManager.getSubscription()
|
||||
setPushState(sub ? 'on' : 'off')
|
||||
} catch {
|
||||
setPushState('off')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refreshPushState() }, [refreshPushState])
|
||||
|
||||
async function enablePush() {
|
||||
setPushBusy(true)
|
||||
setPushError(null)
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
if (Notification.permission === 'default') {
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') { setPushState('denied'); return }
|
||||
} else if (Notification.permission === 'denied') {
|
||||
setPushState('denied')
|
||||
return
|
||||
}
|
||||
const { publicKey } = await fetchVapidKey()
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
})
|
||||
await subscribePush(sub.toJSON())
|
||||
setPushState('on')
|
||||
} catch (err) {
|
||||
setPushError(err instanceof Error ? err.message : 'Failed to enable push notifications')
|
||||
} finally {
|
||||
setPushBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function disablePush() {
|
||||
setPushBusy(true)
|
||||
setPushError(null)
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
const sub = await reg.pushManager.getSubscription()
|
||||
if (sub) {
|
||||
await unsubscribePush(sub.endpoint)
|
||||
await sub.unsubscribe()
|
||||
}
|
||||
setPushState('off')
|
||||
} catch (err) {
|
||||
setPushError(err instanceof Error ? err.message : 'Failed to disable push notifications')
|
||||
} finally {
|
||||
setPushBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const caldavUrl = `${window.location.origin}/calendar/caldav/`
|
||||
|
||||
const reload = useCallback(() => {
|
||||
|
|
@ -72,6 +151,34 @@ export default function CalDavSetup() {
|
|||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="section-title">Push notifications</div>
|
||||
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{pushState === 'on' ? (
|
||||
<Bell size={18} strokeWidth={1.75} color="var(--gold)" />
|
||||
) : (
|
||||
<BellOff size={18} strokeWidth={1.75} style={{ color: 'var(--text-mid)' }} />
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13.5 }}>
|
||||
{pushState === 'unsupported' && 'Not supported on this device/browser'}
|
||||
{pushState === 'denied' && 'Blocked — enable notifications for this site in your browser settings'}
|
||||
{pushState === 'checking' && 'Checking status…'}
|
||||
{pushState === 'off' && 'Notifications are off on this device'}
|
||||
{pushState === 'on' && 'Notifications are on for this device'}
|
||||
</div>
|
||||
{pushError && <div className="field-hint" style={{ color: 'var(--danger)' }}>{pushError}</div>}
|
||||
</div>
|
||||
{(pushState === 'off' || pushState === 'on') && (
|
||||
<button
|
||||
className={`btn btn-sm ${pushState === 'off' ? 'btn-primary' : ''}`}
|
||||
onClick={pushState === 'off' ? enablePush : disablePush}
|
||||
disabled={pushBusy}
|
||||
>
|
||||
{pushBusy ? 'Working…' : pushState === 'off' ? 'Enable' : 'Disable'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section-title">Your CalDAV devices</div>
|
||||
{loading ? (
|
||||
<div className="empty-state">Loading…</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
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'
|
||||
import type { Calendar, AppConfig } from '../types'
|
||||
import { can } from '../types'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import { fetchCalendars, createCalendar, updateCalendar, deleteCalendar, fetchConfig, updateConfig } from '../api'
|
||||
|
||||
// Curated swatch — similarly saturated hues that read well against the navy/gold theme.
|
||||
const SWATCHES = [
|
||||
|
|
@ -10,10 +12,17 @@ const SWATCHES = [
|
|||
]
|
||||
|
||||
export default function CalendarSettings() {
|
||||
const { user } = useAuth()
|
||||
const isAdmin = can(user, 'admin')
|
||||
|
||||
const [calendars, setCalendars] = useState<Calendar[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [config, setConfig] = useState<AppConfig | null>(null)
|
||||
const [configError, setConfigError] = useState<string | null>(null)
|
||||
const [configSavingKey, setConfigSavingKey] = useState<string | null>(null)
|
||||
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editColor, setEditColor] = useState(SWATCHES[0])
|
||||
|
|
@ -31,6 +40,27 @@ export default function CalendarSettings() {
|
|||
|
||||
useEffect(() => { reload() }, [reload])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return
|
||||
fetchConfig().then(setConfig).catch(err => setConfigError(err instanceof Error ? err.message : 'Failed to load config'))
|
||||
}, [isAdmin])
|
||||
|
||||
async function saveConfig(key: string, value: unknown) {
|
||||
if (!config) return
|
||||
setConfigSavingKey(key)
|
||||
setConfigError(null)
|
||||
const previous = config
|
||||
setConfig({ ...config, [key]: value })
|
||||
try {
|
||||
await updateConfig(key, value)
|
||||
} catch (err) {
|
||||
setConfig(previous)
|
||||
setConfigError(err instanceof Error ? err.message : 'Failed to save setting')
|
||||
} finally {
|
||||
setConfigSavingKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(cal: Calendar) {
|
||||
setEditingId(cal.id)
|
||||
setEditName(cal.name)
|
||||
|
|
@ -195,6 +225,47 @@ export default function CalendarSettings() {
|
|||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="section-title">Notifications</div>
|
||||
{configError && <div className="error-banner">{configError}</div>}
|
||||
{!config ? (
|
||||
<div className="empty-state">Loading…</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<label className="field-check" style={{ marginBottom: 12 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!config.notify_on_assignment}
|
||||
onChange={e => saveConfig('notify_on_assignment', e.target.checked)}
|
||||
disabled={configSavingKey !== null}
|
||||
/>
|
||||
Notify staff when they're assigned to an event
|
||||
</label>
|
||||
<label className="field-check" style={{ marginBottom: 12 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!config.notify_reminders}
|
||||
onChange={e => saveConfig('notify_reminders', e.target.checked)}
|
||||
disabled={configSavingKey !== null}
|
||||
/>
|
||||
Send reminder notifications before events start
|
||||
</label>
|
||||
<div className="field" style={{ maxWidth: 220, marginBottom: 0 }}>
|
||||
<label>Reminder lead time (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={config.reminder_minutes_before}
|
||||
onChange={e => saveConfig('reminder_minutes_before', parseInt(e.target.value) || 0)}
|
||||
disabled={configSavingKey !== null || !config.notify_reminders}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { fetchCalendars, fetchEvents } from '../api'
|
|||
import CalendarToggleList from '../components/CalendarToggleList'
|
||||
import ViewSwitcher, { type CalendarViewKey } from '../components/ViewSwitcher'
|
||||
import EventForm from '../components/EventForm'
|
||||
import OccurrenceScopePrompt from '../components/OccurrenceScopePrompt'
|
||||
import MonthGrid from '../components/views/MonthGrid'
|
||||
import WeekGrid from '../components/views/WeekGrid'
|
||||
import DayGrid from '../components/views/DayGrid'
|
||||
|
|
@ -55,7 +56,14 @@ export default function CalendarView() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [formState, setFormState] = useState<{ open: boolean; eventId?: number; initialDate?: Date }>({ open: false })
|
||||
const [formState, setFormState] = useState<{
|
||||
open: boolean
|
||||
eventId?: number
|
||||
initialDate?: Date
|
||||
occurrenceStart?: string
|
||||
scope?: 'this' | 'all'
|
||||
}>({ open: false })
|
||||
const [scopePromptEvent, setScopePromptEvent] = useState<EventSummary | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchCalendars().then(cals => {
|
||||
|
|
@ -91,8 +99,26 @@ export default function CalendarView() {
|
|||
if (!can(user, 'create')) return
|
||||
setFormState({ open: true, initialDate: date })
|
||||
}
|
||||
function openEdit(id: number) {
|
||||
setFormState({ open: true, eventId: id })
|
||||
// Recurring occurrences (occurrence_start != null) need the user to choose
|
||||
// "this event" vs "the whole series" before we know what to fetch/save —
|
||||
// a plain, non-recurring event skips straight to the form as before.
|
||||
function openEdit(ev: EventSummary) {
|
||||
if (ev.occurrence_start != null) {
|
||||
setScopePromptEvent(ev)
|
||||
} else {
|
||||
setFormState({ open: true, eventId: ev.id })
|
||||
}
|
||||
}
|
||||
function chooseScope(scope: 'this' | 'all') {
|
||||
const ev = scopePromptEvent
|
||||
setScopePromptEvent(null)
|
||||
if (!ev) return
|
||||
setFormState({
|
||||
open: true,
|
||||
eventId: ev.id,
|
||||
occurrenceStart: scope === 'this' ? (ev.occurrence_start ?? undefined) : undefined,
|
||||
scope,
|
||||
})
|
||||
}
|
||||
function closeForm() {
|
||||
setFormState({ open: false })
|
||||
|
|
@ -119,14 +145,14 @@ export default function CalendarView() {
|
|||
|
||||
{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="cal-layout">
|
||||
<div className="card cal-sidebar-card">
|
||||
<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' }}>
|
||||
<div className="cal-main">
|
||||
<div className="cal-toolbar">
|
||||
<button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, -1))}>
|
||||
<ChevronLeft size={14} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
|
@ -134,7 +160,7 @@ export default function CalendarView() {
|
|||
<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>
|
||||
<div className="cal-toolbar-label">{labelFor(view, currentDate)}</div>
|
||||
<ViewSwitcher view={view} onChange={setView} />
|
||||
</div>
|
||||
|
||||
|
|
@ -156,10 +182,20 @@ export default function CalendarView() {
|
|||
<EventForm
|
||||
eventId={formState.eventId}
|
||||
initialDate={formState.initialDate}
|
||||
occurrenceStart={formState.occurrenceStart}
|
||||
scope={formState.scope}
|
||||
onClose={closeForm}
|
||||
onSaved={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{scopePromptEvent && (
|
||||
<OccurrenceScopePrompt
|
||||
title={scopePromptEvent.title}
|
||||
onChoose={chooseScope}
|
||||
onCancel={() => setScopePromptEvent(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { CalendarClock, Users2 } from 'lucide-react'
|
|||
import type { Department, EventSummary } from '../types'
|
||||
import { fetchMyUpcoming, fetchMyDepartments } from '../api'
|
||||
import EventForm from '../components/EventForm'
|
||||
import OccurrenceScopePrompt from '../components/OccurrenceScopePrompt'
|
||||
import { formatDayHeader, formatTime } from '../dateUtils'
|
||||
|
||||
export default function Dashboard() {
|
||||
|
|
@ -10,7 +11,12 @@ export default function Dashboard() {
|
|||
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 [formState, setFormState] = useState<{
|
||||
eventId: number
|
||||
occurrenceStart?: string
|
||||
scope?: 'this' | 'all'
|
||||
} | null>(null)
|
||||
const [scopePromptEvent, setScopePromptEvent] = useState<EventSummary | null>(null)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setLoading(true)
|
||||
|
|
@ -23,6 +29,24 @@ export default function Dashboard() {
|
|||
|
||||
useEffect(() => { reload() }, [reload])
|
||||
|
||||
function openEvent(ev: EventSummary) {
|
||||
if (ev.occurrence_start != null) {
|
||||
setScopePromptEvent(ev)
|
||||
} else {
|
||||
setFormState({ eventId: ev.id })
|
||||
}
|
||||
}
|
||||
function chooseScope(scope: 'this' | 'all') {
|
||||
const ev = scopePromptEvent
|
||||
setScopePromptEvent(null)
|
||||
if (!ev) return
|
||||
setFormState({
|
||||
eventId: ev.id,
|
||||
occurrenceStart: scope === 'this' ? (ev.occurrence_start ?? undefined) : undefined,
|
||||
scope,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
|
|
@ -63,7 +87,7 @@ export default function Dashboard() {
|
|||
<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)}>
|
||||
<div key={ev.id} className="card task-card" onClick={() => openEvent(ev)}>
|
||||
<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>
|
||||
|
|
@ -77,13 +101,23 @@ export default function Dashboard() {
|
|||
))
|
||||
)}
|
||||
|
||||
{openEventId !== null && (
|
||||
{formState && (
|
||||
<EventForm
|
||||
eventId={openEventId}
|
||||
onClose={() => setOpenEventId(null)}
|
||||
eventId={formState.eventId}
|
||||
occurrenceStart={formState.occurrenceStart}
|
||||
scope={formState.scope}
|
||||
onClose={() => setFormState(null)}
|
||||
onSaved={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{scopePromptEvent && (
|
||||
<OccurrenceScopePrompt
|
||||
title={scopePromptEvent.title}
|
||||
onChoose={chooseScope}
|
||||
onCancel={() => setScopePromptEvent(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,3 +9,27 @@ registerRoute(
|
|||
denylist: [/\/api\//],
|
||||
})
|
||||
)
|
||||
|
||||
self.addEventListener('push', event => {
|
||||
const data = event.data?.json() ?? {}
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'Calendar', {
|
||||
body: data.body || '',
|
||||
icon: '/calendar/icons/icon-192.png',
|
||||
badge: '/calendar/icons/icon-192.png',
|
||||
data: { url: data.url || '/calendar/' },
|
||||
tag: data.tag || 'calendar',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close()
|
||||
const url = event.notification.data?.url || '/calendar/'
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window' }).then(list => {
|
||||
const existing = list.find(c => c.url.includes('/calendar/') && 'focus' in c)
|
||||
return existing ? existing.focus() : clients.openWindow(url)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ export interface EventSummary {
|
|||
department_names: string[]
|
||||
assignee_names: string[]
|
||||
attachment_count: number
|
||||
rrule: string | null
|
||||
// Non-null only when this row is an expanded occurrence of a recurring
|
||||
// series — use it as the stable key for "this specific occurrence" in
|
||||
// edit/delete calls, even though the displayed start_at may differ from
|
||||
// occurrence_start if a per-occurrence override moved it.
|
||||
occurrence_start: string | null
|
||||
}
|
||||
|
||||
export interface EventDepartment {
|
||||
|
|
@ -53,6 +59,10 @@ export interface EventDetail extends EventSummary {
|
|||
color: string
|
||||
is_system: boolean
|
||||
}
|
||||
// Only meaningful when the detail was fetched with ?occurrence_start= —
|
||||
// otherwise absent/false.
|
||||
is_recurring?: boolean
|
||||
is_exception?: boolean
|
||||
}
|
||||
|
||||
export interface ActivityLogEntry {
|
||||
|
|
@ -107,3 +117,12 @@ export interface User {
|
|||
export function can(user: User, cap: string): boolean {
|
||||
return user.is_admin || user.caps.includes(cap)
|
||||
}
|
||||
|
||||
// Flat app config — GET /api/config. Backend may carry additional keys
|
||||
// beyond the ones the UI currently surfaces, hence the index signature.
|
||||
export interface AppConfig {
|
||||
notify_on_assignment: boolean
|
||||
notify_reminders: boolean
|
||||
reminder_minutes_before: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue