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: 1 addition & 5 deletions app/(dashboard)/bookings/deny-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,7 @@ export default function DenyModal({ requestId, onClose, onDenied, kind = 'reques
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(
isRevision
// The revisions route only ever denies, so it takes no status --
// there is no other transition to ask it for.
? { id: requestId, denial_reason: reason.trim() || null }
: { id: requestId, status: 'Denied', denial_reason: reason.trim() || null }
{ id: requestId, status: 'Denied', denial_reason: reason.trim() || null }
),
}
)
Expand Down
7 changes: 7 additions & 0 deletions app/(dashboard)/bookings/edit-one-time-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ interface Body {
}

interface OneTimeSession {
/**
* The stored row this edits, sent back so the save writes it in place rather
* than replacing it -- which is what keeps its calendar invite pointing at the
* same event (issue #69). Absent for a session added here.
*/
id?: string
room_name: string
booking_date: string
start_time: string
Expand Down Expand Up @@ -85,6 +91,7 @@ export default function EditOneTimeForm({ booking, bodies, onClose, onSuccess }:

const [sessions, setSessions] = useState<OneTimeSession[]>(
booking.one_time_room_bookings?.map(d => ({
id: d.id,
room_name: d.room_name ?? '',
booking_date: d.booking_date ?? '',
start_time: d.start_time.slice(0, 5) ?? '',
Expand Down
3 changes: 2 additions & 1 deletion app/(dashboard)/bookings/one-time-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import DateField from '@/app/_components/date-field'
import BookingScopeSelector, { type BookingScopeValue } from '@/app/_components/booking-scope-selector'
import ScopeLabel from '@/app/_components/scope-label'
import { DIVISIONS, type Division, type BookingScope } from '@/lib/booking-scope'
import { isOpenRequestStatus } from '@/lib/request-status'

const STATUSES = [
'Reserved',
Expand Down Expand Up @@ -105,7 +106,7 @@ export default function OneTimeForm({ bodies, semesters, onClose, onSuccess }: O
getJson<{ requests?: PendingRequest[] }>('/api/administrator/requests', {})
.then(({ requests }) => {
setPendingRequests(
(requests ?? []).filter((r: PendingRequest) => r.status === 'Pending' && r.type === 'One-Time Room')
(requests ?? []).filter((r: PendingRequest) => isOpenRequestStatus(r.status) && r.type === 'One-Time Room')
)
})
}, [])
Expand Down
119 changes: 107 additions & 12 deletions app/(dashboard)/bookings/requests-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ import { Skeleton } from '@/app/_components/skeleton'
import ScopeLabel from '@/app/_components/scope-label'
import type { BookingScope, Division } from '@/lib/booking-scope'
import { usePendingActionsWatch } from '../pending-actions-watch'
import {
AWAITING_CSC,
OPS_REVIEW,
isOpenRequestStatus,
type OpenRequestStatus,
type RevisionRequestStatus,
type RoomRequestStatus,
} from '@/lib/request-status'

function RequestsTabSkeleton() {
const card = (wide: boolean) => (
Expand Down Expand Up @@ -38,15 +46,14 @@ function RequestsTabSkeleton() {
)
}

type RequestStatus = 'Pending' | 'Fulfilled' | 'Denied'

interface RevisionRequest {
id: string
change_type: 'Time' | 'Room' | 'Both'
new_start_time: string | null
new_end_time: string | null
new_room: string | null
more_info: string
status: RevisionRequestStatus
created_at: string
bookings: {
id: string
Expand All @@ -64,7 +71,7 @@ interface RoomRequest {
purpose: string
/** Null on tabling, and on room requests made before issue #76. */
capacity: number | null
status: RequestStatus
status: RoomRequestStatus
notes: string | null
created_at: string
scope: BookingScope
Expand Down Expand Up @@ -100,12 +107,32 @@ function formatDate(date: string) {
})
}

const statusColors: Record<RequestStatus, string> = {
Pending: 'bg-[#3d2200] text-[#fb923c]',
const statusColors: Record<RoomRequestStatus | RevisionRequestStatus, string> = {
[OPS_REVIEW]: 'bg-[#3d2200] text-[#fb923c]',
[AWAITING_CSC]: 'bg-[#2a1f4d] text-[#a78bfa]',
Fulfilled: 'bg-[#0f3d20] text-[#4ade80]',
Done: 'bg-[#0f3d20] text-[#4ade80]',
Denied: 'bg-[#3d0f0f] text-[#f87171]',
}

const primaryBtn = 'px-3 py-1 text-sm bg-[#7c3aed] text-white rounded-lg hover:bg-[#6d28d9] disabled:opacity-60'
const secondaryBtn = 'px-3 py-1 text-sm border border-[#1e5080] text-[#93b8d8] rounded-lg hover:text-[#f0f6ff] hover:border-[#93b8d8] disabled:opacity-60'

/**
* The open status an admin can move a request to from the other one (issue
* #128). From Ops Review it is the suggested next step and is drawn as the
* primary action; from Awaiting CSC it is the way back, and is secondary to
* Fulfill and Deny. The order is a suggestion, not a rule.
*/
const OTHER_OPEN_STATUS: Record<OpenRequestStatus, OpenRequestStatus> = {
[OPS_REVIEW]: AWAITING_CSC,
[AWAITING_CSC]: OPS_REVIEW,
}
const MOVE_LABELS: Record<OpenRequestStatus, string> = {
[OPS_REVIEW]: 'Back to Ops Review',
[AWAITING_CSC]: 'Mark Sent to CSC',
}

interface RequestsTabProps {
onCountChange: () => void
}
Expand All @@ -129,6 +156,8 @@ export default function RequestsTab({ onCountChange }: RequestsTabProps) {
linkedBodies: { id: string; name: string }[]
} | null>(null)
const [typeFilter, setTypeFilter] = useState<'all' | BookingScope>('all')
const [moving, setMoving] = useState<string | null>(null)
const [moveError, setMoveError] = useState<{ id: string; message: string } | null>(null)

const fetchRequests = async () => {
const [reqRes, revRes] = await Promise.all([
Expand All @@ -146,6 +175,29 @@ export default function RequestsTab({ onCountChange }: RequestsTabProps) {
fetchRequests()
}, [])

/** Moves a room or revision request to an open status, e.g. Ops Review -> Awaiting CSC. */
const moveTo = async (kind: 'request' | 'revision', id: string, status: OpenRequestStatus) => {
setMoving(id)
setMoveError(null)
try {
const res = await fetch(kind === 'revision' ? '/api/administrator/revisions' : '/api/administrator/requests', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, status }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setMoveError({ id, message: data.error || 'Something went wrong.' })
}
// Refetched either way: a 409 means the row moved underneath this admin,
// and the list should show where it went.
await fetchRequests()
onCountChange()
} finally {
setMoving(null)
}
}

if (loading) return <RequestsTabSkeleton />

if (requests.length === 0 && revisions.length === 0) return <div className="text-[#6a96bb] text-sm">No requests found.</div>
Expand Down Expand Up @@ -176,9 +228,14 @@ export default function RequestsTab({ onCountChange }: RequestsTabProps) {
<span className="mx-2 text-[#1e5080]">·</span>
<span className="text-sm text-[#93b8d8]">{rv.bookings?.type === 'One-Time Room' ? 'One-Time/Multiple Room' : rv.bookings?.type}</span>
</div>
<span className="text-xs font-semibold px-2.5 py-1 rounded-full bg-[#0e2f4f] text-[#4285f4]">
Revision Request
</span>
<div className="flex items-center gap-2 flex-wrap justify-end">
<span className="text-xs font-semibold px-2.5 py-1 rounded-full bg-[#0e2f4f] text-[#4285f4]">
Revision Request
</span>
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${statusColors[rv.status]}`}>
{rv.status}
</span>
</div>
</div>
<div className="text-sm text-[#93b8d8] space-y-1">
<p><span className="font-medium text-[#f0f6ff]">Requested by:</span> {rv.users?.full_name || 'Unknown'}</p>
Expand Down Expand Up @@ -207,14 +264,27 @@ export default function RequestsTab({ onCountChange }: RequestsTabProps) {
is no "Approve" here to pair with this: it would have nothing to
do that opening the booking does not already do.
*/}
<div className="flex gap-2 pt-1">
<div className="flex gap-2 pt-1 flex-wrap">
{isOpenRequestStatus(rv.status) && (
<button
onClick={() => moveTo('revision', rv.id, OTHER_OPEN_STATUS[rv.status as OpenRequestStatus])}
disabled={moving === rv.id}
className={rv.status === OPS_REVIEW ? primaryBtn : secondaryBtn}
>
{MOVE_LABELS[OTHER_OPEN_STATUS[rv.status as OpenRequestStatus]]}
</button>
)}
<button
onClick={() => setDenyingRevision(rv.id)}
className="px-3 py-1.5 text-sm border border-[#1e5080] text-[#f87171] rounded-lg hover:bg-[#3d0f0f] hover:border-[#f87171] transition-colors"
>
Deny
</button>
</div>
{rv.status === AWAITING_CSC && (
<p className="text-xs text-[#6a96bb]">Once CSC responds, edit the booking to grant this, or deny it.</p>
)}
{moveError?.id === rv.id && <p className="text-xs text-[#f87171]">{moveError.message}</p>}
</div>
))}
</div>
Expand Down Expand Up @@ -316,7 +386,7 @@ export default function RequestsTab({ onCountChange }: RequestsTabProps) {
</div>

{/* Actions */}
{r.status === 'Pending' && (
{isOpenRequestStatus(r.status) && (
confirmingDenial === r.id ? (
<div className="flex items-center gap-2">
<span className="text-sm text-[#93b8d8]">Are you sure?</span>
Expand All @@ -334,7 +404,18 @@ export default function RequestsTab({ onCountChange }: RequestsTabProps) {
</button>
</div>
) : (
<div className="flex gap-2 pt-1">
<div className="flex gap-2 pt-1 flex-wrap">
{/*
The suggested next step leads (issue #128): a request in Ops
Review goes to CSC first, and one back from CSC is fulfilled or
denied. Fulfill and Deny stay available from either, since not
every request needs CSC.
*/}
{r.status === OPS_REVIEW && (
<button onClick={() => moveTo('request', r.id, AWAITING_CSC)} disabled={moving === r.id} className={primaryBtn}>
{MOVE_LABELS[AWAITING_CSC]}
</button>
)}
<button
onClick={() => setFulfillingRequest({
id: r.id,
Expand All @@ -356,9 +437,23 @@ export default function RequestsTab({ onCountChange }: RequestsTabProps) {
>
Deny
</button>
{r.status === AWAITING_CSC && (
<button onClick={() => moveTo('request', r.id, OPS_REVIEW)} disabled={moving === r.id} className={secondaryBtn}>
{MOVE_LABELS[OPS_REVIEW]}
</button>
)}
</div>
)
)}
{/* A denial can be reversed; a fulfilment cannot, since a booking is linked to it. */}
{r.status === 'Denied' && (
<div className="flex gap-2 pt-1">
<button onClick={() => moveTo('request', r.id, OPS_REVIEW)} disabled={moving === r.id} className={secondaryBtn}>
Reopen
</button>
</div>
)}
{moveError?.id === r.id && <p className="text-xs text-[#f87171]">{moveError.message}</p>}
</div>
))}
</div>
Expand All @@ -382,7 +477,7 @@ export default function RequestsTab({ onCountChange }: RequestsTabProps) {
kind="revision"
requestId={denyingRevision}
onClose={() => setDenyingRevision(null)}
// fetchRequests drops the row (the GET only returns Pending) and
// fetchRequests drops the row (the GET only returns open ones) and
// onCountChange clears the pending action it was driving.
onDenied={() => { setDenyingRevision(null); fetchRequests(); onCountChange() }}
/>
Expand Down
3 changes: 2 additions & 1 deletion app/(dashboard)/bookings/sga-spaces-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ interface SpaceBooking {
start_time: string
end_time: string
attendee_ids: string[]
external_attendees?: string[] | null
creator_name: string | null
/** The weekly series this booking is one week of (issue #112). */
series_id: string | null
Expand Down Expand Up @@ -308,7 +309,7 @@ function AdminBookingsPanel({ spaces }: { spaces: Space[] }) {
<td className="px-4 py-3 text-[#93b8d8]">{b.creator_name ?? '—'}</td>
<td className="px-4 py-3 text-[#93b8d8] whitespace-nowrap">{formatDateTime(b.start_time)}</td>
<td className="px-4 py-3 text-[#93b8d8] whitespace-nowrap">{formatDateTime(b.end_time)}</td>
<td className="px-4 py-3 text-[#93b8d8]">{(b.attendee_ids ?? []).length + 1}</td>
<td className="px-4 py-3 text-[#93b8d8]">{(b.attendee_ids ?? []).length + (b.external_attendees ?? []).length + 1}</td>
<td className="px-4 py-3">
<button
onClick={() => cancelBooking(b.id)}
Expand Down
3 changes: 2 additions & 1 deletion app/(dashboard)/bookings/tabling-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import DateField from '@/app/_components/date-field'
import BookingScopeSelector, { type BookingScopeValue } from '@/app/_components/booking-scope-selector'
import ScopeLabel from '@/app/_components/scope-label'
import { DIVISIONS, type Division, type BookingScope } from '@/lib/booking-scope'
import { isOpenRequestStatus } from '@/lib/request-status'

const STATUSES = [
'Reserved',
Expand Down Expand Up @@ -109,7 +110,7 @@ export default function TablingForm({ bodies, semesters, onClose, onSuccess }: T
getJson<{ requests?: PendingRequest[] }>('/api/administrator/requests', {})
.then(({ requests }) => {
setPendingRequests(
(requests ?? []).filter((r: PendingRequest) => r.status === 'Pending' && r.type === 'Tabling')
(requests ?? []).filter((r: PendingRequest) => isOpenRequestStatus(r.status) && r.type === 'Tabling')
)
})
}, [])
Expand Down
10 changes: 8 additions & 2 deletions app/(dashboard)/bookings/weekly-booking-grid.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import { Fragment } from 'react'
import type { BookingScope, Division } from '@/lib/booking-scope'
import { formatScopeLabel, type BookingScope, type Division } from '@/lib/booking-scope'

interface WeeklyOccurrence {
id: string
Expand Down Expand Up @@ -159,8 +159,14 @@ export default function WeeklyBookingGrid({ bookings, onBookingClick }: WeeklyBo
</tr>
)}
<tr>
{/*
Named for who the booking is for, not the body that owns the
row: a divisional booking shows its division, and a multi-body
one its owner plus the others (issue #133).
*/}
<td className="pr-4 text-[#93b8d8] whitespace-nowrap py-0.5">
{b.bodies?.name} — {formatTime(w.start_time)}
{formatScopeLabel(b, (b.booking_bodies ?? []).map(x => ({ id: x.body_id, name: x.bodies?.name ?? '' }))).short}
{' — '}{formatTime(w.start_time)}
</td>
{weeks.map(wk => {
const occ = occMap.get(wk)
Expand Down
3 changes: 2 additions & 1 deletion app/(dashboard)/bookings/weekly-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import DateField from '@/app/_components/date-field'
import BookingScopeSelector, { type BookingScopeValue } from '@/app/_components/booking-scope-selector'
import ScopeLabel from '@/app/_components/scope-label'
import { DIVISIONS, type Division, type BookingScope } from '@/lib/booking-scope'
import { isOpenRequestStatus } from '@/lib/request-status'

const STATUSES = [
'Reserved',
Expand Down Expand Up @@ -98,7 +99,7 @@ export default function WeeklyForm({ bodies, semesters, onClose, onSuccess }: We
getJson<{ requests?: PendingRequest[] }>('/api/administrator/requests', {})
.then(({ requests }) => {
setPendingRequests(
(requests ?? []).filter((r: PendingRequest) => r.status === 'Pending' && r.type === 'Weekly Room')
(requests ?? []).filter((r: PendingRequest) => isOpenRequestStatus(r.status) && r.type === 'Weekly Room')
)
})
}, [])
Expand Down
Loading
Loading