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
191 changes: 191 additions & 0 deletions src/components/app/attendance-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import { useCallback, useState } from "react";
import { toast } from "sonner";

import { CheckInPanel } from "@/components/app/check-in-panel";
import { RosterRow } from "@/components/app/dashboard-events";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
type AttendanceMember,
type FullEvent,
type KanaePage,
determineCheckIn,
fmtClock,
} from "@/lib/dashboard-events";

/// Types & interfaces

interface AttendanceDialogProps {
event: FullEvent;
now: Date;
open: boolean;
onOpenChange: (open: boolean) => void;
}

/// Constants

const API_BASE_URL =
(import.meta.env.VITE_API_URL as string | undefined) ?? "http://localhost:8000";
const ROSTER_STAT_CLASS =
"flex-1 rounded-xl border border-border bg-card px-3.5 py-3 shadow-[0px_2px_5px_rgba(112,144,176,0.12)] dark:shadow-[0px_2px_5px_rgba(0,0,0,0.3)]";

/// Tanstack Query options

const eventAttendanceCodeQueryOptions = (eventId: string) =>
queryOptions({
queryKey: ["events", eventId, "attendance-code"],
queryFn: async () => {
const { data } = await axios.get<{ code: string }>(
`${API_BASE_URL}/events/${eventId}/attendance-code`,
);
return data;
},
});

const eventAttendanceQueryOptions = (eventId: string) =>
queryOptions({
queryKey: ["events", eventId, "attendance"],
queryFn: async () => {
const { data } = await axios.get<KanaePage<AttendanceMember>>(
`${API_BASE_URL}/events/${eventId}/attendance`,
{ params: { page: 1, size: 100 } },
);
return data;
},
});

/// AttendanceDialog

export function AttendanceDialog({
event,
now,
open,
onOpenChange,
}: Readonly<AttendanceDialogProps>) {
const queryClient = useQueryClient();

const [rosterTab, setRosterTab] = useState<"qr" | "roster">("qr");
const [copied, setCopied] = useState(false);

const codeQuery = useQuery({
...eventAttendanceCodeQueryOptions(event.id),
enabled: rosterTab === "qr",
});
const rosterQuery = useQuery({
...eventAttendanceQueryOptions(event.id),
enabled: rosterTab === "roster",
});

const { mutate: undoCheckin, isPending: isUndoing } = useMutation({
mutationFn: async (memberId: string) => {
await axios.delete(`${API_BASE_URL}/events/${event.id}/attendance/${memberId}`);
},
onError: () => toast.error("Couldn't undo the check-in. Please try again."),
onSuccess: () => toast.success("Check-in undone."),
onSettled: () =>
queryClient.invalidateQueries({ queryKey: eventAttendanceQueryOptions(event.id).queryKey }),
});

const code = (codeQuery.data?.code ?? "").slice(0, 8);
const copyCode = useCallback(() => {
navigator.clipboard
.writeText(code)
.then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1800);
})
.catch(() => {
toast.error("Couldn't copy the code.");
});
}, [code]);

const rosterMembers = rosterQuery.data?.data ?? [];
const plannedCount = rosterMembers.filter((member) => member.planned).length;
const attendedCount = rosterMembers.filter((member) => member.attended).length;
const checkIn = determineCheckIn(event, now);

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88svh] gap-4 overflow-y-auto sm:max-w-135">
<DialogHeader>
<DialogTitle className="text-lg font-extrabold">Attendance</DialogTitle>
<DialogDescription>{event.name}</DialogDescription>
</DialogHeader>

<Tabs value={rosterTab} onValueChange={setRosterTab} className="gap-4">
<TabsList className="h-10 w-full border border-border">
<TabsTrigger value="qr" className="font-bold data-active:border-border">
Check-in QR
</TabsTrigger>
<TabsTrigger value="roster" className="font-bold data-active:border-border">
Roster · {String(rosterMembers.length)}
</TabsTrigger>
</TabsList>

<TabsContent value="qr" className="flex flex-col items-center gap-4.5">
<CheckInPanel
code={code}
copied={copied}
onCopy={copyCode}
state={checkIn}
closesAt={fmtClock(event.end_at, event.timezone)}
/>
</TabsContent>

<TabsContent value="roster" className="flex flex-col gap-3">
<div className="flex gap-3">
<div className={ROSTER_STAT_CLASS}>
<div className="text-2xl leading-none font-extrabold text-brand-sky">
{String(plannedCount)}
</div>
<div className="mt-1 text-xs font-semibold text-muted-foreground">
Planned (RSVP'd)
</div>
</div>
<div className={ROSTER_STAT_CLASS}>
<div className="text-2xl leading-none font-extrabold text-[#15a66e] dark:text-[#3fd68c]">
{String(attendedCount)}
</div>
<div className="mt-1 text-xs font-semibold text-muted-foreground">Attended</div>
</div>
</div>
<div className="-mx-2 flex max-h-72 flex-col gap-2 overflow-y-auto p-2">
{rosterQuery.isPending && (
<p className="px-2 py-6 text-center text-sm text-muted-foreground">
Loading roster…
</p>
)}
{!rosterQuery.isPending && rosterMembers.length === 0 && (
<p className="px-2 py-6 text-center text-sm text-muted-foreground">
No attendees yet.
</p>
)}
{rosterMembers.map((member) => (
<RosterRow
key={member.id}
member={member}
disabled={isUndoing}
onUndo={undoCheckin}
/>
))}
</div>
<p className="text-[11.5px]/relaxed text-muted-foreground">
Undo clears a member's attended flag but keeps their RSVP — for correcting a mistaken
check-in.
</p>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
105 changes: 105 additions & 0 deletions src/components/app/dashboard-events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ import {
LayoutGrid,
List,
MapPin,
MinusCircle,
MoreHorizontal,
QrCode,
RotateCcw,
ScanLine,
Search,
Ticket,
User,
UserPlus,
X,
} from "lucide-react";
import {
Expand All @@ -32,6 +36,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
Expand All @@ -43,6 +53,7 @@ import {
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
type AttendanceMember,
type DashboardEvent,
type EventType,
type EventView,
Expand All @@ -60,6 +71,8 @@ import { cn } from "@/lib/utils";

/// Types & interfaces

type RosterStatus = "checked_in" | "expected" | "no_show" | "walk_in";

export interface EventCallbacks {
onOpen?: (event: DashboardEvent) => void;
onRsvp?: (event: DashboardEvent, planned: boolean) => void;
Expand Down Expand Up @@ -100,6 +113,29 @@ const LONG_DATE_OPTIONS: Intl.DateTimeFormatOptions = {
day: "numeric",
};

const ROSTER_STATUS = {
checked_in: {
label: "Checked in",
icon: Check,
className: "bg-[#15a66e]/15 text-[#15a66e] dark:text-[#3fd68c]",
},
walk_in: {
label: "Walk-in",
icon: UserPlus,
className: "bg-[#f7b731]/18 text-[#a06d00] dark:text-[#ffd56b]",
},
expected: {
label: "Expected",
icon: Clock,
className: "bg-brand-sky/15 text-brand-sky-text",
},
no_show: {
label: "No-show",
icon: MinusCircle,
className: "bg-muted text-muted-foreground",
},
} satisfies Record<RosterStatus, { className: string; icon: LucideIcon; label: string }>;

/// Helpers

function eventStateMeta(event: DashboardEvent, now: Date): StateMeta {
Expand All @@ -108,6 +144,11 @@ function eventStateMeta(event: DashboardEvent, now: Date): StateMeta {
return event.planned ? STATE_GOING : STATE_OPEN;
}

function getRosterStatus(member: AttendanceMember): RosterStatus {
if (member.attended) return member.planned ? "checked_in" : "walk_in";
return member.planned ? "expected" : "no_show";
}

/// EventTypeChip

interface EventTypeChipProps {
Expand Down Expand Up @@ -859,3 +900,67 @@ export function EventDetailDialog({
</Dialog>
);
}

/// RosterRow

export function RosterRow({
disabled,
member,
onUndo,
}: Readonly<{
disabled: boolean;
member: AttendanceMember;
onUndo: (memberId: string) => void;
}>) {
const handleUndo = useCallback(() => {
onUndo(member.id);
}, [member.id, onUndo]);

const status = ROSTER_STATUS[getRosterStatus(member)];
const initials =
member.name
.match(/\S+/g)
?.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase() ?? "··";

const StatusIcon = status.icon;

return (
<div className="flex items-center gap-3 rounded-xl border border-border bg-card px-3.5 py-2.5 shadow-[0px_2px_5px_rgba(112,144,176,0.12)] dark:shadow-[0px_2px_5px_rgba(0,0,0,0.3)]">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-sky/15 text-[12px] font-extrabold text-brand-sky-text">
{initials}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-foreground">{member.name}</div>
</div>
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11.5px] font-bold",
status.className,
)}
>
<StatusIcon className="size-3.5" />
{status.label}
</span>
{member.attended && (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-sm" />}
title="Member actions"
aria-label="Member actions"
>
<MoreHorizontal />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuItem disabled={disabled} onClick={handleUndo}>
<RotateCcw />
Undo check-in
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}
1 change: 1 addition & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@
}
html {
@apply scroll-smooth font-sans;
scrollbar-gutter: stable;
}
}

Expand Down
Loading