feat(admin): add materialisation trigger/status UI + court management infrastructure
continuous-integration/drone/push Build is passing Details

Phase 1 continuation + Phase 2 preparation per Chief Cole request (Chief_Cole-20251107102650)

Materialisation UI (Phase 1 continuation):
- Created MaterialisationStatusPanel component with 5 states (never run, success, running, failed, rate limited)
- Polling strategy: 3-second intervals while job running
- Rate-limit countdown timer with live updates
- Idempotency key generation (UUID v4) for manual triggers
- Mock data for development with state cycling
- Integrated into slot definitions page between header and table
- TypeScript types: MaterialisationStatus, MaterialisationTriggerRequest/Response, helpers
- API client with mock implementation (USE_MOCKS flag)
- Professional slate theme consistent with existing UI

Court Management Infrastructure (Phase 2 preparation):
- Created TypeScript types for courts and club profile
- Court types: Court, CourtRequest, CourtDependencies
- Profile types: ClubProfile, ClubProfileUpdateRequest
- Common timezones list (40+ IANA zones)
- Validation helpers (email, URL)
- API client with full CRUD mocks:
  - getClubProfile, updateClubProfile
  - getCourts, createCourt, updateCourt, deleteCourt
  - getCourtDependencies (cascade blocking)
- Mock data: 3 courts, full profile, dependency simulation
- In-memory mock state management

Implementation ready for:
- Next: Club detail tab navigation (Profile/Courts/Slot Definitions)
- Next: Profile tab with edit form
- Next: Courts tab with add/edit/delete modals

Build tested and passed (npm run build successful)
master
Guillermo Pages 1 month ago
parent 478d43b44a
commit 251b849500

@ -0,0 +1,292 @@
'use client';
import { useState, useEffect } from 'react';
import { Calendar, Loader2, AlertCircle, CheckCircle, Clock, AlertTriangle } from 'lucide-react';
import { getMaterialisationStatus, triggerMaterialisation } from '@/src/lib/api/materialisation';
import type { MaterialisationStatus } from '@/src/types/materialisation';
import {
generateIdempotencyKey,
calculateRemainingCooldown,
formatCountdown,
formatTimestamp,
} from '@/src/types/materialisation';
interface MaterialisationStatusPanelProps {
clubId: number;
}
export default function MaterialisationStatusPanel({ clubId }: MaterialisationStatusPanelProps) {
const [status, setStatus] = useState<MaterialisationStatus | null>(null);
const [loading, setLoading] = useState(true);
const [triggering, setTriggering] = useState(false);
const [error, setError] = useState<string | null>(null);
const [countdown, setCountdown] = useState(0);
// Poll status on mount and when triggering completes
useEffect(() => {
loadStatus();
}, [clubId]);
// Poll status every 3 seconds while job is running
useEffect(() => {
if (!status || status.status !== 'running') return;
const interval = setInterval(() => {
loadStatus();
}, 3000);
return () => clearInterval(interval);
}, [status?.status]);
// Update countdown every second when rate limited
useEffect(() => {
if (!status?.rate_limit.next_available_at) {
setCountdown(0);
return;
}
const remaining = calculateRemainingCooldown(status.rate_limit.next_available_at);
setCountdown(remaining);
if (remaining <= 0) return;
const interval = setInterval(() => {
const newRemaining = calculateRemainingCooldown(status.rate_limit.next_available_at!);
setCountdown(newRemaining);
if (newRemaining <= 0) {
clearInterval(interval);
// Reload status to update rate limit state
loadStatus();
}
}, 1000);
return () => clearInterval(interval);
}, [status?.rate_limit.next_available_at]);
async function loadStatus() {
const result = await getMaterialisationStatus(clubId);
if (result.success) {
setStatus(result.data);
setError(null);
} else {
setError(result.error.detail);
}
setLoading(false);
}
async function handleTrigger() {
if (!status || triggering) return;
setTriggering(true);
setError(null);
const idempotencyKey = generateIdempotencyKey();
const result = await triggerMaterialisation(clubId, { idempotency_key: idempotencyKey });
if (result.success) {
// Start polling immediately
loadStatus();
} else {
if (result.error.status === 429) {
// Rate limit error - reload status to show countdown
loadStatus();
} else {
setError(result.error.detail);
}
}
setTriggering(false);
}
// Loading state
if (loading) {
return (
<div className="bg-slate-50 border-2 border-slate-200 rounded-2xl p-6 mb-8">
<div className="flex items-center space-x-3">
<Loader2 className="w-5 h-5 text-slate-600 animate-spin" />
<span className="text-slate-600 font-medium">Loading materialisation status...</span>
</div>
</div>
);
}
// Error state
if (error && !status) {
return (
<div className="bg-red-50 border-2 border-red-200 rounded-2xl p-6 mb-8">
<div className="flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-lg font-bold text-slate-900 mb-1">
Failed to load status
</h3>
<p className="text-slate-700">{error}</p>
</div>
</div>
</div>
);
}
if (!status) return null;
// Determine panel state
const isRateLimited = !status.rate_limit.can_trigger && countdown > 0;
const canTrigger = status.rate_limit.can_trigger && status.status !== 'running' && !triggering;
// Render status panel
return (
<div className="bg-slate-50 border-2 border-slate-200 rounded-2xl p-6 mb-8">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center space-x-3">
<Calendar className="w-6 h-6 text-slate-700" />
<h2 className="text-xl font-bold text-slate-900">Slot Materialisation</h2>
</div>
{/* Status badge */}
{status.status === 'completed' && !isRateLimited && (
<div className="flex items-center space-x-2 text-green-600">
<CheckCircle className="w-5 h-5" />
<span className="font-semibold">Up to date</span>
</div>
)}
{status.status === 'running' && (
<div className="flex items-center space-x-2 text-slate-600">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="font-semibold">Processing...</span>
</div>
)}
{status.status === 'failed' && (
<div className="flex items-center space-x-2 text-red-600">
<AlertCircle className="w-5 h-5" />
<span className="font-semibold">Failed</span>
</div>
)}
{isRateLimited && (
<div className="flex items-center space-x-2 text-amber-600">
<Clock className="w-5 h-5" />
<span className="font-semibold">Rate limit</span>
</div>
)}
{status.status === 'idle' && (
<div className="flex items-center space-x-2 text-slate-500">
<AlertTriangle className="w-5 h-5" />
<span className="font-semibold">Not yet run</span>
</div>
)}
</div>
{/* Never run state */}
{status.status === 'idle' && !status.last_run_at && (
<div className="space-y-4">
<p className="text-slate-700">
Generate slot instances from your definitions to make them available for booking.
</p>
</div>
)}
{/* Success state */}
{status.status === 'completed' && status.last_success_at && !isRateLimited && (
<div className="space-y-2">
<div className="text-sm text-slate-700">
<span className="font-semibold">Last generated:</span>{' '}
{formatTimestamp(status.last_success_at)}
</div>
{status.slots_generated !== null && (
<div className="text-sm text-slate-700">
<span className="font-semibold">Status:</span>{' '}
{status.slots_generated} slots generated successfully
</div>
)}
</div>
)}
{/* Running state */}
{status.status === 'running' && status.last_run_at && (
<div className="space-y-2">
<div className="text-sm text-slate-700">
<span className="font-semibold">Started:</span>{' '}
{formatTimestamp(status.last_run_at)}
</div>
<div className="text-sm text-slate-700">
<span className="font-semibold">Status:</span> Generating slots from definitions...
</div>
</div>
)}
{/* Failed state */}
{status.status === 'failed' && (
<div className="space-y-2">
{status.last_run_at && (
<div className="text-sm text-slate-700">
<span className="font-semibold">Last attempt:</span>{' '}
{formatTimestamp(status.last_run_at)}
</div>
)}
{status.last_error && (
<div className="text-sm text-red-700">
<span className="font-semibold">Error:</span> {status.last_error}
</div>
)}
</div>
)}
{/* Rate limited state */}
{isRateLimited && (
<div className="space-y-3">
{status.last_success_at && (
<div className="text-sm text-slate-700">
<span className="font-semibold">Last generated:</span>{' '}
{formatTimestamp(status.last_success_at)}
</div>
)}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-amber-900 mb-2">
Job triggered too recently. Please wait 5 minutes between manual regenerations.
</p>
<p className="text-sm font-semibold text-amber-900">
Next available in: {formatCountdown(countdown)}
</p>
</div>
</div>
</div>
</div>
)}
{/* Error message */}
{error && (
<div className="mt-4 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
)}
{/* Trigger button */}
<div className="mt-6">
<button
onClick={handleTrigger}
disabled={!canTrigger}
className="inline-flex items-center px-6 py-3 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{triggering && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{status.status === 'failed' ? 'Retry Generation' :
status.last_run_at ? 'Regenerate Slots' : 'Generate Slots'}
</button>
</div>
</div>
);
}

@ -8,6 +8,7 @@ import { getSlotDefinitions, getMockSlotDefinitions, deleteSlotDefinition } from
import type { SlotDefinition, SlotDefinitionError } from '@/src/types/slot-definitions'; import type { SlotDefinition, SlotDefinitionError } from '@/src/types/slot-definitions';
import { DAY_NAMES, formatTime, calculateEndTime } from '@/src/types/slot-definitions'; import { DAY_NAMES, formatTime, calculateEndTime } from '@/src/types/slot-definitions';
import SlotDefinitionForm from './SlotDefinitionForm'; import SlotDefinitionForm from './SlotDefinitionForm';
import MaterialisationStatusPanel from './MaterialisationStatusPanel';
interface SlotDefinitionsComponentProps { interface SlotDefinitionsComponentProps {
clubId: number; clubId: number;
@ -167,6 +168,9 @@ export default function SlotDefinitionsComponent({ clubId }: SlotDefinitionsComp
</div> </div>
</div> </div>
{/* Materialisation Status Panel */}
<MaterialisationStatusPanel clubId={clubId} />
{/* Empty state */} {/* Empty state */}
{definitions.length === 0 ? ( {definitions.length === 0 ? (
<div className="bg-slate-50 border-2 border-slate-200 rounded-2xl p-12"> <div className="bg-slate-50 border-2 border-slate-200 rounded-2xl p-12">

@ -0,0 +1,567 @@
/**
* Court Management API Client
*
* Handles club profile and court inventory CRUD operations.
* Uses mock data until backend endpoints are ready.
*/
import type {
Court,
CourtRequest,
CourtDependencies,
ClubProfile,
ClubProfileUpdateRequest,
CourtError,
} from '@/src/types/courts';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://staging.api.playchoo.com';
type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: CourtError };
/**
* Get club profile
*/
export async function getClubProfile(clubId: number): Promise<ApiResult<ClubProfile>> {
// 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, 300));
const mockProfile = getMockClubProfile(clubId);
return { success: true, data: mockProfile };
}
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error: CourtError = await response.json();
return { success: false, error };
}
const data: ClubProfile = 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 club profile. Please check your connection.',
code: 'network_error',
},
};
}
}
/**
* Update club profile
*/
export async function updateClubProfile(
clubId: number,
request: ClubProfileUpdateRequest
): Promise<ApiResult<ClubProfile>> {
// 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));
// Simple validation
if (!request.name || request.name.trim().length === 0) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Validation Error',
status: 400,
detail: 'One or more fields failed validation',
code: 'validation_error',
errors: [
{
field: 'name',
message: 'Name is required',
},
],
},
};
}
const mockProfile: ClubProfile = {
...getMockClubProfile(clubId),
...request,
updated_at: new Date().toISOString(),
};
return { success: true, data: mockProfile };
}
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}`, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
const error: CourtError = await response.json();
return { success: false, error };
}
const data: ClubProfile = 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 club profile. Please check your connection.',
code: 'network_error',
},
};
}
}
/**
* Get courts for a club
*/
export async function getCourts(clubId: number): Promise<ApiResult<Court[]>> {
// 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, 300));
const mockCourts = getMockCourts(clubId);
return { success: true, data: mockCourts };
}
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/courts`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error: CourtError = await response.json();
return { success: false, error };
}
const data: Court[] = 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 courts. Please check your connection.',
code: 'network_error',
},
};
}
}
/**
* Create a new court
*/
export async function createCourt(
clubId: number,
request: CourtRequest
): Promise<ApiResult<Court>> {
// 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));
// Simple validation
if (!request.name || request.name.trim().length === 0) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Validation Error',
status: 400,
detail: 'Court name is required',
code: 'validation_error',
errors: [
{
field: 'name',
message: 'Court name is required',
},
],
},
};
}
// Check for duplicate name (in mock data)
const existingCourts = getMockCourts(clubId);
if (existingCourts.some(c => c.name.toLowerCase() === request.name.trim().toLowerCase())) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Duplicate Court Name',
status: 409,
detail: `A court with the name '${request.name}' already exists for this club`,
code: 'duplicate_court_name',
},
};
}
const newCourt: Court = {
court_id: Math.max(...existingCourts.map(c => c.court_id), 100) + 1,
name: request.name.trim(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
// Add to mock data (in-memory only)
mockCourtsData.push(newCourt);
return { success: true, data: newCourt };
}
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/courts`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
const error: CourtError = await response.json();
return { success: false, error };
}
const data: Court = 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 court. Please check your connection.',
code: 'network_error',
},
};
}
}
/**
* Update an existing court
*/
export async function updateCourt(
clubId: number,
courtId: number,
request: CourtRequest
): Promise<ApiResult<Court>> {
// 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));
// Simple validation
if (!request.name || request.name.trim().length === 0) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Validation Error',
status: 400,
detail: 'Court name is required',
code: 'validation_error',
errors: [
{
field: 'name',
message: 'Court name is required',
},
],
},
};
}
// Check for duplicate name (excluding current court)
const existingCourts = getMockCourts(clubId);
if (existingCourts.some(c => c.court_id !== courtId && c.name.toLowerCase() === request.name.trim().toLowerCase())) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Duplicate Court Name',
status: 409,
detail: `A court with the name '${request.name}' already exists for this club`,
code: 'duplicate_court_name',
},
};
}
const court = existingCourts.find(c => c.court_id === courtId);
if (!court) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Court Not Found',
status: 404,
detail: 'Court not found',
code: 'court_not_found',
},
};
}
const updatedCourt: Court = {
...court,
name: request.name.trim(),
updated_at: new Date().toISOString(),
};
// Update in mock data (in-memory only)
const index = mockCourtsData.findIndex(c => c.court_id === courtId);
if (index !== -1) {
mockCourtsData[index] = updatedCourt;
}
return { success: true, data: updatedCourt };
}
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/courts/${courtId}`, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
const error: CourtError = await response.json();
return { success: false, error };
}
const data: Court = 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 court. Please check your connection.',
code: 'network_error',
},
};
}
}
/**
* Get court dependencies (before deletion)
*/
export async function getCourtDependencies(
clubId: number,
courtId: number
): Promise<ApiResult<CourtDependencies>> {
// 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, 300));
// Mock dependencies - first court has dependencies, others don't
const mockDeps: CourtDependencies = courtId === 101 ? {
can_delete: false,
dependencies: {
slot_definitions: 12,
upcoming_bookings: 45,
},
} : {
can_delete: true,
dependencies: {
slot_definitions: 0,
upcoming_bookings: 0,
},
};
return { success: true, data: mockDeps };
}
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/courts/${courtId}/dependencies`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error: CourtError = await response.json();
return { success: false, error };
}
const data: CourtDependencies = await response.json();
return { success: true, data };
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: 'Failed to check court dependencies. Please check your connection.',
code: 'network_error',
},
};
}
}
/**
* Delete a court
*/
export async function deleteCourt(
clubId: number,
courtId: number
): Promise<ApiResult<void>> {
// 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));
// Check dependencies first
const depsResult = await getCourtDependencies(clubId, courtId);
if (depsResult.success && !depsResult.data.can_delete) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Court Has Dependencies',
status: 409,
detail: 'Cannot delete court because it is referenced by slot definitions or upcoming bookings',
code: 'court_has_dependencies',
dependencies: depsResult.data.dependencies,
},
};
}
// Remove from mock data (in-memory only)
const index = mockCourtsData.findIndex(c => c.court_id === courtId);
if (index !== -1) {
mockCourtsData.splice(index, 1);
}
return { success: true, data: undefined };
}
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/courts/${courtId}`, {
method: 'DELETE',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error: CourtError = 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 court. Please check your connection.',
code: 'network_error',
},
};
}
}
/**
* Mock data for development
*/
let mockCourtsData: Court[] = [
{
court_id: 101,
name: 'Court 1',
created_at: '2024-06-15T10:30:00Z',
updated_at: '2024-06-15T10:30:00Z',
},
{
court_id: 102,
name: 'Court 2',
created_at: '2024-06-15T10:30:00Z',
updated_at: '2024-06-15T10:30:00Z',
},
{
court_id: 103,
name: 'Court 3',
created_at: '2024-06-15T10:30:00Z',
updated_at: '2024-06-15T10:30:00Z',
},
];
export function getMockCourts(clubId: number): Court[] {
return [...mockCourtsData];
}
export function getMockClubProfile(clubId: number): ClubProfile {
return {
club_id: clubId,
name: 'Central Padel',
timezone: 'Europe/London',
address_line_1: '123 High Street',
address_line_2: 'Building A',
city: 'London',
postal_code: 'SW1A 1AA',
country: 'United Kingdom',
phone: '+44 20 1234 5678',
email: 'info@centralpadel.com',
website: 'https://www.centralpadel.com',
provider: 'local',
remote_club_id: null,
created_at: '2024-06-15T10:30:00Z',
updated_at: '2025-11-05T14:23:00Z',
};
}

@ -0,0 +1,266 @@
/**
* Materialisation API Client
*
* Handles slot materialisation status polling and manual trigger requests.
* Uses mock data until backend endpoints are ready.
*/
import type {
MaterialisationStatus,
MaterialisationTriggerRequest,
MaterialisationTriggerResponse,
MaterialisationError,
} from '@/src/types/materialisation';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://staging.api.playchoo.com';
type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: MaterialisationError };
/**
* Get materialisation status for a club
*/
export async function getMaterialisationStatus(
clubId: number
): Promise<ApiResult<MaterialisationStatus>> {
// 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, 300));
const mockStatus = getMockMaterialisationStatus();
return { success: true, data: mockStatus };
}
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/materialisation-status`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error: MaterialisationError = await response.json();
return { success: false, error };
}
const data: MaterialisationStatus = 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 materialisation status. Please check your connection.',
code: 'network_error',
},
};
}
}
/**
* Trigger materialisation job manually
*/
export async function triggerMaterialisation(
clubId: number,
request: MaterialisationTriggerRequest
): Promise<ApiResult<MaterialisationTriggerResponse>> {
// 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));
const mockResponse = getMockTriggerResponse();
return { success: true, data: mockResponse };
}
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/materialisation-trigger`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
const error: MaterialisationError = await response.json();
return { success: false, error };
}
const data: MaterialisationTriggerResponse = await response.json();
return { success: true, data };
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: 'Failed to trigger materialisation. Please check your connection.',
code: 'network_error',
},
};
}
}
/**
* Mock data for development
*/
// Mock state to simulate different scenarios
let mockState: 'never_run' | 'success' | 'running' | 'failed' | 'rate_limited' = 'success';
let mockTriggerCount = 0;
export function getMockMaterialisationStatus(): MaterialisationStatus {
const now = new Date();
const lastRun = new Date(now.getTime() - 15 * 60 * 1000); // 15 minutes ago
const nextAvailable = new Date(now.getTime() + 3 * 60 * 1000); // 3 minutes from now
// Cycle through states for demo purposes
if (mockState === 'never_run') {
return {
status: 'idle',
last_run_at: null,
last_success_at: null,
last_error: null,
slots_generated: null,
rate_limit: {
can_trigger: true,
next_available_at: null,
cooldown_seconds: 300,
},
};
}
if (mockState === 'success') {
return {
status: 'completed',
last_run_at: lastRun.toISOString(),
last_success_at: lastRun.toISOString(),
last_error: null,
slots_generated: 234,
rate_limit: {
can_trigger: true,
next_available_at: null,
cooldown_seconds: 300,
},
};
}
if (mockState === 'running') {
return {
status: 'running',
last_run_at: new Date(now.getTime() - 30 * 1000).toISOString(), // 30 seconds ago
last_success_at: lastRun.toISOString(),
last_error: null,
slots_generated: null,
rate_limit: {
can_trigger: false,
next_available_at: null,
cooldown_seconds: 300,
},
};
}
if (mockState === 'failed') {
return {
status: 'failed',
last_run_at: new Date(now.getTime() - 5 * 60 * 1000).toISOString(), // 5 minutes ago
last_success_at: lastRun.toISOString(),
last_error: 'Database connection timeout',
slots_generated: null,
rate_limit: {
can_trigger: true,
next_available_at: null,
cooldown_seconds: 300,
},
};
}
if (mockState === 'rate_limited') {
return {
status: 'completed',
last_run_at: new Date(now.getTime() - 2 * 60 * 1000).toISOString(), // 2 minutes ago
last_success_at: new Date(now.getTime() - 2 * 60 * 1000).toISOString(),
last_error: null,
slots_generated: 234,
rate_limit: {
can_trigger: false,
next_available_at: nextAvailable.toISOString(),
cooldown_seconds: 300,
},
};
}
// Default to success
return {
status: 'completed',
last_run_at: lastRun.toISOString(),
last_success_at: lastRun.toISOString(),
last_error: null,
slots_generated: 234,
rate_limit: {
can_trigger: true,
next_available_at: null,
cooldown_seconds: 300,
},
};
}
export function getMockTriggerResponse(): MaterialisationTriggerResponse {
mockTriggerCount++;
// First trigger: accept and start running
if (mockTriggerCount === 1) {
mockState = 'running';
return {
status: 'accepted',
job_id: 'job-' + crypto.randomUUID(),
message: 'Materialisation job queued successfully',
};
}
// Second trigger: simulate rate limit (should not happen with proper UI, but handle it)
if (mockTriggerCount === 2) {
mockState = 'rate_limited';
throw {
success: false,
error: {
type: 'about:blank',
title: 'Rate Limit Exceeded',
status: 429,
detail: 'Materialisation can only be triggered once every 5 minutes. Please wait before retrying.',
code: 'rate_limit_exceeded',
retry_after: 180,
next_available_at: new Date(Date.now() + 3 * 60 * 1000).toISOString(),
},
};
}
// Subsequent triggers: accept
mockState = 'running';
return {
status: 'accepted',
job_id: 'job-' + crypto.randomUUID(),
message: 'Materialisation job queued successfully',
};
}
/**
* Helper to set mock state for testing different scenarios
* (Only used in development/testing)
*/
export function setMockState(state: typeof mockState) {
mockState = state;
}

@ -0,0 +1,151 @@
/**
* Court Management Types
*
* Types for club profile and court inventory CRUD operations.
*/
export interface Court {
court_id: number;
name: string;
created_at: string; // ISO 8601 timestamp
updated_at: string; // ISO 8601 timestamp
}
export interface CourtRequest {
name: string;
}
export interface CourtDependencies {
can_delete: boolean;
dependencies: {
slot_definitions: number;
upcoming_bookings: number;
};
}
export interface ClubProfile {
club_id: number;
name: string;
timezone: string; // IANA timezone
address_line_1?: string | null;
address_line_2?: string | null;
city?: string | null;
postal_code?: string | null;
country?: string | null;
phone?: string | null;
email?: string | null;
website?: string | null;
provider: 'local' | 'fairplay' | 'other';
remote_club_id?: string | null;
created_at: string; // ISO 8601 timestamp
updated_at: string; // ISO 8601 timestamp
}
export interface ClubProfileUpdateRequest {
name: string;
timezone: string;
address_line_1?: string;
address_line_2?: string;
city?: string;
postal_code?: string;
country?: string;
phone?: string;
email?: string;
website?: string;
}
export interface CourtError {
type: string;
title: string;
status: number;
detail: string;
code: string;
errors?: ValidationError[];
dependencies?: {
slot_definitions: number;
upcoming_bookings: number;
};
}
export interface ValidationError {
field: string;
message: string;
}
/**
* IANA Timezone list (common timezones)
* Full list can be expanded as needed
*/
export const COMMON_TIMEZONES = [
{ value: 'Europe/London', label: 'London (GMT/BST)' },
{ value: 'Europe/Paris', label: 'Paris (CET/CEST)' },
{ value: 'Europe/Berlin', label: 'Berlin (CET/CEST)' },
{ value: 'Europe/Madrid', label: 'Madrid (CET/CEST)' },
{ value: 'Europe/Rome', label: 'Rome (CET/CEST)' },
{ value: 'Europe/Amsterdam', label: 'Amsterdam (CET/CEST)' },
{ value: 'Europe/Brussels', label: 'Brussels (CET/CEST)' },
{ value: 'Europe/Vienna', label: 'Vienna (CET/CEST)' },
{ value: 'Europe/Zurich', label: 'Zurich (CET/CEST)' },
{ value: 'Europe/Stockholm', label: 'Stockholm (CET/CEST)' },
{ value: 'Europe/Copenhagen', label: 'Copenhagen (CET/CEST)' },
{ value: 'Europe/Oslo', label: 'Oslo (CET/CEST)' },
{ value: 'Europe/Helsinki', label: 'Helsinki (EET/EEST)' },
{ value: 'Europe/Athens', label: 'Athens (EET/EEST)' },
{ value: 'Europe/Istanbul', label: 'Istanbul (TRT)' },
{ value: 'Europe/Moscow', label: 'Moscow (MSK)' },
{ value: 'America/New_York', label: 'New York (EST/EDT)' },
{ value: 'America/Chicago', label: 'Chicago (CST/CDT)' },
{ value: 'America/Denver', label: 'Denver (MST/MDT)' },
{ value: 'America/Los_Angeles', label: 'Los Angeles (PST/PDT)' },
{ value: 'America/Toronto', label: 'Toronto (EST/EDT)' },
{ value: 'America/Vancouver', label: 'Vancouver (PST/PDT)' },
{ value: 'America/Mexico_City', label: 'Mexico City (CST/CDT)' },
{ value: 'America/Sao_Paulo', label: 'São Paulo (BRT/BRST)' },
{ value: 'America/Buenos_Aires', label: 'Buenos Aires (ART)' },
{ value: 'Asia/Dubai', label: 'Dubai (GST)' },
{ value: 'Asia/Kolkata', label: 'Kolkata (IST)' },
{ value: 'Asia/Bangkok', label: 'Bangkok (ICT)' },
{ value: 'Asia/Singapore', label: 'Singapore (SGT)' },
{ value: 'Asia/Hong_Kong', label: 'Hong Kong (HKT)' },
{ value: 'Asia/Shanghai', label: 'Shanghai (CST)' },
{ value: 'Asia/Tokyo', label: 'Tokyo (JST)' },
{ value: 'Asia/Seoul', label: 'Seoul (KST)' },
{ value: 'Australia/Sydney', label: 'Sydney (AEDT/AEST)' },
{ value: 'Australia/Melbourne', label: 'Melbourne (AEDT/AEST)' },
{ value: 'Australia/Brisbane', label: 'Brisbane (AEST)' },
{ value: 'Pacific/Auckland', label: 'Auckland (NZDT/NZST)' },
];
/**
* Helper to validate email format
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* Helper to validate URL format
*/
export function isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* Helper to format timestamp for display
*/
export function formatTimestamp(isoString: string): string {
const date = new Date(isoString);
return date.toLocaleString('en-GB', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}

@ -0,0 +1,96 @@
/**
* Materialisation Status Types
*
* Types for slot materialisation job status and manual trigger endpoints.
* Supports status polling, rate limiting, and idempotency.
*/
export type MaterialisationJobStatus = 'idle' | 'running' | 'completed' | 'failed';
export interface MaterialisationRateLimit {
can_trigger: boolean;
next_available_at: string | null; // ISO 8601 timestamp
cooldown_seconds: number;
}
export interface MaterialisationStatus {
status: MaterialisationJobStatus;
last_run_at: string | null; // ISO 8601 timestamp
last_success_at: string | null; // ISO 8601 timestamp
last_error: string | null;
slots_generated: number | null;
rate_limit: MaterialisationRateLimit;
}
export interface MaterialisationTriggerRequest {
idempotency_key: string; // UUID v4
}
export interface MaterialisationTriggerResponse {
status: 'accepted' | 'running' | 'completed' | 'failed';
job_id: string;
message: string;
duplicate?: boolean;
}
export interface MaterialisationError {
type: string;
title: string;
status: number;
detail: string;
code: string;
retry_after?: number; // seconds (for 429 responses)
next_available_at?: string; // ISO 8601 timestamp (for 429 responses)
}
/**
* Helper to calculate remaining cooldown seconds
*/
export function calculateRemainingCooldown(nextAvailableAt: string | null): number {
if (!nextAvailableAt) return 0;
const now = new Date();
const next = new Date(nextAvailableAt);
const diffMs = next.getTime() - now.getTime();
return Math.max(0, Math.ceil(diffMs / 1000));
}
/**
* Helper to format countdown display (e.g., "2m 34s", "45s")
*/
export function formatCountdown(seconds: number): string {
if (seconds <= 0) return '0s';
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes > 0) {
return `${minutes}m ${remainingSeconds}s`;
}
return `${seconds}s`;
}
/**
* Helper to format timestamp for display
*/
export function formatTimestamp(isoString: string): string {
const date = new Date(isoString);
return date.toLocaleString('en-GB', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
}
/**
* Generate UUID v4 for idempotency keys
*/
export function generateIdempotencyKey(): string {
return crypto.randomUUID();
}
Loading…
Cancel
Save