feat(equipment): per-item reservations roster on /equipment/manage
New EquipmentReservations component (listEquipmentReservations, equipment.manage-gated) behind a per-item 'Reservations' toggle: shows who reserved + time range + status, with a manager Cancel. Mirrors the classes SessionAttendance pattern.
This commit is contained in:
@@ -10,6 +10,7 @@ import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/
|
|||||||
import { useConfirm } from '@/components/ui/confirm'
|
import { useConfirm } from '@/components/ui/confirm'
|
||||||
import { createEquipment, updateEquipment, deleteEquipment } from '@/lib/actions'
|
import { createEquipment, updateEquipment, deleteEquipment } from '@/lib/actions'
|
||||||
import { EQUIPMENT_STATUS_LABEL } from '@/lib/equipment-logic'
|
import { EQUIPMENT_STATUS_LABEL } from '@/lib/equipment-logic'
|
||||||
|
import { EquipmentReservations } from '@/components/equipment/equipment-reservations'
|
||||||
|
|
||||||
type Equip = {
|
type Equip = {
|
||||||
id: string
|
id: string
|
||||||
@@ -37,6 +38,7 @@ export function EquipmentManageClient({ initial, certs }: { initial: Equip[]; ce
|
|||||||
const [draft, setDraft] = useState(emptyDraft())
|
const [draft, setDraft] = useState(emptyDraft())
|
||||||
const [editId, setEditId] = useState<string | null>(null)
|
const [editId, setEditId] = useState<string | null>(null)
|
||||||
const [editDraft, setEditDraft] = useState(emptyDraft())
|
const [editDraft, setEditDraft] = useState(emptyDraft())
|
||||||
|
const [resFor, setResFor] = useState<string | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
const certName = (id: string | null) => (id ? certs.find(c => c.id === id)?.name ?? null : null)
|
const certName = (id: string | null) => (id ? certs.find(c => c.id === id)?.name ?? null : null)
|
||||||
@@ -237,12 +239,16 @@ export function EquipmentManageClient({ initial, certs }: { initial: Equip[]; ce
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setResFor(resFor === it.id ? null : it.id)}>
|
||||||
|
{resFor === it.id ? 'Hide reservations' : 'Reservations'}
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => startEdit(it)}>Edit</Button>
|
<Button size="sm" variant="outline" onClick={() => startEdit(it)}>Edit</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => onToggleActive(it)}>{it.is_active ? 'Archive' : 'Restore'}</Button>
|
<Button size="sm" variant="outline" onClick={() => onToggleActive(it)}>{it.is_active ? 'Archive' : 'Restore'}</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => onDelete(it)}>Delete</Button>
|
<Button size="sm" variant="outline" onClick={() => onDelete(it)}>Delete</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{editId !== it.id && resFor === it.id && <EquipmentReservations equipmentId={it.id} />}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { listEquipmentReservations, cancelReservation } from '@/lib/actions'
|
||||||
|
|
||||||
|
type Reservation = {
|
||||||
|
id: string
|
||||||
|
member_id: string
|
||||||
|
starts_at: string
|
||||||
|
ends_at: string
|
||||||
|
status: string
|
||||||
|
notes: string | null
|
||||||
|
space_members: { display_name: string | null; email: string | null } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function who(r: Reservation) {
|
||||||
|
return r.space_members?.display_name ?? r.space_members?.email ?? 'Member'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager view of who reserved a piece of equipment, with a manager cancel.
|
||||||
|
// listEquipmentReservations is equipment.manage-gated server-side.
|
||||||
|
export function EquipmentReservations({ equipmentId }: { equipmentId: string }) {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [rows, setRows] = useState<Reservation[]>([])
|
||||||
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const res = await listEquipmentReservations({ equipmentId })
|
||||||
|
setLoading(false)
|
||||||
|
if ('error' in res && res.error) {
|
||||||
|
toast.error(res.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRows(((res as { data: Reservation[] }).data) ?? [])
|
||||||
|
}, [equipmentId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
async function onCancel(r: Reservation) {
|
||||||
|
setBusy(r.id)
|
||||||
|
const res = await cancelReservation({ reservationId: r.id })
|
||||||
|
setBusy(null)
|
||||||
|
if ('error' in res && res.error) {
|
||||||
|
toast.error(res.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRows(prev => prev.map(x => (x.id === r.id ? { ...x, status: 'cancelled' } : x)))
|
||||||
|
toast.success('Reservation cancelled')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded border border-border bg-background p-3">
|
||||||
|
<p className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-2">Reservations</p>
|
||||||
|
{loading ? (
|
||||||
|
<p className="font-mono text-[10px] text-muted-foreground">Loading…</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="font-mono text-[10px] text-muted-foreground">No reservations.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{rows.map(r => (
|
||||||
|
<li key={r.id} className="py-2 flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="font-sans text-sm text-foreground">{who(r)}</span>
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground ml-2">
|
||||||
|
{new Date(r.starts_at).toLocaleString()} – {new Date(r.ends_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
{' · '}
|
||||||
|
<span className={r.status === 'cancelled' ? 'text-red-500' : 'text-primary'}>{r.status}</span>
|
||||||
|
</span>
|
||||||
|
{r.notes && <p className="font-sans text-xs text-muted-foreground mt-0.5">{r.notes}</p>}
|
||||||
|
</div>
|
||||||
|
{r.status !== 'cancelled' && (
|
||||||
|
<button
|
||||||
|
onClick={() => onCancel(r)}
|
||||||
|
disabled={busy === r.id}
|
||||||
|
className="font-mono text-[10px] border border-border px-3 py-2 rounded hover:border-red-500 hover:text-red-500 transition disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user