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