diff --git a/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/SlotDefinitionsComponent.tsx b/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/SlotDefinitionsComponent.tsx new file mode 100644 index 0000000..3087587 --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/SlotDefinitionsComponent.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Calendar, Plus, Loader2, AlertCircle, Edit, Trash2, ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; +import useTranslation from '@/src/hooks/useTranslation'; +import { getSlotDefinitions, getMockSlotDefinitions } from '@/src/lib/api/slot-definitions'; +import type { SlotDefinition, SlotDefinitionError } from '@/src/types/slot-definitions'; +import { DAY_NAMES, formatTime, calculateEndTime } from '@/src/types/slot-definitions'; + +interface SlotDefinitionsComponentProps { + clubId: number; +} + +export default function SlotDefinitionsComponent({ clubId }: SlotDefinitionsComponentProps) { + const { t, locale } = useTranslation(); + const [definitions, setDefinitions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreateModal, setShowCreateModal] = useState(false); + + useEffect(() => { + loadDefinitions(); + }, [clubId]); + + async function loadDefinitions() { + setLoading(true); + + // Use mock data for now (until backend is ready) + const USE_MOCKS = true; + + if (USE_MOCKS) { + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 500)); + setDefinitions(getMockSlotDefinitions()); + setError(null); + setLoading(false); + return; + } + + const result = await getSlotDefinitions(clubId); + if (result.success) { + setDefinitions(result.data); + setError(null); + } else { + setError(result.error); + setDefinitions([]); + } + setLoading(false); + } + + function handleCreate() { + setShowCreateModal(true); + } + + function handleEdit(definition: SlotDefinition) { + // TODO: Open edit modal + console.log('Edit', definition); + } + + async function handleDelete(definition: SlotDefinition) { + if (!confirm(`Delete slot definition for ${DAY_NAMES[definition.dow]} at ${formatTime(definition.starts_at)}?`)) { + return; + } + // TODO: Call delete API + console.log('Delete', definition); + } + + // Loading state + if (loading) { + return ( +
+
+ +

Loading slot definitions...

+
+
+ ); + } + + // Error state + if (error) { + return ( +
+
+
+
+ +
+

+ Error Loading Slot Definitions +

+

+ {error.detail} +

+

+ Error code: {error.code} +

+ + + Back to club + +
+
+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ + + Back to club + + +
+
+

+ Slot Definitions +

+

+ Recurring schedule templates for automatic slot generation +

+
+ + +
+
+ + {/* Empty state */} + {definitions.length === 0 ? ( +
+
+ +

+ No Slot Definitions Yet +

+

+ Create your first recurring slot definition to automatically generate available time slots for bookings. +

+ +
+
+ ) : ( + /* Table */ +
+
+ + + + + + + + + + + + + + {definitions.map((definition) => { + const endTime = calculateEndTime(definition.starts_at, definition.duration_minutes); + return ( + + + + + + + + + + ); + })} + +
DayTimeDurationCapacityValid PeriodDescriptionActions
+ + {DAY_NAMES[definition.dow]} + + + + {formatTime(definition.starts_at)} - {endTime} + + + {definition.duration_minutes} min + + {definition.capacity} players + +
+
{definition.valid_from}
+ {definition.valid_to && ( +
to {definition.valid_to}
+ )} + {!definition.valid_to && ( +
No end date
+ )} +
+
+ {definition.rule?.description ? ( + {definition.rule.description} + ) : ( + No description + )} + +
+ + +
+
+
+
+ )} + + {/* TODO: Create/Edit Modal */} + {showCreateModal && ( +
+
+

Create Slot Definition

+

Form coming next...

+ +
+
+ )} +
+ ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/page.tsx b/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/page.tsx new file mode 100644 index 0000000..4235d09 --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/page.tsx @@ -0,0 +1,17 @@ +import AdminAuthGuard from '@/src/components/AdminAuthGuard'; +import SlotDefinitionsComponent from './SlotDefinitionsComponent'; + +export default async function SlotDefinitionsPage({ + params +}: { + params: Promise<{ club_id: string }>; +}) { + const { club_id } = await params; + const clubId = parseInt(club_id, 10); + + return ( + + + + ); +} diff --git a/src/lib/api/slot-definitions.ts b/src/lib/api/slot-definitions.ts new file mode 100644 index 0000000..f79b06a --- /dev/null +++ b/src/lib/api/slot-definitions.ts @@ -0,0 +1,219 @@ +import type { + SlotDefinition, + SlotDefinitionRequest, + SlotDefinitionError, +} from '@/src/types/slot-definitions'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://api.playchoo.com'; + +type ApiResult = + | { success: true; data: T } + | { success: false; error: SlotDefinitionError }; + +// GET /admin/clubs/{club_id}/slot-definitions +export async function getSlotDefinitions( + clubId: number, + filters?: { court_id?: number; active_on?: string } +): Promise> { + try { + const params = new URLSearchParams(); + if (filters?.court_id) params.append('court_id', filters.court_id.toString()); + if (filters?.active_on) params.append('active_on', filters.active_on); + + const queryString = params.toString(); + const url = `${API_BASE_URL}/admin/clubs/${clubId}/slot-definitions${queryString ? `?${queryString}` : ''}`; + + const response = await fetch(url, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const error: SlotDefinitionError = await response.json(); + return { success: false, error }; + } + + const data: SlotDefinition[] = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to fetch slot definitions. Please check your connection.', + code: 'network_error', + }, + }; + } +} + +// POST /admin/clubs/{club_id}/slot-definitions +export async function createSlotDefinition( + clubId: number, + request: SlotDefinitionRequest +): Promise> { + try { + const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/slot-definitions`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const error: SlotDefinitionError = await response.json(); + return { success: false, error }; + } + + const data: SlotDefinition = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to create slot definition. Please check your connection.', + code: 'network_error', + }, + }; + } +} + +// PATCH /admin/clubs/{club_id}/slot-definitions/{slot_definition_id} +export async function updateSlotDefinition( + clubId: number, + slotDefinitionId: number, + request: Partial +): Promise> { + try { + const response = await fetch( + `${API_BASE_URL}/admin/clubs/${clubId}/slot-definitions/${slotDefinitionId}`, + { + method: 'PATCH', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + } + ); + + if (!response.ok) { + const error: SlotDefinitionError = await response.json(); + return { success: false, error }; + } + + const data: SlotDefinition = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to update slot definition. Please check your connection.', + code: 'network_error', + }, + }; + } +} + +// DELETE /admin/clubs/{club_id}/slot-definitions/{slot_definition_id} +export async function deleteSlotDefinition( + clubId: number, + slotDefinitionId: number +): Promise> { + try { + const response = await fetch( + `${API_BASE_URL}/admin/clubs/${clubId}/slot-definitions/${slotDefinitionId}`, + { + method: 'DELETE', + credentials: 'include', + } + ); + + if (!response.ok) { + const error: SlotDefinitionError = await response.json(); + return { success: false, error }; + } + + return { success: true, data: undefined }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to delete slot definition. Please check your connection.', + code: 'network_error', + }, + }; + } +} + +// Mock data for local development +export function getMockSlotDefinitions(): SlotDefinition[] { + return [ + { + slot_definition_id: 1, + court_id: 101, + dow: 0, // Monday + starts_at: '09:00:00', + duration_minutes: 90, + capacity: 4, + valid_from: '2025-01-01', + valid_to: '2025-12-31', + rule: { + weekly: true, + description: 'Monday morning regulars', + }, + created_at: '2025-11-01T10:00:00Z', + updated_at: '2025-11-01T10:00:00Z', + updated_by_app_user_id: 1, + }, + { + slot_definition_id: 2, + court_id: 102, + dow: 2, // Wednesday + starts_at: '14:00:00', + duration_minutes: 60, + capacity: 4, + valid_from: '2025-01-01', + rule: { + weekly: true, + description: 'Afternoon session', + }, + created_at: '2025-11-01T10:00:00Z', + updated_at: '2025-11-01T10:00:00Z', + updated_by_app_user_id: 1, + }, + { + slot_definition_id: 3, + court_id: 101, + dow: 5, // Saturday + starts_at: '10:00:00', + duration_minutes: 120, + capacity: 4, + valid_from: '2025-01-01', + valid_to: '2025-06-30', + rule: { + weekly: true, + description: 'Weekend tournament slots', + }, + created_at: '2025-11-01T10:00:00Z', + updated_at: '2025-11-01T10:00:00Z', + updated_by_app_user_id: 1, + }, + ]; +} diff --git a/src/types/slot-definitions.ts b/src/types/slot-definitions.ts new file mode 100644 index 0000000..5c8b771 --- /dev/null +++ b/src/types/slot-definitions.ts @@ -0,0 +1,86 @@ +// Slot Definition Types based on Backend API Contract +// Contract: docs/owners/payloads/slot-definition-api-contract.md + +export type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0=Monday, 6=Sunday + +export interface SlotDefinitionRule { + weekly: boolean; + description?: string; +} + +export interface SlotDefinitionRequest { + court_id: number; + dow: DayOfWeek; + starts_at: string; // HH:MM:SS format + duration_minutes: number; + capacity: number; + valid_from: string; // YYYY-MM-DD + valid_to?: string; // YYYY-MM-DD, optional + rule?: SlotDefinitionRule; +} + +export interface SlotDefinition extends SlotDefinitionRequest { + slot_definition_id: number; + created_at: string; // ISO 8601 + updated_at: string; // ISO 8601 + updated_by_app_user_id: number; +} + +export interface ValidationError { + field: string; + message: string; +} + +export interface SlotDefinitionError { + type: string; + title: string; + status: number; + detail: string; + code: string; + errors?: ValidationError[]; +} + +// Helper constants +export const DAY_NAMES: Record = { + 0: 'Monday', + 1: 'Tuesday', + 2: 'Wednesday', + 3: 'Thursday', + 4: 'Friday', + 5: 'Saturday', + 6: 'Sunday', +}; + +export const DAY_NAMES_SHORT: Record = { + 0: 'Mon', + 1: 'Tue', + 2: 'Wed', + 3: 'Thu', + 4: 'Fri', + 5: 'Sat', + 6: 'Sun', +}; + +// Helper to format time for display +export function formatTime(timeStr: string): string { + // Convert HH:MM:SS to HH:MM + return timeStr.substring(0, 5); +} + +// Helper to format time for API +export function formatTimeForAPI(timeStr: string): string { + // Ensure HH:MM:SS format + if (timeStr.length === 5) { + return `${timeStr}:00`; + } + return timeStr; +} + +// Helper to calculate end time +export function calculateEndTime(startTime: string, durationMinutes: number): string { + const [hours, minutes] = startTime.split(':').map(Number); + const totalMinutes = hours * 60 + minutes + durationMinutes; + const endHours = Math.floor(totalMinutes / 60); + const endMinutes = totalMinutes % 60; + return `${String(endHours).padStart(2, '0')}:${String(endMinutes).padStart(2, '0')}`; +}