diff --git a/.sisyphus/notepads/club-work-manager/learnings.md b/.sisyphus/notepads/club-work-manager/learnings.md index 54480fa..1bfffe3 100644 --- a/.sisyphus/notepads/club-work-manager/learnings.md +++ b/.sisyphus/notepads/club-work-manager/learnings.md @@ -1561,3 +1561,14 @@ frontend/ - **UI Component Usage**: Leveraged shadcn `Table` for the list and `Card` for details and new task forms, alongside raw inputs for simplified creation without needing heavy forms libraries. + + +## Task 20: Shift Sign-Up UI (2026-03-03) + +### Key Learnings + +- Card-based UI pattern for shifts: Used shadcn Card component instead of tables for a more visual schedule representation +- Capacity calculation and Progress component: Calculated percentage and used shadcn Progress bar to visually indicate filled spots +- Past shift detection and button visibility: Checked if shift startTime is in the past to conditionally show 'Past' badge and hide sign-up buttons +- Sign-up/cancel mutation patterns: Added mutations using useSignUpShift and useCancelSignUp hooks that invalidate the 'shifts' query on success +- Tests: Vitest tests need to wrap Suspense inside act when dealing with asynchronous loading in Next.js 15+ diff --git a/.sisyphus/plans/club-work-manager.md b/.sisyphus/plans/club-work-manager.md index 0de3a52..6add7d2 100644 --- a/.sisyphus/plans/club-work-manager.md +++ b/.sisyphus/plans/club-work-manager.md @@ -1854,7 +1854,7 @@ Max Concurrent: 6 (Wave 1) - Files: `frontend/src/app/(protected)/tasks/**/*.tsx`, `frontend/src/hooks/useTasks.ts` - Pre-commit: `bun run build && bun run test` -- [ ] 20. Shift List + Shift Detail + Sign-Up UI +- [x] 20. Shift List + Shift Detail + Sign-Up UI **What to do**: - Create `/frontend/src/app/(protected)/shifts/page.tsx`: diff --git a/frontend/src/app/(protected)/shifts/[id]/page.tsx b/frontend/src/app/(protected)/shifts/[id]/page.tsx new file mode 100644 index 0000000..aeff791 --- /dev/null +++ b/frontend/src/app/(protected)/shifts/[id]/page.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { use } from 'react'; +import { useShift, useSignUpShift, useCancelSignUp } from '@/hooks/useShifts'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import { Badge } from '@/components/ui/badge'; +import { useRouter } from 'next/navigation'; +import { useSession } from 'next-auth/react'; + +export default function ShiftDetailPage({ params }: { params: Promise<{ id: string }> }) { + const resolvedParams = use(params); + const { data: shift, isLoading } = useShift(resolvedParams.id); + const signUpMutation = useSignUpShift(); + const cancelMutation = useCancelSignUp(); + const router = useRouter(); + const { data: session } = useSession(); + + if (isLoading) return
Loading shift...
; + if (!shift) return
Shift not found
; + + const capacityPercentage = (shift.signups.length / shift.capacity) * 100; + const isFull = shift.signups.length >= shift.capacity; + const isPast = new Date(shift.startTime) < new Date(); + const isSignedUp = shift.signups.some((s) => s.memberId === session?.user?.id); + + const handleSignUp = async () => { + await signUpMutation.mutateAsync(shift.id); + }; + + const handleCancelSignUp = async () => { + await cancelMutation.mutateAsync(shift.id); + }; + + return ( +
+ + +
+ {shift.title} + {isPast && Past} +
+
+ +
+
+ Time: {new Date(shift.startTime).toLocaleString()} - {new Date(shift.endTime).toLocaleTimeString()} +
+ {shift.location && ( +
Location: {shift.location}
+ )} + {shift.description && ( +
Description: {shift.description}
+ )} +
+ +
+
+ Capacity + {shift.signups.length}/{shift.capacity} spots filled +
+ +
+ +
+

Signed Up Members ({shift.signups.length})

+ {shift.signups.length === 0 ? ( +

No sign-ups yet

+ ) : ( +
    + {shift.signups.map((signup) => ( +
  • Member ID: {signup.memberId}
  • + ))} +
+ )} +
+ +
+ {!isPast && !isFull && !isSignedUp && ( + + )} + {!isPast && isSignedUp && ( + + )} + +
+
+
+
+ ); +} diff --git a/frontend/src/app/(protected)/shifts/new/page.tsx b/frontend/src/app/(protected)/shifts/new/page.tsx new file mode 100644 index 0000000..093f93a --- /dev/null +++ b/frontend/src/app/(protected)/shifts/new/page.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useState } from 'react'; +import { useCreateShift } from '@/hooks/useShifts'; +import { useTenant } from '@/contexts/tenant-context'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { useRouter } from 'next/navigation'; + +export default function NewShiftPage() { + const { mutateAsync: createShift, isPending, error } = useCreateShift(); + const { activeClubId } = useTenant(); + const router = useRouter(); + + const [formData, setFormData] = useState({ + title: '', + description: '', + location: '', + startTime: '', + endTime: '', + capacity: 5, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!activeClubId) return; + + try { + const data = await createShift({ + ...formData, + startTime: new Date(formData.startTime).toISOString(), + endTime: new Date(formData.endTime).toISOString(), + clubId: activeClubId, + }); + + router.push(`/shifts/${data.id}`); + } catch (err) { + console.error('Failed to create shift', err); + } + }; + + return ( +
+ + + Create New Shift + + +
+
+ + setFormData({ ...formData, title: e.target.value })} + required + /> +
+ +
+ +