Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/(dashboard)/administrator/bookings-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ interface WeeklyBooking extends BookingBase {
status: string | null
reservation_code: string | null
senate_type: string | null
/** Overrides the booking's purpose for this date; null inherits (issue #55). */
purpose: string | null
/** Overrides the booking's hidden flag; null inherits (issue #55). */
hidden: boolean | null
/** Marks this single occurrence as an event. Authoritative, not an override (issue #55). */
is_event: boolean
}[]
}[] | null
}
Expand Down
72 changes: 70 additions & 2 deletions app/(dashboard)/administrator/edit-weekly-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,21 @@ interface Occurrence {
status: string | null
reservation_code: string | null
senate_type: string | null
/** Overrides bookings.purpose for this date; null inherits (issue #55). */
purpose: string | null
/** Overrides bookings.hidden; null inherits, false forces visible (issue #55). */
hidden: boolean | null
/** Marks this single occurrence as an event. Authoritative, not an override (issue #55). */
is_event: boolean
}

interface EditWeeklyFormProps {
booking: {
id: string
body_id: string
purpose: string
/** Only read to label what "Default" means on each occurrence's overrides. */
hidden: boolean
scope: BookingScope
division: Division | null
booking_bodies: { body_id: string; bodies: { name: string } | null }[] | null
Expand Down Expand Up @@ -133,10 +141,14 @@ export default function EditWeeklyForm({ booking, bodies, initialExpandedOcc, on

// Still keyed on the owning body, which stays populated for every scope.
const isSenate = bodies.find(b => b.id === scopeValue.body_id)?.name === 'Senate'
// What an occurrence inherits when its visibility override is left on Default.
const bookingHidden = booking.hidden

if (!w) return null

const updateOccurrence = (id: string, field: keyof Occurrence, value: string | null) => {
// `boolean` in the value union for the visibility override, which is the only
// non-string field here.
const updateOccurrence = (id: string, field: keyof Occurrence, value: string | boolean | null) => {
setOccurrences(prev => prev.map(o => o.id === id ? { ...o, [field]: value } : o))
}

Expand All @@ -149,6 +161,11 @@ export default function EditWeeklyForm({ booking, bodies, initialExpandedOcc, on
status: null,
reservation_code: null,
senate_type: null,
purpose: null,
hidden: null,
// is_event is deliberately not reset: it is not an override, it is a
// statement that this date is an event, and clearing the room and time
// overrides does not stop it being one.
} : o))
}

Expand Down Expand Up @@ -248,7 +265,9 @@ export default function EditWeeklyForm({ booking, bodies, initialExpandedOcc, on
<p className="text-sm font-semibold text-[#f0f6ff]">Occurrences</p>
{getWeeklyDates(form.start_date, form.end_date).map(date => {
const occ = occurrences.find(o => o.occurrence_date === date)
const hasOverride = occ && (occ.room_name || occ.start_time || occ.end_time || occ.status || occ.reservation_code)
// `hidden != null` because false is an override, not an absence.
const hasOverride = occ && (occ.room_name || occ.start_time || occ.end_time || occ.status
|| occ.reservation_code || occ.purpose || occ.hidden != null)
const isExpanded = expandedOcc === date

return (
Expand Down Expand Up @@ -323,6 +342,55 @@ export default function EditWeeklyForm({ booking, bodies, initialExpandedOcc, on
/>
</div>

<div>
<label className={labelCls}>Purpose Override</label>
<input
type="text"
placeholder={`Default: ${form.purpose || 'None'}`}
value={occ.purpose ?? ''}
onChange={e => updateOccurrence(occ.id, 'purpose', e.target.value || null)}
className={inputCls}
/>
</div>

<div>
<label className={labelCls}>Visibility Override</label>
{/* A select rather than a checkbox because there are three
states, not two: inherit, forced visible, forced hidden.
Forced-visible is what lets a single week of a hidden
series be published, and a checkbox cannot express the
difference between that and inheriting a visible parent. */}
<select
value={occ.hidden == null ? '' : occ.hidden ? 'hidden' : 'visible'}
onChange={e =>
updateOccurrence(
occ.id,
'hidden',
e.target.value === '' ? null : e.target.value === 'hidden'
)
}
className={inputCls}
>
<option value="">Default: {bookingHidden ? 'Hidden' : 'Visible'}</option>
<option value="visible">Visible</option>
<option value="hidden">Hidden</option>
</select>
</div>

<label className="flex items-center gap-2 cursor-pointer">
{/* A checkbox here, not a select like the two above, because
this one genuinely is two-state: a weekly event is marked
on the week it happens, so there is no parent value to
inherit and no third option to express. */}
<input
type="checkbox"
checked={occ.is_event}
onChange={e => updateOccurrence(occ.id, 'is_event', e.target.checked)}
className="accent-[#c8102e]"
/>
<span className="text-sm text-[#f0f6ff]">Mark this date as an Event</span>
</label>

{isSenate && (
<div>
<label className={labelCls}>Session Type</label>
Expand Down
6 changes: 6 additions & 0 deletions app/(dashboard)/administrator/weekly-booking-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ interface WeeklyOccurrence {
status: string | null
reservation_code: string | null
senate_type: string | null
/** Overrides the booking's purpose for this date; null inherits (issue #55). */
purpose: string | null
/** Overrides the booking's hidden flag; null inherits (issue #55). */
hidden: boolean | null
/** Marks this single occurrence as an event. Authoritative, not an override (issue #55). */
is_event: boolean
}

interface WeeklyBooking {
Expand Down
40 changes: 33 additions & 7 deletions app/(dashboard)/events/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@ import { Skeleton } from '@/app/_components/skeleton'
import { usePendingActionsWatch } from '../pending-actions-watch'

interface EventBooking {
/**
* A booking event uses the booking's id. A weekly occurrence event uses
* `<bookingId>:<date>`, because every flagged week of one series would
* otherwise share an id -- and this keys both the checklist state and the
* pending-actions highlighting.
*/
id: string
/** The real booking id, set only on occurrence rows where `id` is synthetic. */
booking_id?: string
/** Set when this row is a single flagged week rather than a whole booking. */
occurrence_date: string | null
purpose: string
type: string
created_at: string
Expand Down Expand Up @@ -111,7 +121,14 @@ function BookingDetails({ booking }: { booking: EventBooking }) {
{sessions.map((w, i) => (
<div key={i} className={sessions.length > 1 ? 'border-t border-[#1e5080] pt-1 first:border-0 first:pt-0' : ''}>
{w.room_name && <p><span className="font-medium text-[#f0f6ff]">Room:</span> {w.room_name}</p>}
<p><span className="font-medium text-[#f0f6ff]">Dates:</span> {formatDate(w.start_date)} – {formatDate(w.end_date)}</p>
{/* An occurrence event is one week, so a "Dates: Sep 1 – Sep 1" range
would be noise. The row carries occurrence_date precisely so this
can say Date instead. */}
{booking.occurrence_date ? (
<p><span className="font-medium text-[#f0f6ff]">Date:</span> {formatDate(booking.occurrence_date)}</p>
) : (
<p><span className="font-medium text-[#f0f6ff]">Dates:</span> {formatDate(w.start_date)} – {formatDate(w.end_date)}</p>
)}
<p><span className="font-medium text-[#f0f6ff]">Time:</span> {formatTime(w.start_time)} – {formatTime(w.end_time)}</p>
</div>
))}
Expand Down Expand Up @@ -167,23 +184,32 @@ export default function EventsPage() {
fetchEvents()
}, [])

const updateStep = async (bookingId: string, step: 'event_management_form' | 'engage_form', checked: boolean) => {
// Takes the row rather than an id: the checklist is keyed by the row's id
// (synthetic for an occurrence event) while the save has to address the real
// booking plus, for an occurrence, its date.
const updateStep = async (row: EventBooking, step: 'event_management_form' | 'engage_form', checked: boolean) => {
const key = row.id
// Optimistic update, rolled back below if the save fails.
setChecklist(prev => ({
...prev,
[bookingId]: { ...prev[bookingId], [step]: checked },
[key]: { ...prev[key], [step]: checked },
}))

const res = await fetch('/api/events/checklist', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ booking_id: bookingId, step, checked }),
body: JSON.stringify({
booking_id: row.booking_id ?? row.id,
occurrence_date: row.occurrence_date,
step,
checked,
}),
})

if (!res.ok) {
setChecklist(prev => ({
...prev,
[bookingId]: { ...prev[bookingId], [step]: !checked },
[key]: { ...prev[key], [step]: !checked },
}))
}
}
Expand Down Expand Up @@ -261,14 +287,14 @@ export default function EventsPage() {
checked={steps.event_management_form}
dueDate={b.event_management_form_due}
danger={isActionDanger(`event-form:${b.id}:mgmt`)}
onChange={checked => updateStep(b.id, 'event_management_form', checked)}
onChange={checked => updateStep(b, 'event_management_form', checked)}
/>
<ChecklistRow
label="Engage Form"
checked={steps.engage_form}
dueDate={b.engage_form_due}
danger={isActionDanger(`event-form:${b.id}:engage`)}
onChange={checked => updateStep(b.id, 'engage_form', checked)}
onChange={checked => updateStep(b, 'engage_form', checked)}
/>
</div>
</div>
Expand Down
6 changes: 5 additions & 1 deletion app/(dashboard)/my-rooms/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,11 @@ export function flattenMyRooms(data: MyRoomsResponse, today: string): FlatBookin
bookingId: b.id,
type: 'Weekly Room',
bodyName: b.bodies?.name || '',
purpose: b.purpose,
// `??` rather than the `||` used by the fields below: an occurrence may
// deliberately override the series purpose, and only null means inherit.
// Empty strings are normalised to null when written (see the weekly PATCH
// handler), so they cannot reach here and read as an intentional blank.
purpose: occ.purpose ?? b.purpose,
location: occ.room_name || w.room_name,
date: occ.occurrence_date,
startTime: occ.start_time || w.start_time,
Expand Down
2 changes: 1 addition & 1 deletion app/api/administrator/bookings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export async function GET(request: Request) {
booking_bodies(body_id, bodies(name)),
creator_role,
weekly_room_bookings(id, room_name, start_date, end_date, start_time, end_time, status, reservation_code,
weekly_room_occurrences(id, occurrence_date, room_name, start_time, end_time, status, reservation_code, senate_type)
weekly_room_occurrences(id, occurrence_date, room_name, start_time, end_time, status, reservation_code, senate_type, purpose, hidden, is_event)
)
`)
.eq('type', 'Weekly Room')
Expand Down
39 changes: 38 additions & 1 deletion app/api/administrator/bookings/weekly/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { sendBookingUpdatedEmail } from '@/lib/emails/booking-updated'
import { checkRateLimit } from '@/lib/check-rate-limit'
import { getAuthedUserWithLiveRoles } from '@/lib/authorization'
import { waitUntil } from '@vercel/functions'

import {
loadScopeContext,
validateScopeSelection,
Expand All @@ -14,6 +15,27 @@ import {
type ScopedRow,
} from '@/lib/booking-scope'

/**
* One occurrence as the editor submits it (issue #55).
*
* Everything but the date and is_event is an override -- of the parent series,
* or of the booking above it for purpose and hidden -- where null means inherit.
* is_event is not an override: a weekly event is marked on the week it happens,
* so the occurrence is authoritative and has nothing to inherit from.
*/
interface OccurrenceInput {
occurrence_date: string
room_name: string | null
start_time: string | null
end_time: string | null
status: string | null
reservation_code: string | null
senate_type: string | null
purpose: string | null
hidden: boolean | null
is_event: boolean
}

const adminSupabase = createAdminClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
Expand Down Expand Up @@ -163,7 +185,7 @@ export async function PATCH(request: Request) {

const dates = getWeeklyDates(start_date, end_date)
const newOccurrences = dates.map(date => {
const existing = occurrences.find((o: { occurrence_date: string; room_name: string | null; start_time: string | null; end_time: string | null; status: string | null; reservation_code: string | null; senate_type: string | null }) => o.occurrence_date === date)
const existing = occurrences.find((o: OccurrenceInput) => o.occurrence_date === date)
return {
weekly_booking_id: weekly_id,
occurrence_date: date,
Expand All @@ -173,6 +195,18 @@ export async function PATCH(request: Request) {
status: existing?.status || null,
reservation_code: existing?.reservation_code || null,
senate_type: existing?.senate_type ?? null,
// Issue #55. Both inherit from the booking when null.
//
// purpose is trimmed, and an empty string collapses to null -- clearing the
// field in the editor means "inherit", not "this week has a blank purpose".
purpose: existing?.purpose?.trim() || null,
// `?? null`, not `|| null`: false is meaningful here. It forces an
// occurrence visible even when its series is hidden, and `||` would
// silently turn that back into inherit.
hidden: existing?.hidden ?? null,
// Not an override: the occurrence is where a weekly event is marked, so an
// absent value is simply "not an event" rather than "inherit".
is_event: existing?.is_event ?? false,
}
})

Expand Down Expand Up @@ -206,8 +240,11 @@ export async function PATCH(request: Request) {
const recipients = await resolveBookingRecipients(adminSupabase, scopedRow)

if (recipients.length && auditLog) {
// `hidden != null` rather than a truthiness test: an occurrence forced
// visible (false) has been changed just as much as one forced hidden.
const changedOcc = newOccurrences.find(
o => o.room_name || o.start_time || o.end_time || o.status || o.reservation_code
|| o.purpose || o.hidden != null
) ?? newOccurrences[0]
await adminSupabase.from('user_alerts').insert(
recipients.map(r => ({
Expand Down
21 changes: 18 additions & 3 deletions app/api/events/checklist/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export async function PATCH(request: Request) {
const rateLimitRes = await checkRateLimit(user.id)
if (rateLimitRes) return rateLimitRes

const { booking_id, step, checked } = await request.json()
const { booking_id, occurrence_date, step, checked } = await request.json()

if (!booking_id || !step || typeof checked !== 'boolean') {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
Expand All @@ -30,11 +30,26 @@ export async function PATCH(request: Request) {
return NextResponse.json({ error: 'Invalid step' }, { status: 400 })
}

// Weekly events are marked per occurrence (issue #55), so one booking can own
// several checklists. null addresses the booking's own, which is what one-time
// and tabling events use and what every pre-existing row is.
if (occurrence_date != null && !/^\d{4}-\d{2}-\d{2}$/.test(occurrence_date)) {
return NextResponse.json({ error: 'Invalid occurrence date' }, { status: 400 })
}

const { error } = await adminSupabase
.from('event_tracking')
.upsert(
{ booking_id, [step]: checked, updated_at: new Date().toISOString() },
{ onConflict: 'booking_id' }
{
booking_id,
occurrence_date: occurrence_date ?? null,
[step]: checked,
updated_at: new Date().toISOString(),
},
// Targets the UNIQUE NULLS NOT DISTINCT constraint the migration adds. A
// plain unique index would treat every NULL as distinct and insert a fresh
// booking-level row on each toggle instead of updating the existing one.
{ onConflict: 'booking_id,occurrence_date' }
)

if (error) return NextResponse.json({ error: error.message }, { status: 500 })
Expand Down
Loading
Loading