feat(slot-definitions): implement Phase 1 slot definition table UI
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
Add TypeScript types, API client, and table view for slot definitions. Uses mock data until backend endpoints are ready. Components: - SlotDefinitionsComponent: table with create/edit/delete actions - TypeScript types matching API contract specs - API client with CRUD operations (POST/GET/PATCH/DELETE) - Mock data for local development Features: - Professional slate theme throughout - Empty state with CTA - Table showing: day, time range, duration, capacity, valid period, description - Edit/delete action buttons (TODOs for modals) - Loading and error states with proper UX - Breadcrumb navigation back to club Contract alignment: - Day format: 0=Monday, 6=Sunday - Time format: HH:MM:SS with display helpers - Date range: valid_from required, valid_to optional - RFC-7807 error handling ready Next: Create/edit form modal with validationmaster
parent
b3778b22af
commit
a8ad9fed51
@ -0,0 +1,265 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Calendar, Plus, Loader2, AlertCircle, Edit, Trash2, ArrowLeft } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import useTranslation from '@/src/hooks/useTranslation';
|
||||||
|
import { getSlotDefinitions, getMockSlotDefinitions } from '@/src/lib/api/slot-definitions';
|
||||||
|
import type { SlotDefinition, SlotDefinitionError } from '@/src/types/slot-definitions';
|
||||||
|
import { DAY_NAMES, formatTime, calculateEndTime } from '@/src/types/slot-definitions';
|
||||||
|
|
||||||
|
interface SlotDefinitionsComponentProps {
|
||||||
|
clubId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SlotDefinitionsComponent({ clubId }: SlotDefinitionsComponentProps) {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const [definitions, setDefinitions] = useState<SlotDefinition[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<SlotDefinitionError | null>(null);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDefinitions();
|
||||||
|
}, [clubId]);
|
||||||
|
|
||||||
|
async function loadDefinitions() {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Use mock data for now (until backend is ready)
|
||||||
|
const USE_MOCKS = true;
|
||||||
|
|
||||||
|
if (USE_MOCKS) {
|
||||||
|
// Simulate API delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
setDefinitions(getMockSlotDefinitions());
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getSlotDefinitions(clubId);
|
||||||
|
if (result.success) {
|
||||||
|
setDefinitions(result.data);
|
||||||
|
setError(null);
|
||||||
|
} else {
|
||||||
|
setError(result.error);
|
||||||
|
setDefinitions([]);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
setShowCreateModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(definition: SlotDefinition) {
|
||||||
|
// TODO: Open edit modal
|
||||||
|
console.log('Edit', definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(definition: SlotDefinition) {
|
||||||
|
if (!confirm(`Delete slot definition for ${DAY_NAMES[definition.dow]} at ${formatTime(definition.starts_at)}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// TODO: Call delete API
|
||||||
|
console.log('Delete', definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-4">
|
||||||
|
<Loader2 className="w-12 h-12 text-slate-900 animate-spin" />
|
||||||
|
<p className="text-slate-600 font-medium">Loading slot definitions...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="bg-red-50 border-2 border-red-200 rounded-2xl p-8">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<AlertCircle className="w-8 h-8 text-red-600 flex-shrink-0 mt-1" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 mb-4">
|
||||||
|
Error Loading Slot Definitions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-700 mb-4 leading-relaxed">
|
||||||
|
{error.detail}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-600 font-mono mb-6">
|
||||||
|
Error code: {error.code}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/admin/clubs/${clubId}`}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to club
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/admin/clubs/${clubId}`}
|
||||||
|
className="inline-flex items-center text-slate-600 hover:text-slate-900 font-medium transition-colors mb-4"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to club
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold text-slate-900 mb-2 tracking-tight">
|
||||||
|
Slot Definitions
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-slate-600 font-light">
|
||||||
|
Recurring schedule templates for automatic slot generation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="inline-flex items-center px-6 py-3 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors shadow-lg"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5 mr-2" />
|
||||||
|
Create Definition
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{definitions.length === 0 ? (
|
||||||
|
<div className="bg-slate-50 border-2 border-slate-200 rounded-2xl p-12">
|
||||||
|
<div className="flex flex-col items-center text-center space-y-6">
|
||||||
|
<Calendar className="w-20 h-20 text-slate-400" />
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900">
|
||||||
|
No Slot Definitions Yet
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 max-w-md leading-relaxed">
|
||||||
|
Create your first recurring slot definition to automatically generate available time slots for bookings.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="inline-flex items-center px-6 py-3 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5 mr-2" />
|
||||||
|
Create First Definition
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Table */
|
||||||
|
<div className="bg-white border-2 border-slate-200 rounded-2xl overflow-hidden shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-slate-50 border-b-2 border-slate-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-left text-sm font-bold text-slate-900">Day</th>
|
||||||
|
<th className="px-6 py-4 text-left text-sm font-bold text-slate-900">Time</th>
|
||||||
|
<th className="px-6 py-4 text-left text-sm font-bold text-slate-900">Duration</th>
|
||||||
|
<th className="px-6 py-4 text-left text-sm font-bold text-slate-900">Capacity</th>
|
||||||
|
<th className="px-6 py-4 text-left text-sm font-bold text-slate-900">Valid Period</th>
|
||||||
|
<th className="px-6 py-4 text-left text-sm font-bold text-slate-900">Description</th>
|
||||||
|
<th className="px-6 py-4 text-right text-sm font-bold text-slate-900">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{definitions.map((definition) => {
|
||||||
|
const endTime = calculateEndTime(definition.starts_at, definition.duration_minutes);
|
||||||
|
return (
|
||||||
|
<tr key={definition.slot_definition_id} className="hover:bg-slate-50 transition-colors">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="font-semibold text-slate-900">
|
||||||
|
{DAY_NAMES[definition.dow]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="text-slate-700 font-medium">
|
||||||
|
{formatTime(definition.starts_at)} - {endTime}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="text-slate-700">{definition.duration_minutes} min</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="text-slate-700">{definition.capacity} players</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="text-sm text-slate-600">
|
||||||
|
<div>{definition.valid_from}</div>
|
||||||
|
{definition.valid_to && (
|
||||||
|
<div className="text-slate-500">to {definition.valid_to}</div>
|
||||||
|
)}
|
||||||
|
{!definition.valid_to && (
|
||||||
|
<div className="text-slate-500">No end date</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{definition.rule?.description ? (
|
||||||
|
<span className="text-sm text-slate-600">{definition.rule.description}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-slate-400 italic">No description</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(definition)}
|
||||||
|
className="p-2 text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-lg transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(definition)}
|
||||||
|
className="p-2 text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TODO: Create/Edit Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white rounded-2xl p-8 max-w-2xl w-full">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 mb-4">Create Slot Definition</h2>
|
||||||
|
<p className="text-slate-600 mb-6">Form coming next...</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
className="px-4 py-2 bg-slate-900 text-white rounded-lg hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
import AdminAuthGuard from '@/src/components/AdminAuthGuard';
|
||||||
|
import SlotDefinitionsComponent from './SlotDefinitionsComponent';
|
||||||
|
|
||||||
|
export default async function SlotDefinitionsPage({
|
||||||
|
params
|
||||||
|
}: {
|
||||||
|
params: Promise<{ club_id: string }>;
|
||||||
|
}) {
|
||||||
|
const { club_id } = await params;
|
||||||
|
const clubId = parseInt(club_id, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminAuthGuard>
|
||||||
|
<SlotDefinitionsComponent clubId={clubId} />
|
||||||
|
</AdminAuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,219 @@
|
|||||||
|
import type {
|
||||||
|
SlotDefinition,
|
||||||
|
SlotDefinitionRequest,
|
||||||
|
SlotDefinitionError,
|
||||||
|
} from '@/src/types/slot-definitions';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://api.playchoo.com';
|
||||||
|
|
||||||
|
type ApiResult<T> =
|
||||||
|
| { success: true; data: T }
|
||||||
|
| { success: false; error: SlotDefinitionError };
|
||||||
|
|
||||||
|
// GET /admin/clubs/{club_id}/slot-definitions
|
||||||
|
export async function getSlotDefinitions(
|
||||||
|
clubId: number,
|
||||||
|
filters?: { court_id?: number; active_on?: string }
|
||||||
|
): Promise<ApiResult<SlotDefinition[]>> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.court_id) params.append('court_id', filters.court_id.toString());
|
||||||
|
if (filters?.active_on) params.append('active_on', filters.active_on);
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const url = `${API_BASE_URL}/admin/clubs/${clubId}/slot-definitions${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error: SlotDefinitionError = await response.json();
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: SlotDefinition[] = await response.json();
|
||||||
|
return { success: true, data };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Network Error',
|
||||||
|
status: 0,
|
||||||
|
detail: 'Failed to fetch slot definitions. Please check your connection.',
|
||||||
|
code: 'network_error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/clubs/{club_id}/slot-definitions
|
||||||
|
export async function createSlotDefinition(
|
||||||
|
clubId: number,
|
||||||
|
request: SlotDefinitionRequest
|
||||||
|
): Promise<ApiResult<SlotDefinition>> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/slot-definitions`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error: SlotDefinitionError = await response.json();
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: SlotDefinition = await response.json();
|
||||||
|
return { success: true, data };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Network Error',
|
||||||
|
status: 0,
|
||||||
|
detail: 'Failed to create slot definition. Please check your connection.',
|
||||||
|
code: 'network_error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /admin/clubs/{club_id}/slot-definitions/{slot_definition_id}
|
||||||
|
export async function updateSlotDefinition(
|
||||||
|
clubId: number,
|
||||||
|
slotDefinitionId: number,
|
||||||
|
request: Partial<SlotDefinitionRequest>
|
||||||
|
): Promise<ApiResult<SlotDefinition>> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/admin/clubs/${clubId}/slot-definitions/${slotDefinitionId}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error: SlotDefinitionError = await response.json();
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: SlotDefinition = await response.json();
|
||||||
|
return { success: true, data };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Network Error',
|
||||||
|
status: 0,
|
||||||
|
detail: 'Failed to update slot definition. Please check your connection.',
|
||||||
|
code: 'network_error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /admin/clubs/{club_id}/slot-definitions/{slot_definition_id}
|
||||||
|
export async function deleteSlotDefinition(
|
||||||
|
clubId: number,
|
||||||
|
slotDefinitionId: number
|
||||||
|
): Promise<ApiResult<void>> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/admin/clubs/${clubId}/slot-definitions/${slotDefinitionId}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error: SlotDefinitionError = await response.json();
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: undefined };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Network Error',
|
||||||
|
status: 0,
|
||||||
|
detail: 'Failed to delete slot definition. Please check your connection.',
|
||||||
|
code: 'network_error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock data for local development
|
||||||
|
export function getMockSlotDefinitions(): SlotDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
slot_definition_id: 1,
|
||||||
|
court_id: 101,
|
||||||
|
dow: 0, // Monday
|
||||||
|
starts_at: '09:00:00',
|
||||||
|
duration_minutes: 90,
|
||||||
|
capacity: 4,
|
||||||
|
valid_from: '2025-01-01',
|
||||||
|
valid_to: '2025-12-31',
|
||||||
|
rule: {
|
||||||
|
weekly: true,
|
||||||
|
description: 'Monday morning regulars',
|
||||||
|
},
|
||||||
|
created_at: '2025-11-01T10:00:00Z',
|
||||||
|
updated_at: '2025-11-01T10:00:00Z',
|
||||||
|
updated_by_app_user_id: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot_definition_id: 2,
|
||||||
|
court_id: 102,
|
||||||
|
dow: 2, // Wednesday
|
||||||
|
starts_at: '14:00:00',
|
||||||
|
duration_minutes: 60,
|
||||||
|
capacity: 4,
|
||||||
|
valid_from: '2025-01-01',
|
||||||
|
rule: {
|
||||||
|
weekly: true,
|
||||||
|
description: 'Afternoon session',
|
||||||
|
},
|
||||||
|
created_at: '2025-11-01T10:00:00Z',
|
||||||
|
updated_at: '2025-11-01T10:00:00Z',
|
||||||
|
updated_by_app_user_id: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot_definition_id: 3,
|
||||||
|
court_id: 101,
|
||||||
|
dow: 5, // Saturday
|
||||||
|
starts_at: '10:00:00',
|
||||||
|
duration_minutes: 120,
|
||||||
|
capacity: 4,
|
||||||
|
valid_from: '2025-01-01',
|
||||||
|
valid_to: '2025-06-30',
|
||||||
|
rule: {
|
||||||
|
weekly: true,
|
||||||
|
description: 'Weekend tournament slots',
|
||||||
|
},
|
||||||
|
created_at: '2025-11-01T10:00:00Z',
|
||||||
|
updated_at: '2025-11-01T10:00:00Z',
|
||||||
|
updated_by_app_user_id: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
// Slot Definition Types based on Backend API Contract
|
||||||
|
// Contract: docs/owners/payloads/slot-definition-api-contract.md
|
||||||
|
|
||||||
|
export type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0=Monday, 6=Sunday
|
||||||
|
|
||||||
|
export interface SlotDefinitionRule {
|
||||||
|
weekly: boolean;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlotDefinitionRequest {
|
||||||
|
court_id: number;
|
||||||
|
dow: DayOfWeek;
|
||||||
|
starts_at: string; // HH:MM:SS format
|
||||||
|
duration_minutes: number;
|
||||||
|
capacity: number;
|
||||||
|
valid_from: string; // YYYY-MM-DD
|
||||||
|
valid_to?: string; // YYYY-MM-DD, optional
|
||||||
|
rule?: SlotDefinitionRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlotDefinition extends SlotDefinitionRequest {
|
||||||
|
slot_definition_id: number;
|
||||||
|
created_at: string; // ISO 8601
|
||||||
|
updated_at: string; // ISO 8601
|
||||||
|
updated_by_app_user_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationError {
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlotDefinitionError {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
status: number;
|
||||||
|
detail: string;
|
||||||
|
code: string;
|
||||||
|
errors?: ValidationError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper constants
|
||||||
|
export const DAY_NAMES: Record<DayOfWeek, string> = {
|
||||||
|
0: 'Monday',
|
||||||
|
1: 'Tuesday',
|
||||||
|
2: 'Wednesday',
|
||||||
|
3: 'Thursday',
|
||||||
|
4: 'Friday',
|
||||||
|
5: 'Saturday',
|
||||||
|
6: 'Sunday',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DAY_NAMES_SHORT: Record<DayOfWeek, string> = {
|
||||||
|
0: 'Mon',
|
||||||
|
1: 'Tue',
|
||||||
|
2: 'Wed',
|
||||||
|
3: 'Thu',
|
||||||
|
4: 'Fri',
|
||||||
|
5: 'Sat',
|
||||||
|
6: 'Sun',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to format time for display
|
||||||
|
export function formatTime(timeStr: string): string {
|
||||||
|
// Convert HH:MM:SS to HH:MM
|
||||||
|
return timeStr.substring(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to format time for API
|
||||||
|
export function formatTimeForAPI(timeStr: string): string {
|
||||||
|
// Ensure HH:MM:SS format
|
||||||
|
if (timeStr.length === 5) {
|
||||||
|
return `${timeStr}:00`;
|
||||||
|
}
|
||||||
|
return timeStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to calculate end time
|
||||||
|
export function calculateEndTime(startTime: string, durationMinutes: number): string {
|
||||||
|
const [hours, minutes] = startTime.split(':').map(Number);
|
||||||
|
const totalMinutes = hours * 60 + minutes + durationMinutes;
|
||||||
|
const endHours = Math.floor(totalMinutes / 60);
|
||||||
|
const endMinutes = totalMinutes % 60;
|
||||||
|
return `${String(endHours).padStart(2, '0')}:${String(endMinutes).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue