mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
1197 lines
48 KiB
TypeScript
1197 lines
48 KiB
TypeScript
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 AdminLayout from '../../layouts/AdminLayout';
|
||
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative, fetchTeamLogoOverrides } 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';
|
||
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
|
||
import { API_URL } from '../../services/api';
|
||
|
||
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: '',
|
||
});
|
||
|
||
const { data: overrides = {} } = useQuery({
|
||
queryKey: ['teamLogoOverrides'],
|
||
queryFn: fetchTeamLogoOverrides,
|
||
staleTime: 5 * 60 * 1000,
|
||
});
|
||
|
||
const normalizeName = (s: string) => {
|
||
let out = String(s || '');
|
||
out = out
|
||
.normalize('NFD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.toLowerCase();
|
||
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
|
||
const orgPhrases = [
|
||
'fotbalovy klub',
|
||
'sportovni klub',
|
||
'telovychovna jednota',
|
||
'skolni sportovni klub',
|
||
'fotbal',
|
||
'futsal',
|
||
];
|
||
for (const phrase of orgPhrases) {
|
||
const re = new RegExp(`(^|\\b)${phrase}(\\b|$)`, 'g');
|
||
out = out.replace(re, ' ');
|
||
}
|
||
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
|
||
out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
|
||
out = out.replace(/\s+/g, ' ').trim();
|
||
return out;
|
||
};
|
||
|
||
const byName: Record<string, string> = (overrides as any)?.by_name || {};
|
||
const byNameNormalized = useMemo(() => {
|
||
const idx: Record<string, string> = {};
|
||
for (const k of Object.keys(byName)) idx[normalizeName(k)] = byName[k];
|
||
return idx;
|
||
}, [byName]);
|
||
|
||
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
|
||
|
||
|
||
const getLogo = (teamName?: string, teamId?: string, facrOriginal?: string) => {
|
||
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
|
||
if (teamId && sportLogosMap[String(teamId)]) return sportLogosMap[String(teamId)];
|
||
let overrideUrl = byName[teamName];
|
||
if (!overrideUrl) overrideUrl = byNameNormalized[normalizeName(teamName)];
|
||
if (overrideUrl) {
|
||
if (overrideUrl.startsWith('/')) return assetUrl(overrideUrl) as string;
|
||
return overrideUrl;
|
||
}
|
||
if (facrOriginal) return facrOriginal;
|
||
return '/dist/img/logo-club-empty.svg';
|
||
};
|
||
|
||
// 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 origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').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,
|
||
}));
|
||
},
|
||
});
|
||
useEffect(() => {
|
||
if (!Array.isArray(matches) || matches.length === 0) return;
|
||
const ids = new Set<string>();
|
||
for (const m of matches as any[]) {
|
||
if (m.home_id) ids.add(String(m.home_id));
|
||
if (m.away_id) ids.add(String(m.away_id));
|
||
}
|
||
if (ids.size === 0) return;
|
||
(async () => {
|
||
try {
|
||
const map = await batchFetchLogosFromSportLogosAPI(Array.from(ids));
|
||
setSportLogosMap(map);
|
||
} catch (e) {
|
||
console.warn('Failed to batch fetch logos:', e);
|
||
}
|
||
})();
|
||
}, [matches]);
|
||
|
||
// 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 origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').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);
|
||
|
||
// Drag-to-scroll state
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [startX, setStartX] = useState(0);
|
||
const [scrollLeft, setScrollLeft] = useState(0);
|
||
const lastXRef = useRef(0);
|
||
const lastTimeRef = useRef(0);
|
||
const velocityRef = useRef(0);
|
||
const animationRef = useRef<number | null>(null);
|
||
const scrollRaf = useRef<number | null>(null);
|
||
|
||
const updateScrollShadow = () => {
|
||
const el = scrollRef.current;
|
||
if (!el) return;
|
||
const left = el.scrollLeft > 0;
|
||
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
|
||
if (left !== canScrollLeft) setCanScrollLeft(left);
|
||
if (right !== canScrollRight) setCanScrollRight(right);
|
||
};
|
||
|
||
// Drag-to-scroll handlers
|
||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
if (!scrollRef.current) return;
|
||
// Cancel any ongoing momentum animation
|
||
if (animationRef.current) {
|
||
cancelAnimationFrame(animationRef.current);
|
||
animationRef.current = null;
|
||
}
|
||
setIsDragging(true);
|
||
setStartX(e.pageX - scrollRef.current.offsetLeft);
|
||
setScrollLeft(scrollRef.current.scrollLeft);
|
||
lastXRef.current = e.pageX;
|
||
lastTimeRef.current = Date.now();
|
||
velocityRef.current = 0;
|
||
scrollRef.current.style.cursor = 'grabbing';
|
||
scrollRef.current.style.userSelect = 'none';
|
||
scrollRef.current.style.scrollBehavior = 'auto'; // Disable smooth scroll during drag
|
||
};
|
||
|
||
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';
|
||
scrollRef.current.style.scrollBehavior = 'smooth';
|
||
|
||
// Apply momentum scrolling
|
||
const velocity = velocityRef.current;
|
||
if (Math.abs(velocity) > 0.5) {
|
||
const applyMomentum = () => {
|
||
if (!scrollRef.current) return;
|
||
velocityRef.current *= 0.95; // Deceleration factor
|
||
scrollRef.current.scrollLeft -= velocityRef.current;
|
||
|
||
if (Math.abs(velocityRef.current) > 0.5) {
|
||
animationRef.current = requestAnimationFrame(applyMomentum);
|
||
} else {
|
||
animationRef.current = null;
|
||
}
|
||
};
|
||
animationRef.current = requestAnimationFrame(applyMomentum);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
if (!isDragging || !scrollRef.current) return;
|
||
e.preventDefault();
|
||
const x = e.pageX - scrollRef.current.offsetLeft;
|
||
const walk = (x - startX) * 1.5; // Scroll speed multiplier (reduced for smoother feel)
|
||
scrollRef.current.scrollLeft = scrollLeft - walk;
|
||
|
||
// Calculate velocity for momentum
|
||
const now = Date.now();
|
||
const timeDelta = now - lastTimeRef.current;
|
||
if (timeDelta > 0) {
|
||
const currentX = e.pageX;
|
||
const distance = currentX - lastXRef.current;
|
||
velocityRef.current = distance / timeDelta * 16; // Normalize to ~60fps
|
||
lastXRef.current = currentX;
|
||
lastTimeRef.current = now;
|
||
}
|
||
};
|
||
|
||
// Touch handlers for mobile
|
||
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
|
||
if (!scrollRef.current) return;
|
||
if (animationRef.current) {
|
||
cancelAnimationFrame(animationRef.current);
|
||
animationRef.current = null;
|
||
}
|
||
const touch = e.touches[0];
|
||
setIsDragging(true);
|
||
setStartX(touch.pageX - scrollRef.current.offsetLeft);
|
||
setScrollLeft(scrollRef.current.scrollLeft);
|
||
lastXRef.current = touch.pageX;
|
||
lastTimeRef.current = Date.now();
|
||
velocityRef.current = 0;
|
||
if (scrollRef.current) scrollRef.current.style.scrollBehavior = 'auto';
|
||
};
|
||
|
||
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
|
||
if (!isDragging || !scrollRef.current) return;
|
||
const touch = e.touches[0];
|
||
const x = touch.pageX - scrollRef.current.offsetLeft;
|
||
const walk = (x - startX) * 1.5;
|
||
scrollRef.current.scrollLeft = scrollLeft - walk;
|
||
|
||
const now = Date.now();
|
||
const timeDelta = now - lastTimeRef.current;
|
||
if (timeDelta > 0) {
|
||
const currentX = touch.pageX;
|
||
const distance = currentX - lastXRef.current;
|
||
velocityRef.current = distance / timeDelta * 16;
|
||
lastXRef.current = currentX;
|
||
lastTimeRef.current = now;
|
||
}
|
||
};
|
||
|
||
const handleTouchEnd = () => {
|
||
setIsDragging(false);
|
||
if (scrollRef.current) {
|
||
scrollRef.current.style.scrollBehavior = 'smooth';
|
||
|
||
const velocity = velocityRef.current;
|
||
if (Math.abs(velocity) > 0.5) {
|
||
const applyMomentum = () => {
|
||
if (!scrollRef.current) return;
|
||
velocityRef.current *= 0.95;
|
||
scrollRef.current.scrollLeft -= velocityRef.current;
|
||
|
||
if (Math.abs(velocityRef.current) > 0.5) {
|
||
animationRef.current = requestAnimationFrame(applyMomentum);
|
||
} else {
|
||
animationRef.current = null;
|
||
}
|
||
};
|
||
animationRef.current = requestAnimationFrame(applyMomentum);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 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);
|
||
// Cleanup momentum animation on unmount
|
||
if (animationRef.current) {
|
||
cancelAnimationFrame(animationRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
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');
|
||
const edgeGradientLeft = useColorModeValue('linear(to-r, white, transparent)', 'linear(to-r, gray.800, transparent)');
|
||
const edgeGradientRight = useColorModeValue('linear(to-l, white, transparent)', 'linear(to-l, gray.800, transparent)');
|
||
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');
|
||
|
||
return (
|
||
<AdminLayout requireAdmin={false}>
|
||
<Box>
|
||
<Box mb={6}>
|
||
<Heading size="lg" mb={2}>Správa zápasů</Heading>
|
||
<Text color={useColorModeValue('gray.600', 'gray.400')}>
|
||
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} display="flex" alignItems="center" gap={1}>
|
||
💡 Tip: Tabulku můžete plynule posouvat tažením myší nebo prstem →
|
||
</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}
|
||
onTouchStart={handleTouchStart}
|
||
onTouchMove={handleTouchMove}
|
||
onTouchEnd={handleTouchEnd}
|
||
onScroll={(e) => {
|
||
if (scrollRaf.current == null) {
|
||
scrollRaf.current = requestAnimationFrame(() => {
|
||
const el = scrollRef.current;
|
||
if (el) {
|
||
updateScrollShadow();
|
||
if (el.scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
|
||
}
|
||
scrollRaf.current = null;
|
||
});
|
||
}
|
||
}}
|
||
sx={{
|
||
WebkitOverflowScrolling: 'touch',
|
||
scrollBehavior: 'smooth',
|
||
transform: 'translateZ(0)',
|
||
willChange: 'transform',
|
||
overscrollBehaviorX: 'contain',
|
||
touchAction: 'pan-x',
|
||
'th, td': { whiteSpace: 'nowrap' },
|
||
'::-webkit-scrollbar': { height: '14px' },
|
||
'::-webkit-scrollbar-thumb': {
|
||
background: '#3182ce',
|
||
borderRadius: '10px',
|
||
border: '3px solid transparent',
|
||
backgroundClip: 'content-box',
|
||
transition: 'background 0.2s ease',
|
||
'&:hover': { background: '#2c5aa0', backgroundClip: 'content-box' },
|
||
'&:active': { background: '#2a4e8a', backgroundClip: 'content-box' }
|
||
},
|
||
'::-webkit-scrollbar-track': {
|
||
background: useColorModeValue('#f7fafc', '#2d3748'),
|
||
borderRadius: '10px',
|
||
margin: '0 8px',
|
||
border: '1px solid',
|
||
borderColor: useColorModeValue('#e2e8f0', '#4a5568')
|
||
},
|
||
}}
|
||
>
|
||
{/* Gradient edges to indicate horizontal scroll */}
|
||
{canScrollLeft && (
|
||
<Box position="sticky" left={0} top={0} bottom={0} w="24px" pointerEvents="none"
|
||
bgGradient={edgeGradientLeft}
|
||
zIndex={1}
|
||
/>
|
||
)}
|
||
{canScrollRight && (
|
||
<Box position="sticky" right={0} top={0} bottom={0} w="24px" pointerEvents="none"
|
||
bgGradient={edgeGradientRight}
|
||
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}>
|
||
<Image
|
||
src={getLogo(m.home || m.home_team || '', m.home_id, m.home_logo_url)}
|
||
alt={m.home || m.home_team || ''}
|
||
boxSize="24px"
|
||
objectFit="contain"
|
||
loading="lazy"
|
||
decoding="async"
|
||
draggable={false}
|
||
/>
|
||
<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}>
|
||
<Image
|
||
src={getLogo(m.away || m.away_team || '', m.away_id, m.away_logo_url)}
|
||
alt={m.away || m.away_team || ''}
|
||
boxSize="24px"
|
||
objectFit="contain"
|
||
loading="lazy"
|
||
decoding="async"
|
||
draggable={false}
|
||
/>
|
||
<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;
|