From c6b3306a715015a853a6d7318a55cef7eb17151f Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 22:17:17 -0700 Subject: [PATCH] feat(presence): dashboard Who's-here panel + /attendance + /me history + nav Dashboard right column gets a 'Who's here' panel: present members with Host badges + your own Check in / Check in as host / Check out with an optional note (router.refresh after). New /attendance page (all members) lists recent visits with status + duration + notes. /me gains a 'My recent visits' section. Sidebar 'Attendance' link under People. --- app/(app)/attendance/page.tsx | 96 +++++++++++++++++++++ app/(app)/dashboard/page.tsx | 10 ++- app/(app)/dashboard/presence-panel.tsx | 112 +++++++++++++++++++++++++ app/(app)/me/page.tsx | 47 ++++++++++- components/app-sidebar.tsx | 3 +- 5 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 app/(app)/attendance/page.tsx create mode 100644 app/(app)/dashboard/presence-panel.tsx diff --git a/app/(app)/attendance/page.tsx b/app/(app)/attendance/page.tsx new file mode 100644 index 0000000..d2f7990 --- /dev/null +++ b/app/(app)/attendance/page.tsx @@ -0,0 +1,96 @@ +import { redirect } from 'next/navigation' +import { createClient } from '@/lib/supabase/server' +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 { CalendarCheck } from 'lucide-react' +import { listAttendance } from '@/lib/actions' + +export const dynamic = 'force-dynamic' + +type Row = { + id: string + name: string + isHost: boolean + status: 'present' | 'checked_out' | 'stale' + checkedInAt: string + checkedOutAt: string | null + checkInNote: string | null + checkOutNote: string | null +} + +const STATUS: Record = { + present: { label: 'Here now', cls: 'text-primary' }, + checked_out: { label: 'Checked out', cls: 'text-muted-foreground' }, + stale: { label: 'No checkout', cls: 'text-amber-600' }, +} + +function dur(inAt: string, outAt: string | null): string { + const end = outAt ? new Date(outAt).getTime() : Date.now() + const mins = Math.max(0, Math.round((end - new Date(inAt).getTime()) / 60000)) + if (mins < 60) return `${mins}m` + const h = Math.floor(mins / 60) + return `${h}h ${mins % 60}m` +} + +export default async function AttendancePage() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const res = await listAttendance() + const rows = ('data' in res ? res.data : []) as Row[] + + return ( + <> + + Attendance + + +
+ {rows.length === 0 ? ( + + + + No visits recorded yet + + When members check in from the dashboard, their visits appear here. + + + + ) : ( +
+ {rows.map(r => { + const s = STATUS[r.status] + return ( +
+
+
+ {r.name} + {r.isHost && Host} + {s.label} +
+ {(r.checkInNote || r.checkOutNote) && ( +

+ {r.checkInNote ? `in: ${r.checkInNote}` : ''} + {r.checkInNote && r.checkOutNote ? ' · ' : ''} + {r.checkOutNote ? `out: ${r.checkOutNote}` : ''} +

+ )} +
+
+ {new Date(r.checkedInAt).toLocaleString()} +
+ {r.checkedOutAt + ? `${dur(r.checkedInAt, r.checkedOutAt)} visit` + : r.status === 'present' ? `${dur(r.checkedInAt, null)} so far` : 'open'} +
+
+ ) + })} +
+ )} +
+ + ) +} diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 454f727..b2ddbe0 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -4,8 +4,9 @@ import { redirect } from 'next/navigation' import type { Tables } from '@/types/database' import { PageTitle } from '@/components/ui/page-title' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' -import { listSelfEntryDoors } from '@/lib/actions' +import { listSelfEntryDoors, listPresentNow } from '@/lib/actions' import { DoorSelfEntry } from './door-self-entry' +import { PresencePanel } from './presence-panel' function IcoUsers({ className }: { className?: string }) { return @@ -158,8 +159,11 @@ export default async function DashboardPage() { ) } - const selfEntryRes = await listSelfEntryDoors() + const [selfEntryRes, presentRes] = await Promise.all([listSelfEntryDoors(), listPresentNow()]) const selfEntryDoors = ('data' in selfEntryRes ? selfEntryRes.data : []) as { id: string; name: string }[] + const present = ('data' in presentRes ? presentRes.data : []) as { + id: string; name: string; isHost: boolean; isMe: boolean; checkedInAt: string; note: string | null + }[] return (
@@ -329,6 +333,8 @@ export default async function DashboardPage() {
+ + {selfEntryDoors.length > 0 && (

diff --git a/app/(app)/dashboard/presence-panel.tsx b/app/(app)/dashboard/presence-panel.tsx new file mode 100644 index 0000000..a4215b3 --- /dev/null +++ b/app/(app)/dashboard/presence-panel.tsx @@ -0,0 +1,112 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { toast } from 'sonner' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { checkIn, checkOut } from '@/lib/actions' + +type Present = { + id: string + name: string + isHost: boolean + isMe: boolean + checkedInAt: string + note: string | null +} + +export function PresencePanel({ present }: { present: Present[] }) { + const router = useRouter() + const [busy, setBusy] = useState(false) + const [note, setNote] = useState('') + const [showNote, setShowNote] = useState(false) + + const me = present.find(p => p.isMe) + const amHere = !!me + const hostCount = present.filter(p => p.isHost).length + + async function doCheckIn(asHost: boolean) { + setBusy(true) + const res = await checkIn({ asHost, note: note.trim() || null }) + setBusy(false) + if ('error' in res && res.error) return toast.error(res.error) + setNote('') + setShowNote(false) + toast.success(asHost ? 'Checked in as host' : 'Checked in') + router.refresh() + } + + async function doCheckOut() { + setBusy(true) + const res = await checkOut({ note: note.trim() || null }) + setBusy(false) + if ('error' in res && res.error) return toast.error(res.error) + setNote('') + setShowNote(false) + toast.success('Checked out') + router.refresh() + } + + return ( +

+
+

+ Who's here ({present.length}{hostCount > 0 ? ` · ${hostCount} hosting` : ''}) +

+
+ +
+ {present.length === 0 ? ( +

+ Nobody is checked in right now. +

+ ) : ( +
    + {present.map(p => ( +
  • + + {p.name}{p.isMe ? ' (you)' : ''} + {p.note && {p.note}} + + + {p.isHost && Host} + + {new Date(p.checkedInAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + + +
  • + ))} +
+ )} + +
+ {showNote && ( + setNote(e.target.value)} + /> + )} +
+ {amHere ? ( + + ) : ( + <> + + + + )} + +
+
+
+
+ ) +} diff --git a/app/(app)/me/page.tsx b/app/(app)/me/page.tsx index 063f381..0ef548c 100644 --- a/app/(app)/me/page.tsx +++ b/app/(app)/me/page.tsx @@ -8,9 +8,10 @@ import { type CertStatus, } from '@/lib/certifications-logic' import { PERMISSIONS, PERMISSION_CODES } from '@/lib/permissions-catalog' -import { getMyClassSignups, getMyReservations, getMyCards } from '@/lib/actions' +import { getMyClassSignups, getMyReservations, getMyCards, getMyVisits } from '@/lib/actions' import { SIGNUP_STATUS_LABEL, SESSION_STATUS_LABEL } from '@/lib/classes-logic' import { RESERVATION_STATUS_LABEL } from '@/lib/equipment-logic' +import { presenceStatus } from '@/lib/presence-logic' export const dynamic = 'force-dynamic' @@ -107,6 +108,9 @@ export default async function MePage() { const cardsRes = await getMyCards() type MyCard = { id: string; card_type: string; label: string | null; is_active: boolean; last4: string } const myCards: MyCard[] = 'data' in cardsRes ? (cardsRes.data as MyCard[]) : [] + const visitsRes = await getMyVisits() + type MyVisit = { id: string; checked_in_at: string; checked_out_at: string | null; is_host: boolean; check_in_note: string | null; check_out_note: string | null } + const myVisits: MyVisit[] = 'data' in visitsRes ? (visitsRes.data as MyVisit[]) : [] const titleOf = (cs: ClassSignup) => { const sess = sessionOf(cs) const c = sess ? (Array.isArray(sess.classes) ? sess.classes[0] : sess.classes) : null @@ -294,6 +298,47 @@ export default async function MePage() { Only the last 4 digits are shown. The full card number is never displayed here.

+ +
+

+ My recent visits +

+ {myVisits.length === 0 ? ( +

+ No visits yet. Check in from the dashboard when you are at the space. +

+ ) : ( +
    + {myVisits.map(v => { + const st = presenceStatus(v.checked_in_at, v.checked_out_at) + return ( +
  • +
    +
    + + {new Date(v.checked_in_at).toLocaleString()} + + {v.is_host && Host} + {st === 'present' && here now} + {st === 'stale' && no checkout} +
    + {(v.check_in_note || v.check_out_note) && ( +

    + {v.check_in_note ? `in: ${v.check_in_note}` : ''} + {v.check_in_note && v.check_out_note ? ' · ' : ''} + {v.check_out_note ? `out: ${v.check_out_note}` : ''} +

    + )} +
    + + {v.checked_out_at ? `out ${new Date(v.checked_out_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : 'open'} + +
  • + ) + })} +
+ )} +
) diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index c21b37d..16a0253 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -15,7 +15,7 @@ import { MessageSquare, Users, CreditCard, BookUser, Download, LogOut, Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch, MessagesSquare, SlidersHorizontal, ClipboardList, FileText, Award, BadgeCheck, GraduationCap, Hammer, - ChevronDown, DoorClosed, + ChevronDown, DoorClosed, CalendarCheck, } from 'lucide-react' interface NavLinkProps { @@ -182,6 +182,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa + {isAdmin && (