This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
@@ -0,0 +1,990 @@
import {
Box,
Heading,
Text,
Spinner,
Alert,
AlertIcon,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
HStack,
Badge,
Button,
useToast,
Drawer,
DrawerOverlay,
DrawerContent,
DrawerHeader,
DrawerBody,
DrawerFooter,
FormControl,
FormLabel,
Input,
Stack,
InputGroup,
InputRightElement,
List,
ListItem,
FormErrorMessage,
Image,
useBreakpointValue,
Wrap,
WrapItem,
useColorModeValue,
Select
} from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TeamLogo } from '../../components/common/TeamLogo';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { parse } from 'date-fns';
import { assetUrl } from '../../utils/url';
const MatchesAdminPage = () => {
const queryClient = useQueryClient();
const toast = useToast();
const [isOpen, setIsOpen] = useState(false);
const [focusSide, setFocusSide] = useState<'home' | 'away' | null>(null);
const [selected, setSelected] = useState<any | null>(null);
const [form, setForm] = useState({
home_name_override: '',
away_name_override: '',
venue_override: '',
date_time_override: '',
home_logo_url: '',
away_logo_url: '',
notes: '',
});
// External logo upload helpers/state
const [homeExternalTeamId, setHomeExternalTeamId] = useState<string>('');
const [awayExternalTeamId, setAwayExternalTeamId] = useState<string>('');
const [homeUploadedFile, setHomeUploadedFile] = useState<File | null>(null);
const [awayUploadedFile, setAwayUploadedFile] = useState<File | null>(null);
// Team search state
const [homeQuery, setHomeQuery] = useState('');
const [awayQuery, setAwayQuery] = useState('');
const [debouncedHome, setDebouncedHome] = useState('');
const [debouncedAway, setDebouncedAway] = useState('');
useEffect(() => {
const t = setTimeout(() => setDebouncedHome(homeQuery), 300);
return () => clearTimeout(t);
}, [homeQuery]);
useEffect(() => {
const t = setTimeout(() => setDebouncedAway(awayQuery), 300);
return () => clearTimeout(t);
}, [awayQuery]);
const { data: homeResults = [] } = useQuery({
queryKey: ['club-search-home', debouncedHome],
queryFn: () => searchClubs(debouncedHome),
enabled: debouncedHome.trim().length >= 2,
});
const { data: awayResults = [] } = useQuery({
queryKey: ['club-search-away', debouncedAway],
queryFn: () => searchClubs(debouncedAway),
enabled: debouncedAway.trim().length >= 2,
});
// Upload refs
const homeFileRef = useRef<HTMLInputElement | null>(null);
const awayFileRef = useRef<HTMLInputElement | null>(null);
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
queryKey: ['admin-matches-list-cache'],
queryFn: async () => {
// Read cached FACR club info
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(apiUrl).origin;
const url = `${origin}/cache/prefetch/facr_club_info.json`;
const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
if (!res.ok) throw new Error(`Failed to load cache: ${res.status}`);
const json = await res.json();
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
const items: any[] = comps.flatMap((c: any) =>
(Array.isArray(c.matches) ? c.matches : []).map((m: any) => ({ ...m, competitionName: c.name, competition_id: c.id }))
);
// Optional: stable sort by date ascending
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
items.sort((a, b) => {
const da = parse(String(a.date_time || a.date), FACR_DATE_FMT, new Date()).getTime();
const db = parse(String(b.date_time || b.date), FACR_DATE_FMT, new Date()).getTime();
return da - db;
});
return items.map((m: any) => ({
id: m.match_id,
date_time: m.date_time || m.date,
competitionName: m.competitionName,
competition_id: m.competition_id,
home: m.home || m.home_team,
home_id: m.home_id || m.home_team_id || m.home_team_facr_id,
away: m.away || m.away_team,
away_id: m.away_id || m.away_team_id || m.away_team_facr_id,
score: m.score,
venue: m.venue,
home_logo_url: m.home_logo_url,
away_logo_url: m.away_logo_url,
}));
},
});
// Filters
const [teamFilter, setTeamFilter] = useState('');
const [dateFrom, setDateFrom] = useState<string>(''); // YYYY-MM-DD
const [dateTo, setDateTo] = useState<string>(''); // YYYY-MM-DD
const [competitionFilter, setCompetitionFilter] = useState<string>('');
const [sideFilter, setSideFilter] = useState<'home' | 'away' | ''>('');
const normalizedTeam = teamFilter.trim().toLowerCase();
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
// Club name (for side filter)
const { data: publicSettings } = useQuery({
queryKey: ['public-settings'],
queryFn: getPublicSettings,
});
const { data: facrClubInfo } = useQuery({
queryKey: ['facr-club-info-name'],
queryFn: async () => {
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(apiUrl).origin;
const url = `${origin}/cache/prefetch/facr_club_info.json`;
const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
if (!res.ok) return null;
return await res.json();
},
});
const clubName: string = (publicSettings as any)?.club_name || (facrClubInfo as any)?.name || '';
const normalize = (s: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const stripPrefixes = (s: string) => {
let x = normalize(s);
// Common Czech club prefixes/words
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '').replace(/\s+/g, ' ').trim();
return x;
};
const clubNorm = normalize(clubName);
const clubStrip = stripPrefixes(clubName);
const teamMatchesClub = (team: string): boolean => {
const t = normalize(team);
const ts = stripPrefixes(team);
return !!clubNorm && (t.includes(clubNorm) || ts.includes(clubStrip) || t.endsWith(clubStrip) || clubStrip.endsWith(ts));
};
const competitionOptions = useMemo(() => {
const set = new Set<string>();
for (const m of matches) {
if (m.competitionName) set.add(String(m.competitionName));
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [matches]);
const filteredMatches = matches.filter((m: any) => {
// team filter
const teamOk = normalizedTeam
? (
sideFilter === 'home'
? [m.home, m.home_team].filter(Boolean).some((v: string) => String(v).toLowerCase().includes(normalizedTeam))
: sideFilter === 'away'
? [m.away, m.away_team].filter(Boolean).some((v: string) => String(v).toLowerCase().includes(normalizedTeam))
: [m.home, m.home_team, m.away, m.away_team]
.filter(Boolean)
.some((v: string) => String(v).toLowerCase().includes(normalizedTeam))
)
: true;
if (!teamOk) return false;
// competition filter
if (competitionFilter && String(m.competitionName || '') !== competitionFilter) return false;
// side filter based on club name
if (sideFilter && clubNorm) {
const homeName = String(m.home || m.home_team || '');
const awayName = String(m.away || m.away_team || '');
if (sideFilter === 'home' && !teamMatchesClub(homeName)) return false;
if (sideFilter === 'away' && !teamMatchesClub(awayName)) return false;
}
// date parse
const dtStr = String(m.date_time || m.date || '');
let ts = NaN;
try {
ts = parse(dtStr, FACR_DATE_FMT, new Date()).getTime();
} catch (_) {
const d2 = new Date(dtStr);
ts = d2.getTime();
}
if (isNaN(ts)) return true; // if can't parse, let it pass other filters
// date range filter
if (dateFrom) {
const fromTs = new Date(dateFrom + 'T00:00:00').getTime();
if (!isNaN(fromTs) && ts < fromTs) return false;
}
if (dateTo) {
const toTs = new Date(dateTo + 'T23:59:59').getTime();
if (!isNaN(toTs) && ts > toTs) return false;
}
return true;
});
// Pagination (Load more) + page size selector
const [pageSize, setPageSize] = useState(50);
const [limit, setLimit] = useState(50);
const [searchParams, setSearchParams] = useSearchParams();
// Initialize filters from URL on first load and when data changes (so comps are known)
useEffect(() => {
const spTeam = searchParams.get('team') || '';
const spFrom = searchParams.get('from') || '';
const spTo = searchParams.get('to') || '';
const spComp = searchParams.get('comp') || '';
const spVenue = searchParams.get('venue') || '';
const spSide = searchParams.get('side') || '';
const spSize = parseInt(searchParams.get('size') || '') || undefined;
const spLimit = parseInt(searchParams.get('limit') || '') || undefined;
if (spTeam) setTeamFilter(spTeam);
if (spFrom) setDateFrom(spFrom);
if (spTo) setDateTo(spTo);
if (spComp) setCompetitionFilter(spComp);
// venue filter removed
if (spSide === 'home' || spSide === 'away') setSideFilter(spSide);
if (spSize) {
setPageSize(spSize);
setLimit(spSize);
}
if (spLimit) setLimit(spLimit);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Keep URL in sync when filters/pagination change
useEffect(() => {
const params: Record<string, string> = {};
if (teamFilter) params.team = teamFilter;
if (dateFrom) params.from = dateFrom;
if (dateTo) params.to = dateTo;
if (competitionFilter) params.comp = competitionFilter;
// venue filter removed
if (sideFilter) params.side = sideFilter;
if (pageSize !== 50) params.size = String(pageSize);
if (limit !== pageSize) params.limit = String(limit);
setSearchParams(params, { replace: true });
}, [teamFilter, dateFrom, dateTo, competitionFilter, sideFilter, pageSize, limit, setSearchParams]);
useEffect(() => {
// reset pagination on filter change
setLimit(pageSize);
}, [normalizedTeam, dateFrom, dateTo, competitionFilter, sideFilter, clubNorm, pageSize]);
const visibleMatches = filteredMatches.slice(0, limit);
// Date presets
const setThisWeek = () => {
const now = new Date();
const day = now.getDay(); // 0 Sun .. 6 Sat
const diffToMonday = (day === 0 ? -6 : 1 - day); // Monday start
const monday = new Date(now);
monday.setDate(now.getDate() + diffToMonday);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
const f = monday.toISOString().slice(0, 10);
const t = sunday.toISOString().slice(0, 10);
setDateFrom(f);
setDateTo(t);
};
const setNext30Days = () => {
const now = new Date();
const to = new Date(now);
to.setDate(now.getDate() + 30);
const f = now.toISOString().slice(0, 10);
const t = to.toISOString().slice(0, 10);
setDateFrom(f);
setDateTo(t);
};
// Export CSV of filtered results
const exportCsv = () => {
const rows = filteredMatches.map((m: any) => {
const date = m.date_time || m.date || '';
const comp = m.competitionName || '';
const home = m.home || m.home_team || '';
const away = m.away || m.away_team || '';
const score = m.score || (m.result_home != null && m.result_away != null ? `${m.result_home}:${m.result_away}` : '');
const venue = m.venue || '';
return { date, competition: comp, home, away, score, venue };
});
const headers = ['date', 'competition', 'home', 'away', 'score', 'venue'];
const escape = (v: any) => {
const s = String(v ?? '');
if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
return s;
};
const csv = [headers.join(','), ...rows.map(r => headers.map(h => escape((r as any)[h])).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'matches.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Datetime validation (RFC3339-ish)
const isDateInvalid = form.date_time_override.trim() !== '' && isNaN(Date.parse(form.date_time_override));
const saveMutation = useMutation({
mutationFn: async () => {
const externalMatchId: string = selected?.match_id || selected?.id;
if (!externalMatchId) throw new Error('Chybí match_id');
const payload: any = { ...form };
// normalize empty strings to null so backend can clear values
Object.keys(payload).forEach((k) => {
if (payload[k as keyof typeof payload] === '') payload[k as keyof typeof payload] = null;
});
// First store current overrides
await putMatchOverride(externalMatchId, payload);
// Best-effort upload to logoapi.sportcreative.eu for home/away
const results: { home?: { success: boolean; error?: string }; away?: { success: boolean; error?: string } } = {};
const processSide = async (
side: 'home' | 'away',
externalTeamId: string,
uploadedFile: File | null,
nameOverride: string,
logoUrl: string | null
) => {
try {
if (!externalTeamId) return { success: false, error: 'Chybí ID týmu' };
let file: File | Blob | null = uploadedFile;
if (!file && logoUrl) {
file = await fetchLogoAsBlob(logoUrl);
}
if (!file) return { success: false, error: 'Nelze získat soubor loga' };
const up = await uploadToLogaSportcreative(externalTeamId, file, {
filename: file instanceof File ? file.name : `${externalTeamId}.png`,
clubName: nameOverride || 'Neznámý klub',
clubType: 'football',
});
if (!up.success) return { success: false, error: up.error || 'Upload selhal' };
if (up.url) {
// Patch override to immediately use external URL
await patchMatchOverride(
externalMatchId,
side === 'home' ? { home_logo_url: up.url } : { away_logo_url: up.url }
);
}
return { success: true };
} catch (e: any) {
return { success: false, error: e?.message || 'Chyba při uploadu' };
}
};
if (homeExternalTeamId && (form.home_logo_url || homeUploadedFile)) {
results.home = await processSide('home', homeExternalTeamId, homeUploadedFile, form.home_name_override, form.home_logo_url);
}
if (awayExternalTeamId && (form.away_logo_url || awayUploadedFile)) {
results.away = await processSide('away', awayExternalTeamId, awayUploadedFile, form.away_name_override, form.away_logo_url);
}
return { ok: true, results };
},
onSuccess: (res: any) => {
const r = res?.results || {};
const parts: string[] = [];
if (r.home) parts.push(r.home.success ? 'Logo domácích nahráno' : `Domácí: ${r.home.error || 'chyba'}`);
if (r.away) parts.push(r.away.success ? 'Logo hostů nahráno' : `Hosté: ${r.away.error || 'chyba'}`);
const description = parts.length ? parts.join(' • ') : undefined;
toast({ title: 'Uloženo', description, status: 'success' });
setIsOpen(false);
setSelected(null);
setHomeUploadedFile(null);
setAwayUploadedFile(null);
// Invalidate the cache-backed list to refresh any merged overrides
queryClient.invalidateQueries({ queryKey: ['admin-matches-list-cache'] });
},
onError: (e: any) => {
toast({ title: 'Uložení selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
},
});
const openEdit = (m: any, side?: 'home' | 'away') => {
setSelected(m);
// Convert FACR-style date (e.g., 25.08.2025 18:30) to RFC3339 for backend
const facrStr: string = m.date_time || m.date || '';
let iso = '';
if (facrStr) {
try {
const dt = parse(String(facrStr), 'dd.MM.yyyy HH:mm', new Date());
if (!isNaN(dt.getTime())) iso = dt.toISOString();
} catch (_) {
// If it's already ISO or another parseable format, keep as-is if valid
const d2 = new Date(facrStr);
if (!isNaN(d2.getTime())) iso = d2.toISOString();
}
}
setForm({
home_name_override: m.home || m.home_team || '',
away_name_override: m.away || m.away_team || '',
venue_override: m.venue || '',
date_time_override: iso,
home_logo_url: m.home_logo_url || '',
away_logo_url: m.away_logo_url || '',
notes: '',
});
setIsOpen(true);
setFocusSide(side ?? null);
// Reset external selections and uploaded files to avoid stale state
setHomeExternalTeamId('');
setAwayExternalTeamId('');
setHomeUploadedFile(null);
setAwayUploadedFile(null);
};
// Autofocus on the selected team input when drawer opens
const homeInputRef = useRef<HTMLInputElement | null>(null);
const awayInputRef = useRef<HTMLInputElement | null>(null);
const handleHomeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
setHomeQuery(e.target.value);
};
const handleAwayInput = (e: React.ChangeEvent<HTMLInputElement>) => {
setAwayQuery(e.target.value);
};
useEffect(() => {
if (isOpen && focusSide) {
const t = setTimeout(() => {
if (focusSide === 'home') homeInputRef.current?.focus();
if (focusSide === 'away') awayInputRef.current?.focus();
}, 50);
return () => clearTimeout(t);
}
}, [isOpen, focusSide]);
const drawerSize = useBreakpointValue({ base: 'full', md: 'md' });
// Horizontal scroll affordance
const scrollRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const [showScrollHint, setShowScrollHint] = useState(true);
const thBg = useColorModeValue('gray.50', 'gray.700');
// Drag-to-scroll state
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
// Color modes for past/future matches
const pastMatchBg = useColorModeValue('gray.100', 'gray.700');
const futureMatchBg = useColorModeValue('white', 'gray.800');
const pastMatchHoverBg = useColorModeValue('gray.200', 'gray.600');
const futureMatchHoverBg = useColorModeValue('gray.50', 'gray.700');
const updateScrollShadow = () => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 0);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
// Drag-to-scroll handlers
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!scrollRef.current) return;
setIsDragging(true);
setStartX(e.pageX - scrollRef.current.offsetLeft);
setScrollLeft(scrollRef.current.scrollLeft);
scrollRef.current.style.cursor = 'grabbing';
scrollRef.current.style.userSelect = 'none';
};
const handleMouseLeave = () => {
setIsDragging(false);
if (scrollRef.current) {
scrollRef.current.style.cursor = 'grab';
scrollRef.current.style.userSelect = 'auto';
}
};
const handleMouseUp = () => {
setIsDragging(false);
if (scrollRef.current) {
scrollRef.current.style.cursor = 'grab';
scrollRef.current.style.userSelect = 'auto';
}
};
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isDragging || !scrollRef.current) return;
e.preventDefault();
const x = e.pageX - scrollRef.current.offsetLeft;
const walk = (x - startX) * 2; // Scroll speed multiplier
scrollRef.current.scrollLeft = scrollLeft - walk;
};
// Utility to check if match is in the past
const isMatchPast = (dateTimeStr: string): boolean => {
if (!dateTimeStr) return false;
try {
const dt = parse(dateTimeStr, FACR_DATE_FMT, new Date());
if (!isNaN(dt.getTime())) {
return dt.getTime() < Date.now();
}
} catch (_) {
const d = new Date(dateTimeStr);
if (!isNaN(d.getTime())) {
return d.getTime() < Date.now();
}
}
return false;
};
useEffect(() => {
updateScrollShadow();
const onResize = () => updateScrollShadow();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const headerBg = useColorModeValue('brand.primary', 'gray.700');
const headerText = useColorModeValue('text.onPrimary', 'white');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
return (
<AdminLayout requireAdmin={false}>
<Box>
<Box bg={headerBg} color={headerText} borderRadius="xl" p={6} mb={6} boxShadow="lg">
<Heading size="lg" mb={2}>Správa zápasů</Heading>
<Text opacity={0.9}>
Správa a úprava zápasů. Můžete upravovat informace o zápasech, včetně názvů týmů, termínů, log a dalších detailů.
</Text>
</Box>
{isLoading ? (
<HStack spacing={3} mb={4}>
<Spinner />
<Text>Načítám zápasy</Text>
</HStack>
) : error ? (
<Alert status="error" variant="left-accent" mb={4}>
<AlertIcon />
Nepodařilo se načíst zápasy.
</Alert>
) : (
<Box>
<Wrap mb={4} spacing={3} align="center">
<WrapItem minW="160px">
<Select size="sm" value={sideFilter} onChange={(e) => setSideFilter((e.target.value as any) || '')}>
<option value="">Všechny strany</option>
<option value="home">Domácí</option>
<option value="away">Hosté</option>
</Select>
</WrapItem>
<WrapItem flex={1} minW="220px">
<Input
placeholder="Filtrovat podle týmu…"
value={teamFilter}
onChange={(e) => setTeamFilter(e.target.value)}
size="sm"
/>
</WrapItem>
<WrapItem>
<HStack>
<Input
type="date"
size="sm"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
<Text color="gray.500" fontSize="sm"></Text>
<Input
type="date"
size="sm"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</HStack>
</WrapItem>
<WrapItem>
<HStack>
<Button size="sm" variant="outline" onClick={setThisWeek} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tento týden</Button>
<Button size="sm" variant="outline" onClick={setNext30Days} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Dalších 30 dní</Button>
</HStack>
</WrapItem>
<WrapItem minW="220px">
<Select size="sm" value={competitionFilter} onChange={(e) => setCompetitionFilter(e.target.value)}>
<option value="">Všechny soutěže</option>
{competitionOptions.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</Select>
</WrapItem>
{(teamFilter || dateFrom || dateTo || competitionFilter || sideFilter) && (
<WrapItem>
<Button size="sm" variant="outline" colorScheme="red" onClick={() => { setTeamFilter(''); setDateFrom(''); setDateTo(''); setCompetitionFilter(''); setSideFilter(''); }} borderRadius="md">
Vymazat filtry
</Button>
</WrapItem>
)}
<WrapItem>
<HStack>
<Text fontSize="sm">Na stránku:</Text>
<Select size="sm" value={pageSize} onChange={(e) => setPageSize(parseInt(e.target.value) || 25)} width="auto">
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</Select>
</HStack>
</WrapItem>
<WrapItem>
<Button size="sm" onClick={exportCsv} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }} borderRadius="md">Export CSV</Button>
</WrapItem>
<WrapItem>
<Text color="gray.500" fontSize="sm">
Zobrazeno {visibleMatches.length} / {filteredMatches.length}
</Text>
</WrapItem>
</Wrap>
{showScrollHint && (
<Text fontSize="xs" color="blue.600" fontWeight="600" mb={2}>
💡 Tip: Tabulku můžete posouvat tažením myší nebo touchem
</Text>
)}
<Box
ref={scrollRef}
overflowX="auto"
borderWidth="2px"
borderRadius="xl"
borderColor={borderColor}
w="full"
bg={cardBg}
boxShadow="md"
maxW="100%"
position="relative"
cursor="grab"
onMouseDown={handleMouseDown}
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
onScroll={(e) => {
updateScrollShadow();
if ((e.currentTarget as HTMLDivElement).scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
}}
sx={{
WebkitOverflowScrolling: 'touch',
'th, td': { whiteSpace: 'nowrap' },
'::-webkit-scrollbar': { height: '12px' },
'::-webkit-scrollbar-thumb': {
background: '#3182ce',
borderRadius: '8px',
'&:hover': { background: '#2c5aa0' }
},
'::-webkit-scrollbar-track': {
background: '#e2e8f0',
borderRadius: '8px',
margin: '0 4px'
},
}}
>
{/* Gradient edges to indicate horizontal scroll */}
{canScrollLeft && (
<Box position="sticky" left={0} top={0} bottom={0} w="24px" pointerEvents="none"
bgGradient={useColorModeValue('linear(to-r, white, transparent)', 'linear(to-r, gray.800, transparent)')}
zIndex={1}
/>
)}
{canScrollRight && (
<Box position="sticky" right={0} top={0} bottom={0} w="24px" pointerEvents="none"
bgGradient={useColorModeValue('linear(to-l, white, transparent)', 'linear(to-l, gray.800, transparent)')}
zIndex={1}
/>
)}
<Table size="sm" sx={{ width: 'max-content' }}>
<Thead sx={{ position: 'sticky', top: 0, zIndex: 2, backgroundColor: headerBg, 'th': { bg: headerBg, color: headerText, fontWeight: 'bold', textTransform: 'uppercase', fontSize: 'xs', letterSpacing: '0.05em' } }}>
<Tr>
<Th minW="140px">Datum</Th>
<Th minW="200px">Soutěž</Th>
<Th minW="260px">Domácí</Th>
<Th minW="80px" textAlign="center">Skóre</Th>
<Th minW="260px">Hosté</Th>
<Th minW="220px">Místo</Th>
<Th minW="180px">Akce</Th>
</Tr>
</Thead>
<Tbody>
{filteredMatches.length === 0 ? (
<Tr>
<Td colSpan={6}>
<Text color="gray.500">Žádné zápasy k zobrazení.</Text>
</Td>
</Tr>
) : (
visibleMatches.map((m: any, idx: number) => {
const isPast = isMatchPast(m.date_time || m.date || '');
const hasScore = m.score || (m.result_home != null && m.result_away != null);
return (
<Tr
key={m.id ?? idx}
bg={isPast ? pastMatchBg : futureMatchBg}
_hover={{ bg: isPast ? pastMatchHoverBg : futureMatchHoverBg }}
opacity={isPast ? 0.85 : 1}
transition="all 0.2s"
>
<Td>
<HStack spacing={2}>
<Text>{m.date_time || m.date || ''}</Text>
{isPast && <Badge colorScheme="gray" fontSize="xs">Odehráno</Badge>}
{!isPast && <Badge colorScheme="green" fontSize="xs">Nadcházející</Badge>}
</HStack>
</Td>
<Td>
<HStack spacing={2}>
<Badge bg="brand.primary" color="text.onPrimary" borderRadius="md">{m.competitionName}</Badge>
</HStack>
</Td>
<Td>
<HStack spacing={2}>
<TeamLogo
teamId={m.home_id}
teamName={m.home || m.home_team || ''}
facrLogo={m.home_logo_url}
size="custom"
boxSize="24px"
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'home')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
</HStack>
</Td>
<Td textAlign="center">
<Text fontWeight={hasScore ? 'bold' : 'normal'} color={hasScore ? 'blue.600' : 'gray.500'}>
{m.score || (m.result_home != null && m.result_away != null ? `${m.result_home}:${m.result_away}` : ':')}
</Text>
</Td>
<Td>
<HStack spacing={2}>
<TeamLogo
teamId={m.away_id}
teamName={m.away || m.away_team || ''}
facrLogo={m.away_logo_url}
size="custom"
boxSize="24px"
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'away')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
</HStack>
</Td>
<Td>{m.venue || ''}</Td>
<Td>
<HStack spacing={2}>
<Button size="xs" onClick={() => openEdit(m)} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }} borderRadius="md">Upravit</Button>
</HStack>
</Td>
</Tr>
);
})
)}
</Tbody>
</Table>
</Box>
{filteredMatches.length > visibleMatches.length && (
<HStack justify="center" mt={6}>
<Button onClick={() => setLimit((n) => n + pageSize)} size="lg" bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }} borderRadius="lg" px={8}>
Načíst další ({filteredMatches.length - visibleMatches.length} zápasů)
</Button>
</HStack>
)}
</Box>
)}
</Box>
{/* Edit Drawer */}
<Drawer isOpen={isOpen} placement="right" onClose={() => setIsOpen(false)} size={drawerSize}>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader>Upravit zápas</DrawerHeader>
<DrawerBody>
{!selected ? (
<Text color="gray.500">Není vybrán žádný zápas.</Text>
) : (
<Stack spacing={4}>
<FormControl>
<FormLabel>Datum a čas (ISO)</FormLabel>
<Input
placeholder="YYYY-MM-DDTHH:mm:ss.sssZ"
value={form.date_time_override}
onChange={(e) => setForm((f) => ({ ...f, date_time_override: e.target.value }))}
/>
{isDateInvalid && (
<FormErrorMessage>Neplatný formát data/času</FormErrorMessage>
)}
</FormControl>
<FormControl>
<FormLabel>Místo</FormLabel>
<Input
placeholder="Místo konání"
value={form.venue_override}
onChange={(e) => setForm((f) => ({ ...f, venue_override: e.target.value }))}
/>
</FormControl>
{/* Home team */}
<FormControl>
<FormLabel>Domácí tým (název)</FormLabel>
<InputGroup>
<Input
ref={homeInputRef}
placeholder="Zadejte název týmu"
value={form.home_name_override}
onChange={(e) => {
setForm((f) => ({ ...f, home_name_override: e.target.value }));
handleHomeInput(e);
}}
/>
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={() => homeFileRef.current?.click()}>Logo</Button>
</InputRightElement>
</InputGroup>
<input
type="file"
accept="image/*"
hidden
ref={homeFileRef}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const up = await uploadImage(file);
setForm((f) => ({ ...f, home_logo_url: up.url }));
setHomeUploadedFile(file);
toast({ title: 'Logo nahráno (domácí)', status: 'success' });
} catch (err: any) {
toast({ title: 'Nahrání se nezdařilo', description: err?.message || '', status: 'error' });
} finally {
if (homeFileRef.current) homeFileRef.current.value = '' as any;
}
}}
/>
{homeResults.length > 0 && (
<Box mt={2} borderWidth="1px" borderRadius="md" p={2} maxH="180px" overflowY="auto">
<List spacing={1}>
{homeResults.map((r: any) => (
<ListItem key={r.id}>
<Button size="xs" variant="ghost" onClick={() => {
setForm((f) => ({ ...f, home_name_override: r.name, home_logo_url: r.logo_url || f.home_logo_url }));
setHomeQuery(r.name);
setHomeExternalTeamId(String(r.id || ''));
}}>
{r.name}
</Button>
</ListItem>
))}
</List>
</Box>
)}
{form.home_logo_url && (
<HStack mt={2} spacing={3}>
<Image src={form.home_logo_url} alt="home logo" boxSize="28px" objectFit="contain" />
<Button size="xs" variant="outline" onClick={() => setForm((f) => ({ ...f, home_logo_url: '' }))}>Odebrat logo</Button>
</HStack>
)}
</FormControl>
{/* Away team */}
<FormControl>
<FormLabel>Hostující tým (název)</FormLabel>
<InputGroup>
<Input
ref={awayInputRef}
placeholder="Zadejte název týmu"
value={form.away_name_override}
onChange={(e) => {
setForm((f) => ({ ...f, away_name_override: e.target.value }));
handleAwayInput(e);
}}
/>
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={() => awayFileRef.current?.click()}>Logo</Button>
</InputRightElement>
</InputGroup>
<input
type="file"
accept="image/*"
hidden
ref={awayFileRef}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const up = await uploadImage(file);
setForm((f) => ({ ...f, away_logo_url: up.url }));
setAwayUploadedFile(file);
toast({ title: 'Logo nahráno (hosté)', status: 'success' });
} catch (err: any) {
toast({ title: 'Nahrání se nezdařilo', description: err?.message || '', status: 'error' });
} finally {
if (awayFileRef.current) awayFileRef.current.value = '' as any;
}
}}
/>
{awayResults.length > 0 && (
<Box mt={2} borderWidth="1px" borderRadius="md" p={2} maxH="180px" overflowY="auto">
<List spacing={1}>
{awayResults.map((r: any) => (
<ListItem key={r.id}>
<Button size="xs" variant="ghost" onClick={() => {
setForm((f) => ({ ...f, away_name_override: r.name, away_logo_url: r.logo_url || f.away_logo_url }));
setAwayQuery(r.name);
setAwayExternalTeamId(String(r.id || ''));
}}>
{r.name}
</Button>
</ListItem>
))}
</List>
</Box>
)}
{form.away_logo_url && (
<HStack mt={2} spacing={3}>
<Image src={form.away_logo_url} alt="away logo" boxSize="28px" objectFit="contain" />
<Button size="xs" variant="outline" onClick={() => setForm((f) => ({ ...f, away_logo_url: '' }))}>Odebrat logo</Button>
</HStack>
)}
</FormControl>
<FormControl>
<FormLabel>Poznámka</FormLabel>
<Input
placeholder="Libovolná poznámka (interní)"
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
/>
</FormControl>
</Stack>
)}
</DrawerBody>
<DrawerFooter>
<HStack spacing={3}>
<Button variant="outline" onClick={() => setIsOpen(false)}>Zavřít</Button>
<Button colorScheme="blue" isLoading={saveMutation.isPending} onClick={() => saveMutation.mutate()} isDisabled={isDateInvalid}>
Uložit změny
</Button>
</HStack>
</DrawerFooter>
</DrawerContent>
</Drawer>
</AdminLayout>
);
};
export default MatchesAdminPage;