mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
upload
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user