feat(admin): add materialisation trigger/status UI + court management infrastructure
continuous-integration/drone/push Build is passing
Details
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
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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…
Reference in New Issue