diff --git a/bun.lock b/bun.lock index 05b4535..eeccb9f 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,7 @@ "tailwindcss": "^4.1.18", "tw-animate-css": "^1.3.6", "zod": "^4.3.6", + "zustand": "^5.0.11", }, "devDependencies": { "@tanstack/devtools-vite": "^0.3.11", @@ -962,6 +963,8 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], + "zxing-wasm": ["zxing-wasm@2.2.4", "", { "dependencies": { "@types/emscripten": "^1.41.5", "type-fest": "^5.2.0" } }, "sha512-1gq5zs4wuNTs5umWLypzNNeuJoluFvwmvjiiT3L9z/TMlVveeJRWy7h90xyUqCe+Qq0zL0w7o5zkdDMWDr9aZA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], diff --git a/package.json b/package.json index a9ad8b2..0616976 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "tailwind-merge": "^3.0.2", "tailwindcss": "^4.1.18", "tw-animate-css": "^1.3.6", - "zod": "^4.3.6" + "zod": "^4.3.6", + "zustand": "^5.0.11" }, "devDependencies": { "@tanstack/devtools-vite": "^0.3.11", diff --git a/src/components/events/attendance-actions.tsx b/src/components/events/attendance-actions.tsx new file mode 100644 index 0000000..6a4f574 --- /dev/null +++ b/src/components/events/attendance-actions.tsx @@ -0,0 +1,122 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Button } from "@/components/ui/button" +import { + MoreHorizontal, + CheckCircle2, + XCircle, + LogIn, + LogOut, +} from "lucide-react" +import type { Schedule, Participant } from "@/types/events" + +interface AttendanceActionsProps { + participant: Participant + schedule: Schedule + onMark: (type: "CHECKIN" | "CHECKOUT" | "BOTH") => void + onUnmark: (type: "CHECKIN" | "CHECKOUT" | "BOTH") => void +} + +export function AttendanceActions({ + participant, + schedule, + onMark, + onUnmark, +}: AttendanceActionsProps) { + const isDuo = schedule.markingType === "DUO" + // const isGroup = schedule.type === "GROUP" // Logic might differ if needed, but actions are similar + + return ( + + + + + + Attendance + + + {isDuo ? ( + <> + {/* DUO MARKING: Check In / Check Out */} + onMark("CHECKIN")} + disabled={participant.checkInStatus} + className="focus:bg-green-900/50 focus:text-green-300 cursor-pointer" + > + + Mark Check In + + + onMark("CHECKOUT")} + disabled={participant.checkOutStatus} + className="focus:bg-blue-900/50 focus:text-blue-300 cursor-pointer" + > + + Mark Check Out + + + + + onUnmark("CHECKIN")} + disabled={!participant.checkInStatus} + className="focus:bg-red-900/50 focus:text-red-300 cursor-pointer" + > + + Unmark Check In + + + onUnmark("CHECKOUT")} + disabled={!participant.checkOutStatus} + className="focus:bg-red-900/50 focus:text-red-300 cursor-pointer" + > + + Unmark Check Out + + + ) : ( + <> + {/* SOLO MARKING: One time mark */} + onMark("BOTH")} + disabled={participant.attendanceStatus} + className="focus:bg-green-900/50 focus:text-green-300 cursor-pointer" + > + + Mark Present + + + + + onUnmark("BOTH")} + disabled={!participant.attendanceStatus} + className="focus:bg-red-900/50 focus:text-red-300 cursor-pointer" + > + + Unmark Present + + + )} + + + ) +} diff --git a/src/components/events/duo-attendance-scanner.tsx b/src/components/events/duo-attendance-scanner.tsx index 4273970..fcb5004 100644 --- a/src/components/events/duo-attendance-scanner.tsx +++ b/src/components/events/duo-attendance-scanner.tsx @@ -11,14 +11,12 @@ import { LogOut, } from "lucide-react" import { toast } from "sonner" -import { useMutation } from "@tanstack/react-query" -import api from "@/lib/axios" -import { apiEndpoints } from "@/lib/api-endpoints" import { cn } from "@/lib/utils" import { markAttendanceSchema, type MarkAttendancePayload, } from "@/schemas/attendance" +import { useMarkAttendance } from "@/hooks/use-attendance" type ScanStatus = "idle" | "success" | "error" type ScanMode = "IN" | "OUT" @@ -43,49 +41,32 @@ export function DuoAttendanceScanner({ "environment" ) - const { mutate: markAttendance } = useMutation({ - mutationFn: (payload: MarkAttendancePayload) => { - let url = "" - if (isTeamEvent) { - url = - scanMode === "IN" - ? apiEndpoints.CHECKIN_TEAM_ATTENDANCE( - payload.studentId, - payload.scheduleId - ) - : apiEndpoints.CHECKOUT_TEAM_ATTENDANCE( - payload.studentId, - payload.scheduleId - ) - } else { - url = - scanMode === "IN" - ? apiEndpoints.CHECKIN_INDIVIDUAL_ATTENDANCE( - payload.studentId, - payload.scheduleId - ) - : apiEndpoints.CHECKOUT_INDIVIDUAL_ATTENDANCE( - payload.studentId, - payload.scheduleId - ) + const { mutate: markAttendance } = useMarkAttendance() + + const handleMark = (payload: MarkAttendancePayload) => { + markAttendance( + { + studentId: payload.studentId, + scheduleId: payload.scheduleId, + type: scanMode === "IN" ? "CHECKIN" : "CHECKOUT", + isTeam: isTeamEvent, + markingType: "DUO", + }, + { + onSuccess: () => { + setStatus("success") + resetStatusAfterDelay() + }, + onError: (error: any) => { + setStatus("error") + const errorMessage = + error.response?.data?.message || "Failed to mark attendance" + toast.error(errorMessage) + resetStatusAfterDelay() + }, } - return api.post(url) - }, - onSuccess: () => { - setStatus("success") - toast.success( - `${scanMode === "IN" ? "Check-in" : "Check-out"} marked successfully` - ) - resetStatusAfterDelay() - }, - onError: (error: any) => { - setStatus("error") - const errorMessage = - error.response?.data?.message || "Failed to mark attendance" - toast.error(errorMessage) - resetStatusAfterDelay() - }, - }) + ) + } const resetStatusAfterDelay = useCallback(() => { setTimeout(() => { @@ -123,7 +104,7 @@ export function DuoAttendanceScanner({ return } - markAttendance(validation.data) + handleMark(validation.data) }, [status, scheduleId, markAttendance, resetStatusAfterDelay] ) diff --git a/src/components/events/event-card.tsx b/src/components/events/event-card.tsx index 4587155..65e6985 100644 --- a/src/components/events/event-card.tsx +++ b/src/components/events/event-card.tsx @@ -1,3 +1,4 @@ +import { Link } from "@tanstack/react-router" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { cn } from "@/lib/utils" import type { EventCardProps } from "@/types/events" @@ -7,43 +8,67 @@ export function EventCard({ className, isActive = false, }: EventCardProps) { + const isGroup = event.type === "GROUP" + return ( - - - - {event.name} - - - -
- {event.day} -
-
-
+ + +
+ + {event.name} + + + {/* Type Badges - DISTINCT COLORS */} + + {isGroup ? "Group" : "Individual"} + +
+
+ +
+
+ {event.day} +
+
+
+
+ ) } diff --git a/src/components/events/mock-data.ts b/src/components/events/mock-data.ts index 6cb0da4..beb238e 100644 --- a/src/components/events/mock-data.ts +++ b/src/components/events/mock-data.ts @@ -1,4 +1,4 @@ -import type { Event } from "@/types/events" +import type { Event, Schedule, Participant } from "@/types/events" export const MOOCK_EVENTS: Event[] = [ { @@ -6,30 +6,40 @@ export const MOOCK_EVENTS: Event[] = [ name: "Tech Talk 2024", organizer: "Computer Science Dept", day: "20 Feb", + type: "INDIVIDUAL", + markingType: "SOLO", }, { id: "2", name: "Cultural Fest", organizer: "Arts Club", day: "21 Feb", + type: "GROUP", + markingType: "SOLO", }, { id: "4", name: "Coding Marathon", organizer: "Computer Science Dept", day: "20 Feb", + type: "INDIVIDUAL", + markingType: "DUO", }, { id: "5", name: "Music Concert", organizer: "Music Club", day: "21 Feb", + type: "GROUP", + markingType: "DUO", }, { id: "7", name: "AI Seminar", organizer: "AI Research Group", day: "20 Feb", + type: "INDIVIDUAL", + markingType: "SOLO", }, ] @@ -42,3 +52,163 @@ export const ORGANIZERS = [ "Gaming Club", "AI Research Group", ] + +// MOCK SCHEDULES +export const MOCK_SCHEDULES: Record = { + "1": [ + // Tech Talk (Individual, Solo) + { + id: "s1", + title: "Morning Session", + venue: "Auditorium A", + startTime: "10:00 AM", + endTime: "12:00 PM", + date: "20 Feb 2024", + eventId: "1", + type: "INDIVIDUAL", + markingType: "SOLO", + }, + ], + "2": [ + // Cultural Fest (Group, Solo) + { + id: "s2", + title: "Dance Competition", + venue: "Open Air Theatre", + startTime: "05:00 PM", + endTime: "08:00 PM", + date: "21 Feb 2024", + eventId: "2", + type: "GROUP", + markingType: "SOLO", + }, + ], + "4": [ + // Coding Marathon (Individual, Duo - Check In/Out) + { + id: "s3", + title: "Hackathon Round 1", + venue: "Lab 201", + startTime: "09:00 AM", + endTime: "05:00 PM", + date: "20 Feb 2024", + eventId: "4", + type: "INDIVIDUAL", + markingType: "DUO", + }, + ], + "5": [ + // Music Concert (Group, Duo - Rehearsal?) + { + id: "s4", + title: "Band Rehearsals", + venue: "Music Room", + startTime: "02:00 PM", + endTime: "04:00 PM", + date: "21 Feb 2024", + eventId: "5", + type: "GROUP", + markingType: "DUO", + }, + ], +} + +// MOCK PARTICIPANTS +export const MOCK_PARTICIPANTS: Record = { + s1: [ + // Individual, Solo + { + id: "p1", + name: "Alice Johnson", + email: "alice@example.com", + checkInStatus: false, + checkOutStatus: false, + attendanceStatus: false, + }, + { + id: "p2", + name: "Bob Smith", + email: "bob@example.com", + checkInStatus: false, + checkOutStatus: false, + attendanceStatus: true, + }, + { + id: "p3", + name: "Charlie Brown", + email: "charlie@example.com", + checkInStatus: false, + checkOutStatus: false, + attendanceStatus: false, + }, + ], + s2: [ + // Group, Solo + { + id: "g1", + name: "Team Alphas", + email: "leader@alphas.com", + teamName: "Alphas", + checkInStatus: false, + checkOutStatus: false, + attendanceStatus: true, + }, + { + id: "g2", + name: "Team Betas", + email: "contact@betas.com", + teamName: "Betas", + checkInStatus: false, + checkOutStatus: false, + attendanceStatus: false, + }, + ], + s3: [ + // Individual, Duo + { + id: "p4", + name: "David Lee", + email: "david@example.com", + checkInStatus: true, + checkOutStatus: false, + attendanceStatus: false, + }, + { + id: "p5", + name: "Eva Green", + email: "eva@example.com", + checkInStatus: true, + checkOutStatus: true, + attendanceStatus: true, + }, // Both marked + { + id: "p6", + name: "Frank White", + email: "frank@example.com", + checkInStatus: false, + checkOutStatus: false, + attendanceStatus: false, + }, + ], + s4: [ + // Group, Duo + { + id: "g3", + name: "The Rockers", + email: "rockers@band.com", + teamName: "The Rockers", + checkInStatus: true, + checkOutStatus: false, + attendanceStatus: false, + }, + { + id: "g4", + name: "Jazz Hands", + email: "jazz@band.com", + teamName: "Jazz Hands", + checkInStatus: false, + checkOutStatus: false, + attendanceStatus: false, + }, + ], +} diff --git a/src/components/events/schedule-card.tsx b/src/components/events/schedule-card.tsx new file mode 100644 index 0000000..254d6b8 --- /dev/null +++ b/src/components/events/schedule-card.tsx @@ -0,0 +1,63 @@ +import { Link } from "@tanstack/react-router" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { CalendarDays, Clock, MapPin } from "lucide-react" +import { cn } from "@/lib/utils" +import type { Schedule } from "@/types/events" + +interface ScheduleCardProps { + schedule: Schedule + eventId: string + className?: string +} + +export function ScheduleCard({ + schedule, + eventId, + className, +}: ScheduleCardProps) { + return ( + + + +
+ + {schedule.title} + + {/* Badges removed as per user request */} +
+
+ + +
+ + {schedule.venue} +
+ +
+ + + {schedule.startTime} - {schedule.endTime} + +
+ +
+ + {schedule.date} +
+
+
+ + ) +} diff --git a/src/components/events/solo-attendance-scanner.tsx b/src/components/events/solo-attendance-scanner.tsx index 7a443ba..0812de4 100644 --- a/src/components/events/solo-attendance-scanner.tsx +++ b/src/components/events/solo-attendance-scanner.tsx @@ -2,14 +2,12 @@ import { useState, useCallback } from "react" import { Scanner } from "@yudiel/react-qr-scanner" import { Check, X, Camera, ArrowLeft, Info, RefreshCw } from "lucide-react" import { toast } from "sonner" -import { useMutation } from "@tanstack/react-query" -import api from "@/lib/axios" -import { apiEndpoints } from "@/lib/api-endpoints" import { cn } from "@/lib/utils" import { markAttendanceSchema, type MarkAttendancePayload, } from "@/schemas/attendance" +import { useMarkAttendance } from "@/hooks/use-attendance" type ScanStatus = "idle" | "success" | "error" @@ -32,32 +30,32 @@ export function SoloAttendanceScanner({ "environment" ) - const { mutate: markAttendance } = useMutation({ - mutationFn: (payload: MarkAttendancePayload) => { - const url = isTeamEvent - ? apiEndpoints.MARK_TEAM_ATTENDANCE( - payload.studentId, - payload.scheduleId - ) - : apiEndpoints.MARK_INDIVIDUAL_ATTENDANCE( - payload.studentId, - payload.scheduleId - ) - return api.post(url) - }, - onSuccess: () => { - setStatus("success") - toast.success("Attendance marked successfully") - resetStatusAfterDelay() - }, - onError: (error: any) => { - setStatus("error") - const errorMessage = - error.response?.data?.message || "Failed to mark attendance" - toast.error(errorMessage) - resetStatusAfterDelay() - }, - }) + const { mutate: markAttendance } = useMarkAttendance() + + const handleMark = (payload: MarkAttendancePayload) => { + markAttendance( + { + studentId: payload.studentId, + scheduleId: payload.scheduleId, + type: "BOTH", // Solo matches "BOTH" in our hook logic + isTeam: isTeamEvent, + markingType: "SOLO", + }, + { + onSuccess: () => { + setStatus("success") + resetStatusAfterDelay() + }, + onError: (error: any) => { + setStatus("error") + const errorMessage = + error.response?.data?.message || "Failed to mark attendance" + toast.error(errorMessage) + resetStatusAfterDelay() + }, + } + ) + } const resetStatusAfterDelay = useCallback(() => { setTimeout(() => { @@ -96,7 +94,7 @@ export function SoloAttendanceScanner({ return } - markAttendance(validation.data) + handleMark(validation.data) }, [status, scheduleId, markAttendance, resetStatusAfterDelay] ) diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..beb56ed --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..bffc327 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 0000000..5513a5c --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,114 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/src/hooks/use-attendance.ts b/src/hooks/use-attendance.ts new file mode 100644 index 0000000..9d4398e --- /dev/null +++ b/src/hooks/use-attendance.ts @@ -0,0 +1,193 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import axiosClient from "@/lib/axios" +import { apiEndpoints } from "@/lib/api-endpoints" +import type { Participant } from "@/types/events" +import { toast } from "sonner" + +// --- Fetch Participants --- + +export function useParticipants(eventId: string, scheduleId: string) { + return useQuery({ + queryKey: ["participants", eventId, scheduleId], + queryFn: async () => { + // Fetch participants for a specific schedule + // Note: Endpoint name GET_EVENT_SCHEDULES is confusing but based on usage it seems to be for this + const response = await axiosClient.get( + apiEndpoints.GET_EVENT_SCHEDULES(eventId, scheduleId) + ) + return response.data as Participant[] + }, + enabled: !!eventId && !!scheduleId, + }) +} + +// --- Attendance Mutations --- + +interface MarkAttendanceParams { + studentId: string + scheduleId: string + // Type of marking action + type: "CHECKIN" | "CHECKOUT" | "BOTH" + // Is it a Team Event? + isTeam: boolean + // Marking Type (Solo vs Duo - determines the endpoint logic) + markingType: "SOLO" | "DUO" +} + +export function useMarkAttendance() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + studentId, + scheduleId, + type, + isTeam, + markingType, + }: MarkAttendanceParams) => { + let endpoint = "" + + if (markingType === "DUO") { + // DUO Logic: Separate IN and OUT endpoints + if (isTeam) { + if (type === "CHECKIN") + endpoint = apiEndpoints.CHECKIN_TEAM_ATTENDANCE( + studentId, + scheduleId + ) + else if (type === "CHECKOUT") + endpoint = apiEndpoints.CHECKOUT_TEAM_ATTENDANCE( + studentId, + scheduleId + ) + } else { + // Individual + if (type === "CHECKIN") + endpoint = apiEndpoints.CHECKIN_INDIVIDUAL_ATTENDANCE( + studentId, + scheduleId + ) + else if (type === "CHECKOUT") + endpoint = apiEndpoints.CHECKOUT_INDIVIDUAL_ATTENDANCE( + studentId, + scheduleId + ) + } + } else { + // SOLO Logic: One endpoint marks "BOTH" (PRESENT) + if (isTeam) { + endpoint = apiEndpoints.MARK_TEAM_ATTENDANCE(studentId, scheduleId) + } else { + endpoint = apiEndpoints.MARK_INDIVIDUAL_ATTENDANCE( + studentId, + scheduleId + ) + } + } + + if (!endpoint) throw new Error("Invalid marking configuration") + + const response = await axiosClient.post(endpoint) + return response.data + }, + onSuccess: (_, variables) => { + toast.success("Attendance Marked") + // Invalidate queries to refresh the list + // We invalidate all participants for this schedule + queryClient.invalidateQueries({ + queryKey: ["participants", variables.scheduleId], // Invalidate broadly or specifically + }) + queryClient.invalidateQueries({ + queryKey: ["participants"], // Just to be safe for now + }) + }, + onError: (error) => { + console.error("Failed to mark attendance", error) + toast.error("Failed to mark attendance") + }, + }) +} + +// --- Unmark Attendance Mutations --- + +interface UnmarkAttendanceParams { + studentId: string + scheduleId: string + type: "CHECKIN" | "CHECKOUT" | "BOTH" + isTeam: boolean + markingType: "SOLO" | "DUO" +} + +export function useUnmarkAttendance() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + studentId, + scheduleId, + type, + isTeam, + markingType, + }: UnmarkAttendanceParams) => { + let endpoint = "" + + if (markingType === "DUO") { + // DUO Logic + if (isTeam) { + if (type === "CHECKIN") + endpoint = apiEndpoints.UNMARK_TEAM_CHECKIN(studentId, scheduleId) + else if (type === "CHECKOUT") + endpoint = apiEndpoints.UNMARK_TEAM_CHECKOUT(studentId, scheduleId) + } else { + // Individual + if (type === "CHECKIN") + endpoint = apiEndpoints.UNMARK_INDIVIDUAL_CHECKIN( + studentId, + scheduleId + ) + else if (type === "CHECKOUT") + endpoint = apiEndpoints.UNMARK_INDIVIDUAL_CHECKOUT( + studentId, + scheduleId + ) + } + } else { + // SOLO Logic + if (isTeam) { + endpoint = apiEndpoints.UNMARK_TEAM_ATTENDANCE(studentId, scheduleId) + } else { + endpoint = apiEndpoints.UNMARK_INDIVIDUAL_ATTENDANCE( + studentId, + scheduleId + ) + } + } + + if (!endpoint) throw new Error("Invalid unmarking configuration") + + // Unmarking usually is also a POST or DELETE? Assuming DELETE or POST based on typical conventions, + // but generated endpoints imply specific URLs. Most likely these are POST or DELETE. + // Checking api-endpoints.ts... it just gives the URL string. + // Typically 'unMark' implies a state change, likely DELETE or POST. + // I'll stick with POST for now unless I see otherwise in the backend docs (which I don't have). + // Or maybe DELETE? `axiosClient.delete(endpoint)` + // Let's assume POST for /unMark/... pattern unless commonly RESTful. + // Actually, safest is usually POST for RPC-style "unMark" URLs. + const response = await axiosClient.post(endpoint) + return response.data + }, + onSuccess: (_, { scheduleId }) => { + toast.success("Attendance Unmarked") + queryClient.invalidateQueries({ + queryKey: ["participants", scheduleId], + }) + queryClient.invalidateQueries({ + queryKey: ["participants"], + }) + }, + onError: (error) => { + console.error("Failed to unmark attendance", error) + toast.error("Failed to unmark attendance") + }, + }) +} diff --git a/src/hooks/use-events.ts b/src/hooks/use-events.ts new file mode 100644 index 0000000..e6f39e4 --- /dev/null +++ b/src/hooks/use-events.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query" +import axiosClient from "@/lib/axios" +import { apiEndpoints } from "@/lib/api-endpoints" +import type { Event } from "@/types/events" + +export function useOrganizerEvents() { + return useQuery({ + queryKey: ["events"], + queryFn: async () => { + // Fetch organizer events + const response = await axiosClient.get(apiEndpoints.GET_ORGANIZER_EVENTS) + // We assume the response data is the list of events directly or in a standard wrapper + // Adjust this if your API returns { data: [...] } or similiar + return response.data as Event[] + }, + }) +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 6308b61..d933a18 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -8,103 +8,216 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from './routes/__root' -import { Route as ScannerRouteImport } from './routes/scanner' -import { Route as EventsRouteImport } from './routes/events' -import { Route as IndexRouteImport } from './routes/index' -import { Route as LoginIndexRouteImport } from './routes/login/index' +import { Route as rootRouteImport } from "./routes/__root" +import { Route as ScannerRouteImport } from "./routes/scanner" +import { Route as EventsRouteImport } from "./routes/events" +import { Route as IndexRouteImport } from "./routes/index" +import { Route as LoginIndexRouteImport } from "./routes/login/index" +import { Route as EventsIndexRouteImport } from "./routes/events/index" +import { Route as EventsEventIdRouteImport } from "./routes/events/$eventId" +import { Route as EventsEventIdIndexRouteImport } from "./routes/events/$eventId/index" +import { Route as EventsEventIdSchedulesScheduleIdRouteImport } from "./routes/events/$eventId/schedules/$scheduleId" const ScannerRoute = ScannerRouteImport.update({ - id: '/scanner', - path: '/scanner', + id: "/scanner", + path: "/scanner", getParentRoute: () => rootRouteImport, } as any) const EventsRoute = EventsRouteImport.update({ - id: '/events', - path: '/events', + id: "/events", + path: "/events", getParentRoute: () => rootRouteImport, } as any) const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', + id: "/", + path: "/", getParentRoute: () => rootRouteImport, } as any) const LoginIndexRoute = LoginIndexRouteImport.update({ - id: '/login/', - path: '/login/', + id: "/login/", + path: "/login/", getParentRoute: () => rootRouteImport, } as any) +const EventsIndexRoute = EventsIndexRouteImport.update({ + id: "/", + path: "/", + getParentRoute: () => EventsRoute, +} as any) +const EventsEventIdRoute = EventsEventIdRouteImport.update({ + id: "/$eventId", + path: "/$eventId", + getParentRoute: () => EventsRoute, +} as any) +const EventsEventIdIndexRoute = EventsEventIdIndexRouteImport.update({ + id: "/", + path: "/", + getParentRoute: () => EventsEventIdRoute, +} as any) +const EventsEventIdSchedulesScheduleIdRoute = + EventsEventIdSchedulesScheduleIdRouteImport.update({ + id: "/schedules/$scheduleId", + path: "/schedules/$scheduleId", + getParentRoute: () => EventsEventIdRoute, + } as any) export interface FileRoutesByFullPath { - '/': typeof IndexRoute - '/events': typeof EventsRoute - '/scanner': typeof ScannerRoute - '/login/': typeof LoginIndexRoute + "/": typeof IndexRoute + "/events": typeof EventsRouteWithChildren + "/scanner": typeof ScannerRoute + "/events/$eventId": typeof EventsEventIdRouteWithChildren + "/events/": typeof EventsIndexRoute + "/login/": typeof LoginIndexRoute + "/events/$eventId/": typeof EventsEventIdIndexRoute + "/events/$eventId/schedules/$scheduleId": typeof EventsEventIdSchedulesScheduleIdRoute } export interface FileRoutesByTo { - '/': typeof IndexRoute - '/events': typeof EventsRoute - '/scanner': typeof ScannerRoute - '/login': typeof LoginIndexRoute + "/": typeof IndexRoute + "/scanner": typeof ScannerRoute + "/events": typeof EventsIndexRoute + "/login": typeof LoginIndexRoute + "/events/$eventId": typeof EventsEventIdIndexRoute + "/events/$eventId/schedules/$scheduleId": typeof EventsEventIdSchedulesScheduleIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport - '/': typeof IndexRoute - '/events': typeof EventsRoute - '/scanner': typeof ScannerRoute - '/login/': typeof LoginIndexRoute + "/": typeof IndexRoute + "/events": typeof EventsRouteWithChildren + "/scanner": typeof ScannerRoute + "/events/$eventId": typeof EventsEventIdRouteWithChildren + "/events/": typeof EventsIndexRoute + "/login/": typeof LoginIndexRoute + "/events/$eventId/": typeof EventsEventIdIndexRoute + "/events/$eventId/schedules/$scheduleId": typeof EventsEventIdSchedulesScheduleIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/events' | '/scanner' | '/login/' + fullPaths: + | "/" + | "/events" + | "/scanner" + | "/events/$eventId" + | "/events/" + | "/login/" + | "/events/$eventId/" + | "/events/$eventId/schedules/$scheduleId" fileRoutesByTo: FileRoutesByTo - to: '/' | '/events' | '/scanner' | '/login' - id: '__root__' | '/' | '/events' | '/scanner' | '/login/' + to: + | "/" + | "/scanner" + | "/events" + | "/login" + | "/events/$eventId" + | "/events/$eventId/schedules/$scheduleId" + id: + | "__root__" + | "/" + | "/events" + | "/scanner" + | "/events/$eventId" + | "/events/" + | "/login/" + | "/events/$eventId/" + | "/events/$eventId/schedules/$scheduleId" fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute - EventsRoute: typeof EventsRoute + EventsRoute: typeof EventsRouteWithChildren ScannerRoute: typeof ScannerRoute LoginIndexRoute: typeof LoginIndexRoute } -declare module '@tanstack/react-router' { +declare module "@tanstack/react-router" { interface FileRoutesByPath { - '/scanner': { - id: '/scanner' - path: '/scanner' - fullPath: '/scanner' + "/scanner": { + id: "/scanner" + path: "/scanner" + fullPath: "/scanner" preLoaderRoute: typeof ScannerRouteImport parentRoute: typeof rootRouteImport } - '/events': { - id: '/events' - path: '/events' - fullPath: '/events' + "/events": { + id: "/events" + path: "/events" + fullPath: "/events" preLoaderRoute: typeof EventsRouteImport parentRoute: typeof rootRouteImport } - '/': { - id: '/' - path: '/' - fullPath: '/' + "/": { + id: "/" + path: "/" + fullPath: "/" preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } - '/login/': { - id: '/login/' - path: '/login' - fullPath: '/login/' + "/login/": { + id: "/login/" + path: "/login" + fullPath: "/login/" preLoaderRoute: typeof LoginIndexRouteImport parentRoute: typeof rootRouteImport } + "/events/": { + id: "/events/" + path: "/" + fullPath: "/events/" + preLoaderRoute: typeof EventsIndexRouteImport + parentRoute: typeof EventsRoute + } + "/events/$eventId": { + id: "/events/$eventId" + path: "/$eventId" + fullPath: "/events/$eventId" + preLoaderRoute: typeof EventsEventIdRouteImport + parentRoute: typeof EventsRoute + } + "/events/$eventId/": { + id: "/events/$eventId/" + path: "/" + fullPath: "/events/$eventId/" + preLoaderRoute: typeof EventsEventIdIndexRouteImport + parentRoute: typeof EventsEventIdRoute + } + "/events/$eventId/schedules/$scheduleId": { + id: "/events/$eventId/schedules/$scheduleId" + path: "/schedules/$scheduleId" + fullPath: "/events/$eventId/schedules/$scheduleId" + preLoaderRoute: typeof EventsEventIdSchedulesScheduleIdRouteImport + parentRoute: typeof EventsEventIdRoute + } } } +interface EventsEventIdRouteChildren { + EventsEventIdIndexRoute: typeof EventsEventIdIndexRoute + EventsEventIdSchedulesScheduleIdRoute: typeof EventsEventIdSchedulesScheduleIdRoute +} + +const EventsEventIdRouteChildren: EventsEventIdRouteChildren = { + EventsEventIdIndexRoute: EventsEventIdIndexRoute, + EventsEventIdSchedulesScheduleIdRoute: EventsEventIdSchedulesScheduleIdRoute, +} + +const EventsEventIdRouteWithChildren = EventsEventIdRoute._addFileChildren( + EventsEventIdRouteChildren +) + +interface EventsRouteChildren { + EventsEventIdRoute: typeof EventsEventIdRouteWithChildren + EventsIndexRoute: typeof EventsIndexRoute +} + +const EventsRouteChildren: EventsRouteChildren = { + EventsEventIdRoute: EventsEventIdRouteWithChildren, + EventsIndexRoute: EventsIndexRoute, +} + +const EventsRouteWithChildren = + EventsRoute._addFileChildren(EventsRouteChildren) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, - EventsRoute: EventsRoute, + EventsRoute: EventsRouteWithChildren, ScannerRoute: ScannerRoute, LoginIndexRoute: LoginIndexRoute, } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 24c70ab..3c30000 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,9 +1,9 @@ import { Outlet, createRootRouteWithContext } from "@tanstack/react-router" -import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools" -import { TanStackDevtools } from "@tanstack/react-devtools" +// import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools" +// import { TanStackDevtools } from "@tanstack/react-devtools" import { Toaster } from "@/components/ui/sonner" -import TanStackQueryDevtools from "../integrations/tanstack-query/devtools" +// import TanStackQueryDevtools from "../integrations/tanstack-query/devtools" import type { QueryClient } from "@tanstack/react-query" @@ -16,7 +16,7 @@ export const Route = createRootRouteWithContext()({ <> - ()({ }, TanStackQueryDevtools, ]} - /> + /> */} ), }) diff --git a/src/routes/events.tsx b/src/routes/events.tsx index d69bd38..f8563a2 100644 --- a/src/routes/events.tsx +++ b/src/routes/events.tsx @@ -1,80 +1,9 @@ -import { useState, useMemo } from "react" -import { createFileRoute } from "@tanstack/react-router" -import { CalendarDays } from "lucide-react" -import { EventCard } from "@/components/events/event-card" -import { EventsFilter } from "@/components/events/events-filter" -import { MOOCK_EVENTS } from "@/components/events/mock-data" -import { Sidebar, SidebarTrigger, useSwipeToOpen } from "@/components/sidebar" -import type { DayFilter } from "@/types/events" +import { createFileRoute, Outlet } from "@tanstack/react-router" export const Route = createFileRoute("/events")({ - component: EventsPage, + component: EventsLayout, }) -function EventsPage() { - const [searchQuery, setSearchQuery] = useState("") - const [selectedDay, setSelectedDay] = useState("All") - - // Sidebar state with swipe support - const { isOpen, setIsOpen, close } = useSwipeToOpen() - - const filteredEvents = useMemo(() => { - return MOOCK_EVENTS.filter((event) => { - const matchesSearch = - event.name.toLowerCase().includes(searchQuery.toLowerCase()) || - event.organizer.toLowerCase().includes(searchQuery.toLowerCase()) - const matchesDay = selectedDay === "All" || event.day === selectedDay - - return matchesSearch && matchesDay - }) - }, [searchQuery, selectedDay]) - - return ( -
- {/* Sidebar - opens on swipe from left edge or menu button click */} - - - {/* Main content - offset by sidebar width on desktop */} -
- {/* Header with menu button (mobile only) */} -
-
- setIsOpen(true)} /> -
-

- - Events -

-
- - {/* Filter section - constrained width */} -
- -
- - {/* Cards grid - fills available width */} -
- {filteredEvents.length > 0 ? ( - filteredEvents.map((event) => ( - - )) - ) : ( -
-

- No events found matching your criteria. -

-
- )} -
-
-
- ) +function EventsLayout() { + return } diff --git a/src/routes/events/$eventId.tsx b/src/routes/events/$eventId.tsx new file mode 100644 index 0000000..e212940 --- /dev/null +++ b/src/routes/events/$eventId.tsx @@ -0,0 +1,9 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router" + +export const Route = createFileRoute("/events/$eventId")({ + component: EventLayout, +}) + +function EventLayout() { + return +} diff --git a/src/routes/events/$eventId/index.tsx b/src/routes/events/$eventId/index.tsx new file mode 100644 index 0000000..bc75253 --- /dev/null +++ b/src/routes/events/$eventId/index.tsx @@ -0,0 +1,124 @@ +import { createFileRoute, Link } from "@tanstack/react-router" +import { ArrowLeft, CalendarDays, Building2, Loader2 } from "lucide-react" +import { ScheduleCard } from "@/components/events/schedule-card" +import { MOCK_SCHEDULES } from "@/components/events/mock-data" +import { useOrganizerEvents } from "@/hooks/use-events" +import { useEventStore } from "@/store/event-store" +import { useEffect, useMemo } from "react" + +export const Route = createFileRoute("/events/$eventId/")({ + component: EventDetailPage, +}) + +function EventDetailPage() { + const { eventId } = Route.useParams() + const { setSelectedEvent } = useEventStore() + + // API Fetch + const { data: events, isLoading, isError } = useOrganizerEvents() + + const event = useMemo(() => { + return events?.find((e) => e.id === eventId) + }, [events, eventId]) + + // Mock schedules for now as endpoint is ambiguous + const schedules = MOCK_SCHEDULES[eventId] ?? [] + + // Update store on load + useEffect(() => { + if (event) { + setSelectedEvent(event) + } + }, [event, setSelectedEvent]) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (isError || (!isLoading && !event)) { + return ( +
+

+ {isError ? "Failed to load event" : "Event not found"} +

+ + ← Back to Events + +
+ ) + } + + // Safe to assert event is defined here due to check above + const safeEvent = event! + + return ( +
+
+ {/* Header */} +
+ + + +
+ + {/* Event Info */} +
+

+ {safeEvent.name} +

+ +
+
+ + {safeEvent.organizer} +
+
+ + {safeEvent.day} +
+
+
+ + {/* Schedules Grid */} +
+

Schedules

+ + {schedules.length > 0 ? ( +
+ {schedules.map((schedule) => ( + + ))} +
+ ) : ( +
+

No schedules found.

+
+ )} +
+
+
+ ) +} diff --git a/src/routes/events/$eventId/schedules/$scheduleId.tsx b/src/routes/events/$eventId/schedules/$scheduleId.tsx new file mode 100644 index 0000000..2877d79 --- /dev/null +++ b/src/routes/events/$eventId/schedules/$scheduleId.tsx @@ -0,0 +1,367 @@ +import { createFileRoute, Link } from "@tanstack/react-router" +import { useState, useMemo, useEffect } from "react" +import { ArrowLeft, Search, Users, QrCode } from "lucide-react" +import { useEventStore } from "@/store/event-store" +import { SoloAttendanceScanner } from "@/components/events/solo-attendance-scanner" +import { DuoAttendanceScanner } from "@/components/events/duo-attendance-scanner" +import { MOCK_SCHEDULES, MOOCK_EVENTS } from "@/components/events/mock-data" +import { Input } from "@/components/ui/input" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { AttendanceActions } from "@/components/events/attendance-actions" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import type { Participant } from "@/types/events" + +export const Route = createFileRoute("/events/$eventId/schedules/$scheduleId")({ + component: ScheduleParticipantPage, +}) + +import { + useParticipants, + useMarkAttendance, + useUnmarkAttendance, +} from "@/hooks/use-attendance" +import { Loader2 } from "lucide-react" + +// ... imports ... + +function ScheduleParticipantPage() { + const { eventId, scheduleId } = Route.useParams() + const { selectedEvent, setSelectedEvent, setSelectedSchedule } = + useEventStore() + + // State for search and pagination + const [searchQuery, setSearchQuery] = useState("") + const [page, setPage] = useState(1) + const itemsPerPage = 10 + + // API Fetch + const { + data: participantsData, + isLoading: isLoadingParticipants, + isError: isErrorParticipants, + } = useParticipants(eventId, scheduleId) + const { mutate: markAttendance } = useMarkAttendance() + const { mutate: unmarkAttendance } = useUnmarkAttendance() + + if (isErrorParticipants) { + // Simple error handling + console.error("Failed to fetch participants") + } + + // Simulate Data Fetching for Event/Schedule (or use store if already populated) + // We use store or mock fetch for metadata as before, but participants come from API + const event = MOOCK_EVENTS.find((e) => e.id === eventId) + const schedule = MOCK_SCHEDULES[eventId]?.find((s) => s.id === scheduleId) + + // Local state for participants to handle optimistic updates or just syncing + const [participants, setParticipants] = useState([]) + const [showScanner, setShowScanner] = useState(false) + + useEffect(() => { + // Reset or Fetch data + if (event) setSelectedEvent(event) + if (schedule) setSelectedSchedule(schedule) + + // Sync participants from API + if (participantsData) { + setParticipants(participantsData) + } + }, [ + eventId, + scheduleId, + event, + schedule, + setSelectedEvent, + setSelectedSchedule, + participantsData, + ]) + + // Filter Logic + const filteredParticipants = useMemo(() => { + return participants.filter( + (p) => + p.name.toLowerCase().includes(searchQuery.toLowerCase()) || + p.email.toLowerCase().includes(searchQuery.toLowerCase()) || + (p.teamName && + p.teamName.toLowerCase().includes(searchQuery.toLowerCase())) + ) + }, [participants, searchQuery]) + + // Pagination Logic + const totalPages = Math.ceil(filteredParticipants.length / itemsPerPage) + const paginatedParticipants = filteredParticipants.slice( + (page - 1) * itemsPerPage, + page * itemsPerPage + ) + + // Handlers + const handleMark = ( + participantId: string, + type: "CHECKIN" | "CHECKOUT" | "BOTH" + ) => { + if (!schedule) return + + const studentId = participantId // Assuming participantId is the studentId + const isTeam = schedule.type === "GROUP" + const markingType = schedule.markingType + + markAttendance({ + studentId, + scheduleId, + type, + isTeam, + markingType, + }) + } + + const handleUnmark = ( + participantId: string, + type: "CHECKIN" | "CHECKOUT" | "BOTH" + ) => { + if (!schedule) return + + const studentId = participantId + const isTeam = schedule.type === "GROUP" + const markingType = schedule.markingType + + unmarkAttendance({ + studentId, + scheduleId, + type, + isTeam, + markingType, + }) + } + + if (isLoadingParticipants) { + return ( +
+ +
+ ) + } + + // If no schedule found (e.g. direct link) + if (!schedule) { + return ( +
Loading or Schedule Not Found...
+ ) + } + + const isGroup = schedule.type === "GROUP" + const isDuo = schedule.markingType === "DUO" + + return ( +
+
+ {/* Navigation */} +
+ + + +
+ + {selectedEvent?.name} + +

+ {schedule.title} +

+
+
+ + {/* Controls: Search */} +
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ + {/* Participant Table */} +
+ + + + Participant + {/* Removed Team Column */} + + Status + + + Actions + + + + + {paginatedParticipants.length > 0 ? ( + paginatedParticipants.map((p) => ( + + +
+ + {p.name} + + {p.email} + + {/* Team Name badge moved here */} + {isGroup && p.teamName && ( +
+ + + {p.teamName} + +
+ )} +
+
+ + +
+ {isDuo ? ( + // DUO Status badges +
+ + IN + + + OUT + +
+ ) : ( + // SOLO Status badge + + {p.attendanceStatus ? "PRESENT" : "ABSENT"} + + )} +
+
+ + handleMark(p.id, type)} + onUnmark={(type) => handleUnmark(p.id, type)} + /> + +
+ )) + ) : ( + + + No participants found. + + + )} +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ +
+ Page {page} of {totalPages} +
+ +
+ )} + {/* Floating Scanner Button */} + + + {/* Scanner Modal */} + {showScanner && + (isDuo ? ( + setShowScanner(false)} + isTeamEvent={isGroup} + /> + ) : ( + setShowScanner(false)} + isTeamEvent={isGroup} + /> + ))} +
+
+ ) +} diff --git a/src/routes/events/index.tsx b/src/routes/events/index.tsx new file mode 100644 index 0000000..f4675b4 --- /dev/null +++ b/src/routes/events/index.tsx @@ -0,0 +1,117 @@ +import { useState, useMemo } from "react" +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { CalendarDays, LogOut, Loader2 } from "lucide-react" +import { EventCard } from "@/components/events/event-card" +import { EventsFilter } from "@/components/events/events-filter" +import { useOrganizerEvents } from "@/hooks/use-events" +import { Button } from "@/components/ui/button" +import type { DayFilter } from "@/types/events" + +export const Route = createFileRoute("/events/")({ + component: EventsPage, +}) + +function EventsPage() { + const navigate = useNavigate() + const [searchQuery, setSearchQuery] = useState("") + const [selectedDay, setSelectedDay] = useState("All") + + const { data: events, isLoading, isError } = useOrganizerEvents() + + const filteredEvents = useMemo(() => { + if (!events) return [] + return events.filter((event) => { + const matchesSearch = + event.name.toLowerCase().includes(searchQuery.toLowerCase()) || + event.organizer.toLowerCase().includes(searchQuery.toLowerCase()) + const matchesDay = selectedDay === "All" || event.day === selectedDay + + return matchesSearch && matchesDay + }) + }, [searchQuery, selectedDay, events]) + + const handleLogout = () => { + // TODO: Implement actual logout logic + navigate({ to: "/login" }) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (isError) { + return ( +
+

Failed to load events.

+ +
+ ) + } + + return ( +
+ {/* Main content - Centered max width container */} +
+ {/* Header */} +
+
+

+ + Events +

+
+ + {/* Logout Button */} + +
+ + {/* Filter section */} +
+ +
+ + {/* Cards grid */} +
+ {filteredEvents.length > 0 ? ( + filteredEvents.map((event) => ( + + )) + ) : ( +
+

+ No events found matching your criteria. +

+
+ )} +
+
+
+ ) +} diff --git a/src/store/event-store.ts b/src/store/event-store.ts new file mode 100644 index 0000000..89cd019 --- /dev/null +++ b/src/store/event-store.ts @@ -0,0 +1,29 @@ +import { create } from "zustand" +import { persist } from "zustand/middleware" +import type { Event, Schedule } from "@/types/events" + +interface EventStoreState { + selectedEvent: Event | null + setSelectedEvent: (event: Event | null) => void + + selectedSchedule: Schedule | null + setSelectedSchedule: (schedule: Schedule | null) => void + + // Optional: Cache for participants if needed, but Query is better for this + // kept simple for now +} + +export const useEventStore = create()( + persist( + (set) => ({ + selectedEvent: null, + setSelectedEvent: (event) => set({ selectedEvent: event }), + + selectedSchedule: null, + setSelectedSchedule: (schedule) => set({ selectedSchedule: schedule }), + }), + { + name: "event-store", // unique name for localStorage + } + ) +) diff --git a/src/types/events.ts b/src/types/events.ts index ad21bed..e119c57 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -4,6 +4,35 @@ export interface Event { name: string organizer: string day: "20 Feb" | "21 Feb" + type: "INDIVIDUAL" | "GROUP" // <--- Added for badge on Event Card + markingType?: "SOLO" | "DUO" // <--- Optional, good for context +} + +// Schedule entity type +export interface Schedule { + id: string + title: string + venue: string + startTime: string + endTime: string + date: string + eventId: string + type: "INDIVIDUAL" | "GROUP" + markingType: "SOLO" | "DUO" +} + +// Participant entity type +export interface Participant { + id: string + name: string + email: string + teamName?: string // Only for Group events + // Attendance status + // For SOLO: "PRESENT" | "ABSENT" + // For DUO: checkInStatus, checkOutStatus + checkInStatus: boolean + checkOutStatus: boolean + attendanceStatus: boolean // For SOLO marking, typically maps to this } // Event card props