mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #69
This commit is contained in:
@@ -40,13 +40,7 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Show loading state while fetching
|
||||
if (linkQ.isLoading) {
|
||||
return <Badge colorScheme="gray">Načítání...</Badge>;
|
||||
}
|
||||
|
||||
const mid = (linkQ.data as any)?.external_match_id;
|
||||
if (!mid) return <Badge colorScheme="gray">Nepropojeno</Badge>;
|
||||
|
||||
const facrQ = useQuery({
|
||||
queryKey: ['facr-cached-match', mid],
|
||||
@@ -77,6 +71,13 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Show loading state while fetching (after hooks are declared to keep order consistent)
|
||||
if (linkQ.isLoading) {
|
||||
return <Badge colorScheme="gray">Načítání...</Badge>;
|
||||
}
|
||||
|
||||
if (!mid) return <Badge colorScheme="gray">Nepropojeno</Badge>;
|
||||
|
||||
// Guard against errors
|
||||
if (facrQ.isError || linkQ.isError) {
|
||||
return <Badge colorScheme="red">Chyba načítání</Badge>;
|
||||
@@ -1164,6 +1165,12 @@ const ArticlesAdminPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const matchBgSelected = useColorModeValue('blue.50', 'blue.900');
|
||||
const matchBgDefault = useColorModeValue('white', 'gray.700');
|
||||
const matchHoverBg = useColorModeValue('blue.50', 'gray.600');
|
||||
const albumLinkHasPhotosBg = useColorModeValue('green.50', 'green.900');
|
||||
const albumCardBg = useColorModeValue('white', 'gray.700');
|
||||
|
||||
return (
|
||||
<AdminLayout requireAdmin={false}>
|
||||
<Box>
|
||||
@@ -1500,9 +1507,9 @@ const ArticlesAdminPage = () => {
|
||||
borderWidth="2px"
|
||||
borderRadius="md"
|
||||
borderColor={isSelected ? 'blue.500' : 'gray.200'}
|
||||
bg={isSelected ? useColorModeValue('blue.50', 'blue.900') : useColorModeValue('white', 'gray.700')}
|
||||
bg={isSelected ? matchBgSelected : matchBgDefault}
|
||||
cursor="pointer"
|
||||
_hover={{ borderColor: 'blue.300', bg: useColorModeValue('blue.50', 'gray.600') }}
|
||||
_hover={{ borderColor: 'blue.300', bg: matchHoverBg }}
|
||||
transition="all 0.2s"
|
||||
onClick={async () => {
|
||||
const val = matchId;
|
||||
@@ -1605,7 +1612,7 @@ const ArticlesAdminPage = () => {
|
||||
placeholder="https://eu.zonerama.com/…"
|
||||
value={zAlbumLink}
|
||||
onChange={(e) => setZAlbumLink(e.target.value)}
|
||||
bg={zAlbumPhotos.length > 0 ? useColorModeValue('green.50', 'green.900') : undefined}
|
||||
bg={zAlbumPhotos.length > 0 ? albumLinkHasPhotosBg : undefined}
|
||||
/>
|
||||
</InputGroup>
|
||||
<FormHelperText fontSize="xs">
|
||||
@@ -2111,7 +2118,7 @@ const ArticlesAdminPage = () => {
|
||||
{!galleryLoading && cachedAlbums.length > 0 && (
|
||||
<VStack align="stretch" spacing={6}>
|
||||
{cachedAlbums.map((album) => (
|
||||
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('white', 'gray.700')}>
|
||||
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
|
||||
@@ -2200,7 +2207,7 @@ const ArticlesAdminPage = () => {
|
||||
{!galleryLoading && cachedAlbums.length > 0 && (
|
||||
<VStack align="stretch" spacing={6}>
|
||||
{cachedAlbums.map((album) => (
|
||||
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('white', 'gray.700')}>
|
||||
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
|
||||
|
||||
@@ -38,14 +38,14 @@ import {
|
||||
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 { 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';
|
||||
|
||||
const MatchesAdminPage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -63,6 +63,60 @@ const MatchesAdminPage = () => {
|
||||
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>('');
|
||||
@@ -137,7 +191,24 @@ const MatchesAdminPage = () => {
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
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
|
||||
@@ -870,12 +941,11 @@ const MatchesAdminPage = () => {
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<TeamLogo
|
||||
teamId={m.home_id}
|
||||
teamName={m.home || m.home_team || ''}
|
||||
facrLogo={m.home_logo_url}
|
||||
size="custom"
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
@@ -888,12 +958,11 @@ const MatchesAdminPage = () => {
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<TeamLogo
|
||||
teamId={m.away_id}
|
||||
teamName={m.away || m.away_team || ''}
|
||||
facrLogo={m.away_logo_url}
|
||||
size="custom"
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormErrorMessage,
|
||||
Heading,
|
||||
HStack,
|
||||
IconButton,
|
||||
@@ -229,6 +230,13 @@ const PlayersAdminPage: React.FC = () => {
|
||||
const [editing, setEditing] = useState<Editing | null>(null);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const JERSEY_MIN = 0;
|
||||
const JERSEY_MAX = 99;
|
||||
const HEIGHT_MIN = 0;
|
||||
const HEIGHT_MAX = 250;
|
||||
const WEIGHT_MIN = 0;
|
||||
const WEIGHT_MAX = 200;
|
||||
|
||||
// Local state to persist partial DOB selections so the user sees what they picked
|
||||
const [dobParts, setDobParts] = useState<{ day: string; month: string; year: string }>({ day: '', month: '', year: '' });
|
||||
|
||||
@@ -276,14 +284,47 @@ const PlayersAdminPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const maybeSplitName = () => {
|
||||
setEditing((p) => {
|
||||
if (!p) return p;
|
||||
const fn = (p.first_name || '').trim();
|
||||
const ln = (p.last_name || '').trim();
|
||||
if (!ln && fn.includes(' ')) {
|
||||
const parts = fn.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return { ...(p as any), first_name: parts[0], last_name: parts[parts.length - 1] } as any;
|
||||
}
|
||||
}
|
||||
return p as any;
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!editing) return;
|
||||
const fn = (editing.first_name || '').trim();
|
||||
const ln = (editing.last_name || '').trim();
|
||||
let fn = (editing.first_name || '').trim();
|
||||
let ln = (editing.last_name || '').trim();
|
||||
if (!ln && fn.includes(' ')) {
|
||||
const parts = fn.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
fn = parts[0];
|
||||
ln = parts[parts.length - 1];
|
||||
}
|
||||
}
|
||||
if (!fn || !ln) {
|
||||
toast({ title: 'Jméno a příjmení jsou povinné', status: 'warning' });
|
||||
return;
|
||||
}
|
||||
const tooBig = (
|
||||
typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > JERSEY_MAX
|
||||
) || (
|
||||
typeof editing.height === 'number' && Number.isFinite(editing.height) && editing.height > HEIGHT_MAX
|
||||
) || (
|
||||
typeof editing.weight === 'number' && Number.isFinite(editing.weight) && editing.weight > WEIGHT_MAX
|
||||
);
|
||||
if (tooBig) {
|
||||
toast({ title: 'Neplatná čísla', description: `Maxima: číslo dresu ${JERSEY_MAX}, výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
|
||||
return;
|
||||
}
|
||||
// Build payload by including only present values to satisfy backend validation
|
||||
const payload: any = {
|
||||
first_name: fn,
|
||||
@@ -291,10 +332,16 @@ const PlayersAdminPage: React.FC = () => {
|
||||
};
|
||||
if (editing.date_of_birth) payload.date_of_birth = editing.date_of_birth;
|
||||
if (editing.position) payload.position = editing.position;
|
||||
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > 0) payload.jersey_number = editing.jersey_number;
|
||||
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > 0) {
|
||||
payload.jersey_number = editing.jersey_number;
|
||||
}
|
||||
if (editing.nationality) payload.nationality = editing.nationality;
|
||||
if (typeof editing.height === 'number' && editing.height > 0) payload.height = editing.height;
|
||||
if (typeof editing.weight === 'number' && editing.weight > 0) payload.weight = editing.weight;
|
||||
if (typeof editing.height === 'number' && Number.isFinite(editing.height) && editing.height > 0) {
|
||||
payload.height = editing.height;
|
||||
}
|
||||
if (typeof editing.weight === 'number' && Number.isFinite(editing.weight) && editing.weight > 0) {
|
||||
payload.weight = editing.weight;
|
||||
}
|
||||
if (editing.image_url) payload.image_url = editing.image_url;
|
||||
if (typeof editing.is_active === 'boolean') payload.is_active = editing.is_active;
|
||||
const email = ((editing as any).email || '').trim();
|
||||
@@ -373,7 +420,7 @@ const PlayersAdminPage: React.FC = () => {
|
||||
<SimpleGrid columns={[1, 2]} spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Jméno</FormLabel>
|
||||
<Input value={editing?.first_name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), first_name: e.target.value }))} />
|
||||
<Input value={editing?.first_name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), first_name: e.target.value }))} onBlur={maybeSplitName} />
|
||||
</FormControl>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Příjmení</FormLabel>
|
||||
@@ -414,11 +461,12 @@ const PlayersAdminPage: React.FC = () => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControl isInvalid={typeof editing?.jersey_number === 'number' && (editing?.jersey_number as number) > JERSEY_MAX}>
|
||||
<FormLabel>Číslo dresu</FormLabel>
|
||||
<NumberInput value={editing?.jersey_number ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : 0 }))}>
|
||||
<NumberInputField />
|
||||
<NumberInput min={JERSEY_MIN} max={JERSEY_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.jersey_number === 'number' ? editing?.jersey_number : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : undefined }))}>
|
||||
<NumberInputField inputMode="numeric" />
|
||||
</NumberInput>
|
||||
<FormErrorMessage>Maximální číslo dresu je {JERSEY_MAX}.</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
@@ -466,17 +514,19 @@ const PlayersAdminPage: React.FC = () => {
|
||||
</VStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControl isInvalid={typeof editing?.height === 'number' && (editing?.height as number) > HEIGHT_MAX}>
|
||||
<FormLabel>Výška (cm)</FormLabel>
|
||||
<NumberInput value={editing?.height ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), height: Number.isFinite(v) ? v : 0 }))}>
|
||||
<NumberInputField />
|
||||
<NumberInput min={HEIGHT_MIN} max={HEIGHT_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.height === 'number' ? editing?.height : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), height: Number.isFinite(v) ? v : undefined }))}>
|
||||
<NumberInputField inputMode="numeric" />
|
||||
</NumberInput>
|
||||
<FormErrorMessage>Maximální výška je {HEIGHT_MAX} cm.</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormControl isInvalid={typeof editing?.weight === 'number' && (editing?.weight as number) > WEIGHT_MAX}>
|
||||
<FormLabel>Váha (kg)</FormLabel>
|
||||
<NumberInput value={editing?.weight ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), weight: Number.isFinite(v) ? v : 0 }))}>
|
||||
<NumberInputField />
|
||||
<NumberInput min={WEIGHT_MIN} max={WEIGHT_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.weight === 'number' ? editing?.weight : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), weight: Number.isFinite(v) ? v : undefined }))}>
|
||||
<NumberInputField inputMode="numeric" />
|
||||
</NumberInput>
|
||||
<FormErrorMessage>Maximální váha je {WEIGHT_MAX} kg.</FormErrorMessage>
|
||||
</FormControl>
|
||||
{/* Optional contact info (not shown publicly) */}
|
||||
<FormControl>
|
||||
|
||||
@@ -211,6 +211,65 @@ const PollsAdminPage: React.FC = () => {
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const applyPreset = (preset: 'rating5' | 'rating10' | 'attendance') => {
|
||||
if (preset === 'rating5') {
|
||||
const options = Array.from({ length: 5 }).map((_, i) => ({
|
||||
text: String(i + 1),
|
||||
display_order: i + 1,
|
||||
}));
|
||||
setFormData({
|
||||
title: 'Hodnocení zápasu',
|
||||
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
|
||||
type: 'rating',
|
||||
status: 'active',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
show_results: 'after_vote',
|
||||
require_auth: false,
|
||||
allow_guest_vote: true,
|
||||
featured: false,
|
||||
options,
|
||||
});
|
||||
} else if (preset === 'rating10') {
|
||||
const options = Array.from({ length: 10 }).map((_, i) => ({
|
||||
text: String(i + 1),
|
||||
display_order: i + 1,
|
||||
}));
|
||||
setFormData({
|
||||
title: 'Hodnocení zápasu (1–10)',
|
||||
description: 'Ohodnoťte zápas (1 = nejhorší, 10 = nejlepší)',
|
||||
type: 'rating',
|
||||
status: 'active',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
show_results: 'after_vote',
|
||||
require_auth: false,
|
||||
allow_guest_vote: true,
|
||||
featured: false,
|
||||
options,
|
||||
});
|
||||
} else if (preset === 'attendance') {
|
||||
setFormData({
|
||||
title: 'Dorazíš na schůzku?',
|
||||
description: 'Dej nám vědět, zda dorazíš.',
|
||||
type: 'single',
|
||||
status: 'active',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
show_results: 'after_vote',
|
||||
require_auth: false,
|
||||
allow_guest_vote: true,
|
||||
featured: false,
|
||||
options: [
|
||||
{ text: 'Ano', display_order: 0 },
|
||||
{ text: 'Ne', display_order: 1 },
|
||||
{ text: 'Možná', display_order: 2 },
|
||||
],
|
||||
});
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleOpenEdit = (poll: Poll) => {
|
||||
setEditingPoll(poll);
|
||||
setFormData({
|
||||
@@ -362,9 +421,21 @@ const PollsAdminPage: React.FC = () => {
|
||||
<VStack spacing={6} align="stretch">
|
||||
<HStack justify="space-between">
|
||||
<Heading size="lg">Správa anket</Heading>
|
||||
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={handleOpenCreate}>
|
||||
Nová anketa
|
||||
</Button>
|
||||
<HStack>
|
||||
<Menu>
|
||||
<MenuButton as={Button} rightIcon={<ChevronDownIcon />} variant="outline">
|
||||
Předvolby
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuItem onClick={() => applyPreset('rating5')}>Hodnocení zápasu (5 hvězd)</MenuItem>
|
||||
<MenuItem onClick={() => applyPreset('rating10')}>Hodnocení zápasu (1–10)</MenuItem>
|
||||
<MenuItem onClick={() => applyPreset('attendance')}>Dorazíš na schůzku?</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={handleOpenCreate}>
|
||||
Nová anketa
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Alert status="info">
|
||||
@@ -818,7 +889,7 @@ const PollsAdminPage: React.FC = () => {
|
||||
Výsledky
|
||||
</Heading>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{statsData.poll.options.map((option) => {
|
||||
{(statsData.poll.options || []).map((option) => {
|
||||
const percentage =
|
||||
statsData.poll.total_votes > 0
|
||||
? (option.vote_count / statsData.poll.total_votes) * 100
|
||||
@@ -851,13 +922,13 @@ const PollsAdminPage: React.FC = () => {
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{statsData.votes_by_day.length > 0 && (
|
||||
{(statsData.votes_by_day?.length ?? 0) > 0 && (
|
||||
<Box>
|
||||
<Heading size="sm" mb={4}>
|
||||
Hlasy podle dnů
|
||||
</Heading>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{statsData.votes_by_day.map((day) => (
|
||||
{(statsData.votes_by_day || []).map((day) => (
|
||||
<HStack key={day.date} justify="space-between">
|
||||
<Text>{new Date(day.date).toLocaleDateString('cs-CZ')}</Text>
|
||||
<Badge>{day.count} hlasů</Badge>
|
||||
|
||||
@@ -51,7 +51,7 @@ import { searchClubs, uploadImage, putTeamLogoOverride, fetchTeamLogoOverrides,
|
||||
import { getFacrTablesCache } from '../../services/facr/cache';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { TeamLogo } from '../../components/common/TeamLogo';
|
||||
|
||||
|
||||
type TableRow = {
|
||||
rank?: string;
|
||||
@@ -291,38 +291,26 @@ const TeamsAdminPage = () => {
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Save override for each variant name so editing one updates all duplicates
|
||||
await Promise.all(
|
||||
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
|
||||
);
|
||||
|
||||
// Also upload to logoapi.sportcreative.eu (non-blocking, best-effort)
|
||||
// Upload to logoapi.sportcreative.eu first (best-effort). If successful, prefer that URL for overrides.
|
||||
if (logoUrl) {
|
||||
setExternalUploadStatus('uploading');
|
||||
setExternalUploadError(null);
|
||||
|
||||
try {
|
||||
let logoFileToUpload: File | Blob | null = uploadedFile;
|
||||
|
||||
// If no file was uploaded but we have a logo URL, fetch it as blob
|
||||
if (!logoFileToUpload && logoUrl) {
|
||||
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
|
||||
}
|
||||
|
||||
if (logoFileToUpload) {
|
||||
// Upload to the logo service (loga.sportcreative.eu)
|
||||
const logaResult = await uploadToLogaSportcreative(
|
||||
form.external_team_id,
|
||||
logoFileToUpload,
|
||||
{
|
||||
{
|
||||
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
|
||||
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
|
||||
}
|
||||
);
|
||||
|
||||
if (logaResult.success) {
|
||||
setExternalUploadStatus('success');
|
||||
// Use the URL from loga.sportcreative.eu
|
||||
if (logaResult.url) {
|
||||
logoUrl = logaResult.url;
|
||||
}
|
||||
@@ -339,7 +327,12 @@ const TeamsAdminPage = () => {
|
||||
setExternalUploadError(error?.message || 'Upload failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Save override for each variant name so editing one updates all duplicates
|
||||
await Promise.all(
|
||||
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -495,12 +488,10 @@ const TeamsAdminPage = () => {
|
||||
<Td py={1.5} fontSize="xs">{r.rank}</Td>
|
||||
<Td py={1.5}>
|
||||
<HStack spacing={2} align="center">
|
||||
<TeamLogo
|
||||
teamId={(r as any).team_id}
|
||||
teamName={r.team}
|
||||
facrLogo={r.team_logo_url}
|
||||
size="small"
|
||||
<Image
|
||||
src={getLogo(r.team, (r as any).team_id, r.team_logo_url)}
|
||||
alt={r.team}
|
||||
boxSize="24px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
<Text fontSize="xs" noOfLines={1}>{r.team}</Text>
|
||||
|
||||
@@ -49,7 +49,7 @@ interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'admin' | 'editor';
|
||||
role: 'admin' | 'editor' | 'fan';
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -65,7 +65,7 @@ const UsersAdminPage = () => {
|
||||
email: '',
|
||||
password: '',
|
||||
currentPassword: '',
|
||||
role: 'editor' as 'admin' | 'editor',
|
||||
role: 'editor' as 'admin' | 'editor' | 'fan',
|
||||
isActive: true,
|
||||
});
|
||||
const toast = useToast();
|
||||
@@ -254,8 +254,8 @@ const UsersAdminPage = () => {
|
||||
<Td>{user.name}</Td>
|
||||
<Td>{user.email}</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={user.role === 'admin' ? 'purple' : 'blue'}>
|
||||
{user.role === 'admin' ? 'Admin' : 'Editor'}
|
||||
<Badge colorScheme={user.role === 'admin' ? 'purple' : (user.role === 'editor' ? 'blue' : 'gray')}>
|
||||
{user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Fan'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
@@ -385,6 +385,7 @@ const UsersAdminPage = () => {
|
||||
value={formData.role}
|
||||
onChange={handleInputChange}
|
||||
>
|
||||
<option value="fan">Fan</option>
|
||||
<option value="editor" disabled={!!selectedUser && selectedUser.role === 'admin'}>Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</Select>
|
||||
|
||||
Reference in New Issue
Block a user