From 746c5969a29c572b7bcdb0bdbc0ce69de8032c7b Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 19:54:16 -0700 Subject: [PATCH] feat(equipment): member browse + reserve + sidebar link /equipment lists active equipment for any member with status and the required-certification gate shown up front (Reserve is disabled when the item is unavailable or the member is not certified). Reserving opens an inline start/end/notes form; the action enforces eligibility and no-overlap server-side. Sidebar gets a member-visible "Equipment" link. --- .../equipment/equipment-browse-client.tsx | 134 ++++++++++++++++++ app/(app)/equipment/page.tsx | 27 ++++ components/app-sidebar.tsx | 1 + 3 files changed, 162 insertions(+) create mode 100644 app/(app)/equipment/equipment-browse-client.tsx create mode 100644 app/(app)/equipment/page.tsx diff --git a/app/(app)/equipment/equipment-browse-client.tsx b/app/(app)/equipment/equipment-browse-client.tsx new file mode 100644 index 0000000..46c7e2b --- /dev/null +++ b/app/(app)/equipment/equipment-browse-client.tsx @@ -0,0 +1,134 @@ +'use client' + +import { useState } from 'react' +import { toast } from 'sonner' +import { Hammer } from 'lucide-react' +import { PageHeader, PageTitle } from '@/components/ui/page-title' +import { Badge } from '@/components/ui/badge' +import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' +import { reserveEquipment } from '@/lib/actions' +import { EQUIPMENT_STATUS_LABEL } from '@/lib/equipment-logic' +import { useRouter } from 'next/navigation' + +type Item = { + id: string + name: string + description: string | null + location: string | null + status: string + required_certification_id: string | null + certifications: { name: string } | { name: string }[] | null + member_certified: boolean +} + +function certName(i: Item): string | null { + const c = Array.isArray(i.certifications) ? i.certifications[0] : i.certifications + return c?.name ?? null +} + +export function EquipmentBrowseClient({ equipment }: { equipment: unknown[] }) { + const router = useRouter() + const items = equipment as Item[] + const [openId, setOpenId] = useState(null) + const [form, setForm] = useState({ starts_at: '', ends_at: '', notes: '' }) + const [busy, setBusy] = useState(false) + + async function onReserve(e: React.FormEvent, item: Item) { + e.preventDefault() + if (!form.starts_at || !form.ends_at) return toast.error('Pick a start and end time') + setBusy(true) + const res = await reserveEquipment({ + equipmentId: item.id, + starts_at: form.starts_at, + ends_at: form.ends_at, + notes: form.notes.trim() || null, + }) + setBusy(false) + if ('error' in res && res.error) return toast.error(res.error) + toast.success('Reserved') + setOpenId(null) + setForm({ starts_at: '', ends_at: '', notes: '' }) + router.refresh() + } + + return ( + <> + + Equipment + + +
+ {items.length === 0 ? ( + + + + No equipment listed + Nothing is available to reserve yet. + + + ) : ( +
    + {items.map(it => { + const needsCert = !!it.required_certification_id + const blockedByCert = needsCert && !it.member_certified + const unavailable = it.status !== 'available' + return ( +
  • +
    +
    +
    + {it.name} + + {EQUIPMENT_STATUS_LABEL[it.status] ?? it.status} + + {needsCert && ( + + {it.member_certified ? `Certified: ${certName(it)}` : `Requires ${certName(it)}`} + + )} +
    + {it.description &&

    {it.description}

    } + {it.location &&

    {it.location}

    } +
    + +
    + + {openId === it.id && !unavailable && !blockedByCert && ( +
    onReserve(e, it)} className="mt-3 rounded border border-border bg-background p-3 grid grid-cols-1 sm:grid-cols-2 gap-2"> + + + setForm({ ...form, notes: e.target.value })} + className="sm:col-span-2 bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-2 focus:outline-none focus:border-primary" /> +
    + +
    +
    + )} +
  • + ) + })} +
+ )} +
+ + ) +} diff --git a/app/(app)/equipment/page.tsx b/app/(app)/equipment/page.tsx new file mode 100644 index 0000000..43e5ab3 --- /dev/null +++ b/app/(app)/equipment/page.tsx @@ -0,0 +1,27 @@ +import { createClient } from '@/lib/supabase/server' +import { redirect } from 'next/navigation' +import { listEquipmentForMembers } from '@/lib/actions' +import { EquipmentBrowseClient } from './equipment-browse-client' + +export const dynamic = 'force-dynamic' + +export default async function EquipmentPage() { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const { data: member } = await supabase + .from('space_members') + .select('space_id') + .eq('user_id', user.id) + .in('status', ['current', 'unverified', 'late']) + .maybeSingle() + if (!member) redirect('/signup') + + const res = await listEquipmentForMembers() + const equipment = 'data' in res ? res.data : [] + + return +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 0004645..457e6fd 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -136,6 +136,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa + {isAdmin && (