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