feat(slot-definitions): implement Phase 1 slot definition table UI
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 validation
master
Guillermo Pages 1 month ago
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…
Cancel
Save