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
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
122 changes: 122 additions & 0 deletions src/components/events/attendance-actions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-white/10 text-white/70"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48 bg-gray-900 border-gray-800 text-white"
>
<DropdownMenuLabel>Attendance</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-gray-800" />

{isDuo ? (
<>
{/* DUO MARKING: Check In / Check Out */}
<DropdownMenuItem
onClick={() => onMark("CHECKIN")}
disabled={participant.checkInStatus}
className="focus:bg-green-900/50 focus:text-green-300 cursor-pointer"
>
<LogIn className="mr-2 h-4 w-4 text-green-400" />
<span>Mark Check In</span>
</DropdownMenuItem>

<DropdownMenuItem
onClick={() => onMark("CHECKOUT")}
disabled={participant.checkOutStatus}
className="focus:bg-blue-900/50 focus:text-blue-300 cursor-pointer"
>
<LogOut className="mr-2 h-4 w-4 text-blue-400" />
<span>Mark Check Out</span>
</DropdownMenuItem>

<DropdownMenuSeparator className="bg-gray-800" />

<DropdownMenuItem
onClick={() => onUnmark("CHECKIN")}
disabled={!participant.checkInStatus}
className="focus:bg-red-900/50 focus:text-red-300 cursor-pointer"
>
<XCircle className="mr-2 h-4 w-4 text-red-400" />
<span>Unmark Check In</span>
</DropdownMenuItem>

<DropdownMenuItem
onClick={() => onUnmark("CHECKOUT")}
disabled={!participant.checkOutStatus}
className="focus:bg-red-900/50 focus:text-red-300 cursor-pointer"
>
<XCircle className="mr-2 h-4 w-4 text-red-400" />
<span>Unmark Check Out</span>
</DropdownMenuItem>
</>
) : (
<>
{/* SOLO MARKING: One time mark */}
<DropdownMenuItem
onClick={() => onMark("BOTH")}
disabled={participant.attendanceStatus}
className="focus:bg-green-900/50 focus:text-green-300 cursor-pointer"
>
<CheckCircle2 className="mr-2 h-4 w-4 text-green-400" />
<span>Mark Present</span>
</DropdownMenuItem>

<DropdownMenuSeparator className="bg-gray-800" />

<DropdownMenuItem
onClick={() => onUnmark("BOTH")}
disabled={!participant.attendanceStatus}
className="focus:bg-red-900/50 focus:text-red-300 cursor-pointer"
>
<XCircle className="mr-2 h-4 w-4 text-red-400" />
<span>Unmark Present</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
73 changes: 27 additions & 46 deletions src/components/events/duo-attendance-scanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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(() => {
Expand Down Expand Up @@ -123,7 +104,7 @@ export function DuoAttendanceScanner({
return
}

markAttendance(validation.data)
handleMark(validation.data)
},
[status, scheduleId, markAttendance, resetStatusAfterDelay]
)
Expand Down
97 changes: 61 additions & 36 deletions src/components/events/event-card.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -7,43 +8,67 @@ export function EventCard({
className,
isActive = false,
}: EventCardProps) {
const isGroup = event.type === "GROUP"

return (
<Card
className={cn(
// Base glass effect
"w-full overflow-hidden rounded-2xl border border-white/10",
"backdrop-blur-xl",
// Soft inner highlight
"shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]",
// Grey outer glow
"shadow-[0_0_30px_rgba(200,200,200,0.1)]",
// Smooth transitions
"transition-all duration-300 ease-out",
// Hover/Active states
isActive
? "shadow-[0_0_40px_rgba(234,179,8,0.4),inset_0_1px_1px_rgba(255,255,255,0.2)] border-amber-400/30"
: "hover:shadow-[0_0_40px_rgba(200,200,200,0.2),inset_0_1px_1px_rgba(255,255,255,0.15)] hover:border-white/20 hover:-translate-y-1",
className
)}
style={{
backgroundColor: "var(--navy-card)",
}}
<Link
to="/events/$eventId"
params={{ eventId: event.id }}
className="block focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/50 rounded-2xl group"
>
<CardHeader className="pb-1">
<CardTitle className="text-xl font-bold text-white/90">
{event.name}
</CardTitle>
</CardHeader>
<CardContent className="pt-2">
<div
className={cn(
"inline-block rounded-lg px-3 py-1 text-sm font-semibold",
"bg-amber-500/10 text-amber-300/80 border border-amber-400/10"
)}
>
{event.day}
</div>
</CardContent>
</Card>
<Card
className={cn(
// Base glass effect
"w-full overflow-hidden rounded-2xl border border-white/10",
"backdrop-blur-xl",
// Soft inner highlight
"shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]",
// Grey outer glow
"shadow-[0_0_30px_rgba(200,200,200,0.1)]",
// Smooth transitions
"transition-all duration-300 ease-out",
// Hover/Active states
isActive
? "shadow-[0_0_40px_rgba(234,179,8,0.4),inset_0_1px_1px_rgba(255,255,255,0.2)] border-amber-400/30"
: "hover:shadow-[0_0_40px_rgba(200,200,200,0.2),inset_0_1px_1px_rgba(255,255,255,0.15)] hover:border-white/20 hover:-translate-y-1",
className
)}
style={{
backgroundColor: "var(--navy-card)",
}}
>
<CardHeader className="pb-1">
<div className="flex justify-between items-start gap-2">
<CardTitle className="text-xl font-bold text-white/90 group-hover:text-white transition-colors">
{event.name}
</CardTitle>

{/* Type Badges - DISTINCT COLORS */}
<span
className={cn(
"text-[10px] uppercase tracking-wider font-bold px-1.5 py-0.5 rounded border shadow-sm",
isGroup
? "bg-sky-500/10 text-sky-200 border-sky-400/20 shadow-sky-500/10"
: "bg-rose-500/10 text-rose-200 border-rose-400/20 shadow-rose-500/10"
)}
>
{isGroup ? "Group" : "Individual"}
</span>
</div>
</CardHeader>
<CardContent className="pt-2">
<div className="flex items-center gap-2">
<div
className={cn(
"inline-block rounded-lg px-3 py-1 text-sm font-semibold",
"bg-amber-500/10 text-amber-300/80 border border-amber-400/10"
)}
>
{event.day}
</div>
</div>
</CardContent>
</Card>
</Link>
)
}
Loading