This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
@@ -0,0 +1,292 @@
import React, { useState } from 'react';
import {
Box,
VStack,
HStack,
FormControl,
FormLabel,
Button,
Badge,
Text,
useToast,
Select,
Spinner,
Alert,
AlertIcon,
IconButton,
useColorModeValue,
Collapse,
Divider,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AddIcon, DeleteIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
import { getPolls, createPoll, updatePoll, Poll, CreatePollRequest } from '../../services/polls';
interface PollLinkerProps {
articleId?: number;
eventId?: number;
onPollsChanged?: () => void;
}
/**
* PollLinker - Component to manage poll associations with articles/events
* Can be embedded in article and activity admin pages
*/
const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChanged }) => {
const toast = useToast();
const queryClient = useQueryClient();
const [isExpanded, setIsExpanded] = useState(false);
const [selectedPollId, setSelectedPollId] = useState<string>('');
const bgBox = useColorModeValue('gray.50', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
// Query for existing polls
const queryParams = articleId ? { article_id: articleId } : eventId ? { event_id: eventId } : {};
const { data: linkedPolls, isLoading: isLoadingLinked } = useQuery({
queryKey: ['linked-polls', queryParams],
queryFn: () => getPolls(queryParams),
enabled: !!(articleId || eventId),
});
// Query for all available polls
const { data: allPolls, isLoading: isLoadingAll } = useQuery({
queryKey: ['all-admin-polls'],
queryFn: () => getPolls({ status: 'active' }),
});
// Mutation to link existing poll
const linkPollMutation = useMutation({
mutationFn: async (pollId: number) => {
const updateData: any = {};
if (articleId) updateData.related_article_id = articleId;
if (eventId) updateData.related_event_id = eventId;
return updatePoll(pollId, updateData);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['linked-polls'] });
queryClient.invalidateQueries({ queryKey: ['all-admin-polls'] });
toast({
title: 'Anketa propojena',
status: 'success',
duration: 3000,
});
setSelectedPollId('');
if (onPollsChanged) onPollsChanged();
},
onError: (error: any) => {
toast({
title: 'Chyba',
description: error.response?.data?.error || 'Nepodařilo se propojit anketu',
status: 'error',
duration: 5000,
});
},
});
// Mutation to unlink poll
const unlinkPollMutation = useMutation({
mutationFn: async (pollId: number) => {
const updateData: any = {};
if (articleId) updateData.related_article_id = null;
if (eventId) updateData.related_event_id = null;
return updatePoll(pollId, updateData);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['linked-polls'] });
queryClient.invalidateQueries({ queryKey: ['all-admin-polls'] });
toast({
title: 'Anketa odpojena',
status: 'success',
duration: 3000,
});
if (onPollsChanged) onPollsChanged();
},
onError: (error: any) => {
toast({
title: 'Chyba',
description: error.response?.data?.error || 'Nepodařilo se odpojit anketu',
status: 'error',
duration: 5000,
});
},
});
const handleLinkPoll = () => {
if (!selectedPollId) {
toast({
title: 'Vyberte anketu',
description: 'Prosím vyberte anketu ze seznamu',
status: 'warning',
duration: 3000,
});
return;
}
linkPollMutation.mutate(parseInt(selectedPollId));
};
const handleUnlinkPoll = (pollId: number) => {
if (window.confirm('Opravdu chcete odpojit tuto anketu?')) {
unlinkPollMutation.mutate(pollId);
}
};
// Filter out polls that are already linked
const linkedPollIds = new Set(linkedPolls?.map(p => p.id) || []);
const availablePolls = allPolls?.filter(p => !linkedPollIds.has(p.id)) || [];
if (!articleId && !eventId) {
return null;
}
return (
<Box
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
p={4}
bg={bgBox}
>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<HStack>
<Text fontWeight="bold" fontSize="sm">
Ankety ({linkedPolls?.length || 0})
</Text>
{(linkedPolls?.length || 0) > 0 && (
<Badge colorScheme="blue">{linkedPolls!.length} připojeno</Badge>
)}
</HStack>
<IconButton
aria-label={isExpanded ? 'Skrýt' : 'Zobrazit'}
icon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="sm"
variant="ghost"
onClick={() => setIsExpanded(!isExpanded)}
/>
</HStack>
<Collapse in={isExpanded}>
<VStack spacing={4} align="stretch">
{isLoadingLinked ? (
<HStack justify="center" py={4}>
<Spinner size="sm" />
<Text fontSize="sm">Načítání anket...</Text>
</HStack>
) : linkedPolls && linkedPolls.length > 0 ? (
<VStack spacing={2} align="stretch">
<Text fontSize="xs" fontWeight="bold" color="gray.500">
Připojené ankety:
</Text>
{linkedPolls.map((poll) => (
<HStack
key={poll.id}
p={2}
borderWidth="1px"
borderRadius="md"
justify="space-between"
bg="white"
_dark={{ bg: 'gray.800' }}
>
<VStack align="start" spacing={0} flex={1}>
<Text fontSize="sm" fontWeight="medium">
{poll.title}
</Text>
<HStack spacing={2}>
<Badge size="sm" colorScheme={poll.status === 'active' ? 'green' : 'gray'}>
{poll.status}
</Badge>
<Text fontSize="xs" color="gray.500">
{poll.total_votes} hlasů
</Text>
</HStack>
</VStack>
<IconButton
aria-label="Odpojit anketu"
icon={<DeleteIcon />}
size="sm"
colorScheme="red"
variant="ghost"
onClick={() => handleUnlinkPoll(poll.id)}
isLoading={unlinkPollMutation.isPending}
/>
</HStack>
))}
</VStack>
) : (
<Alert status="info" size="sm">
<AlertIcon />
<Text fontSize="sm">Žádné ankety nejsou připojeny</Text>
</Alert>
)}
<Divider />
{isLoadingAll ? (
<HStack justify="center" py={4}>
<Spinner size="sm" />
</HStack>
) : availablePolls.length > 0 ? (
<VStack spacing={3} align="stretch">
<Text fontSize="xs" fontWeight="bold" color="gray.500">
Připojit existující anketu:
</Text>
<HStack>
<Select
value={selectedPollId}
onChange={(e) => setSelectedPollId(e.target.value)}
placeholder="Vyberte anketu..."
size="sm"
flex={1}
>
{availablePolls.map((poll) => (
<option key={poll.id} value={poll.id}>
{poll.title} ({poll.status}) - {poll.total_votes} hlasů
</option>
))}
</Select>
<Button
leftIcon={<AddIcon />}
onClick={handleLinkPoll}
size="sm"
colorScheme="blue"
isLoading={linkPollMutation.isPending}
isDisabled={!selectedPollId}
>
Připojit
</Button>
</HStack>
</VStack>
) : (
<Alert status="warning" size="sm">
<AlertIcon />
<Text fontSize="sm">
Žádné dostupné ankety. Vytvořte novou v{' '}
<Button
as="a"
href="/admin/ankety"
target="_blank"
variant="link"
size="sm"
colorScheme="blue"
>
správě anket
</Button>
</Text>
</Alert>
)}
<Text fontSize="xs" color="gray.500">
💡 Tip: Ankety se zobrazí automaticky na konci {articleId ? 'článku' : 'aktivity'}
</Text>
</VStack>
</Collapse>
</VStack>
</Box>
);
};
export default PollLinker;