Files
MyClub/frontend/src/components/admin/PollLinker.tsx
T
Tomas Dvorak dfc079288f hot fix #1
2026-01-26 08:13:18 +01:00

645 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from 'react';
import {
Box,
VStack,
HStack,
FormControl,
FormLabel,
Button,
Badge,
Text,
useToast,
Select,
Spinner,
Alert,
AlertIcon,
IconButton,
useColorModeValue,
Collapse,
Divider,
Input,
Textarea,
Switch,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AddIcon, DeleteIcon, ChevronDownIcon, ChevronUpIcon, CloseIcon } from '@chakra-ui/icons';
import { FiPlus } from 'react-icons/fi';
import { getAdminPolls, getPolls, createPoll, updatePoll, Poll, CreatePollRequest } from '../../services/polls';
import { useConfirmDialog } from '../../contexts/ConfirmDialogContext';
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(true);
const [selectedPollId, setSelectedPollId] = useState<string>('');
const [showCreateForm, setShowCreateForm] = useState(false);
const { confirm } = useConfirmDialog();
// Poll creation form state
const [newPollData, setNewPollData] = useState<CreatePollRequest>({
title: '',
description: '',
type: 'single',
style: 'auto',
status: 'active',
allow_multiple: false,
max_choices: 1,
show_results: 'after_vote',
require_auth: false,
allow_guest_vote: true,
featured: false,
options: [
{ text: '', display_order: 0 },
{ text: '', display_order: 1 },
],
});
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: () => getAdminPolls({ 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,
});
},
});
// Mutation to create new poll
const createPollMutation = useMutation({
mutationFn: async (pollData: CreatePollRequest) => {
// Add relation to article or event
const createData = { ...pollData };
if (articleId) createData.related_article_id = articleId;
if (eventId) createData.related_event_id = eventId;
return createPoll(createData);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['linked-polls'] });
queryClient.invalidateQueries({ queryKey: ['all-admin-polls'] });
toast({
title: 'Anketa vytvořena a propojena',
status: 'success',
duration: 3000,
});
setShowCreateForm(false);
resetNewPollForm();
if (onPollsChanged) onPollsChanged();
},
onError: (error: any) => {
toast({
title: 'Chyba při vytváření ankety',
description: error.response?.data?.error || 'Nepodařilo se vytvořit 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 = async (pollId: number) => {
const ok = await confirm({
title: 'Odpojit anketu',
message: 'Opravdu chcete odpojit tuto anketu?',
confirmText: 'Odpojit',
cancelText: 'Zrušit',
isDanger: true,
});
if (!ok) return;
unlinkPollMutation.mutate(pollId);
};
const resetNewPollForm = () => {
setNewPollData({
title: '',
description: '',
type: 'single',
style: 'auto',
status: 'active',
allow_multiple: false,
max_choices: 1,
show_results: 'after_vote',
require_auth: false,
allow_guest_vote: true,
featured: false,
options: [
{ text: '', display_order: 0 },
{ text: '', display_order: 1 },
],
});
};
const handleCreatePoll = () => {
// Validate form
if (!newPollData.title.trim()) {
toast({
title: 'Chybí název ankety',
status: 'warning',
duration: 3000,
});
return;
}
const validOptions = newPollData.options.filter(opt => opt.text.trim());
if (validOptions.length < 2) {
toast({
title: 'Anketa musí mít alespoň 2 možnosti',
status: 'warning',
duration: 3000,
});
return;
}
// Submit with only valid options
createPollMutation.mutate({
...newPollData,
options: validOptions,
});
};
const addOption = () => {
setNewPollData(prev => ({
...prev,
options: [...prev.options, { text: '', display_order: prev.options.length }],
}));
};
const removeOption = (index: number) => {
if (newPollData.options.length <= 2) {
toast({
title: 'Anketa musí mít alespoň 2 možnosti',
status: 'warning',
duration: 2000,
});
return;
}
setNewPollData(prev => ({
...prev,
options: prev.options.filter((_, i) => i !== index).map((opt, idx) => ({ ...opt, display_order: idx })),
}));
};
const updateOption = (index: number, text: string) => {
setNewPollData(prev => ({
...prev,
options: prev.options.map((opt, i) => i === index ? { ...opt, text } : opt),
}));
};
// Filter out polls that are already linked to THIS content to avoid duplicates
// But allow polls that are linked elsewhere (user can decide to reuse)
const linkedPollIds = new Set(linkedPolls?.map((p: Poll) => p.id) || []);
const availablePolls = allPolls?.filter((p: Poll) => !linkedPollIds.has(p.id)) || [];
// For debugging: also include all polls to see what's available
const allAvailablePolls = allPolls || [];
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: 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 />
<Tabs size="sm" variant="enclosed" defaultIndex={1}>
<TabList>
<Tab>Propojit existující</Tab>
<Tab>Vytvořit novou</Tab>
</TabList>
<TabPanels>
{/* Tab 1: Link existing poll */}
<TabPanel px={0} py={3}>
{isLoadingAll ? (
<HStack justify="center" py={4}>
<Spinner size="sm" />
</HStack>
) : availablePolls.length > 0 ? (
<VStack spacing={3} align="stretch">
<HStack>
<Select
value={selectedPollId}
onChange={(e) => setSelectedPollId(e.target.value)}
placeholder="Vyberte anketu..."
size="sm"
flex={1}
>
{availablePolls.map((poll: 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>
) : allAvailablePolls.length > 0 ? (
<Alert status="warning" size="sm">
<AlertIcon />
<VStack align="start" spacing={2}>
<Text fontSize="sm">Všechny aktivní ankety jsou již propojeny s touto aktivitou.</Text>
<Text fontSize="xs" color="gray.600">
Dostupné ankety ({allAvailablePolls.length}): {allAvailablePolls.map(p => p.title).join(', ')}
</Text>
</VStack>
</Alert>
) : (
<Alert status="info" size="sm">
<AlertIcon />
<Text fontSize="sm">Žádné dostupné ankety. Vytvořte novou v druhé záložce.</Text>
</Alert>
)}
</TabPanel>
{/* Tab 2: Create new poll */}
<TabPanel px={0} py={3}>
<VStack spacing={3} align="stretch">
<HStack spacing={2} flexWrap="wrap">
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Hodnocení zápasu',
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
type: 'rating',
style: 'rating-stars',
allow_multiple: false,
max_choices: 1,
options: Array.from({ length: 5 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
}))}> 5</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Hodnocení (110)',
description: 'Ohodnoťte (1 = nejhorší, 10 = nejlepší)',
type: 'rating',
style: 'rating-scale',
allow_multiple: false,
max_choices: 1,
options: Array.from({ length: 10 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
}))}>110</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Docházka',
description: 'Dej vědět, zda dorazíš.',
type: 'single',
style: 'choices-chips',
allow_multiple: false,
max_choices: 1,
options: [
{ text: 'Ano', display_order: 0 },
{ text: 'Ne', display_order: 1 },
{ text: 'Možná', display_order: 2 },
]
}))}>Docházka</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Docházka (více možností)',
description: 'Vyberte jednu nebo dvě možnosti.',
type: 'multiple',
style: 'choices-cards',
allow_multiple: true,
max_choices: 2,
options: [
{ text: 'Ano', display_order: 0 },
{ text: 'Pozdě dorazím', display_order: 1 },
{ text: 'Ne', display_order: 2 },
]
}))}>Docházka (multi)</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Výběr možností',
description: 'Vyber až tři možnosti.',
type: 'multiple',
style: 'choices-list',
allow_multiple: true,
max_choices: 3,
options: [
{ text: 'A', display_order: 0 },
{ text: 'B', display_order: 1 },
{ text: 'C', display_order: 2 },
{ text: 'D', display_order: 3 },
]
}))}>Multi (3)</Button>
</HStack>
<FormControl isRequired>
<FormLabel fontSize="sm">Název ankety</FormLabel>
<Input
size="sm"
placeholder="např. Dorazíš na příští trénink?"
value={newPollData.title}
onChange={(e) => setNewPollData(prev => ({ ...prev, title: e.target.value }))}
/>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">Popis (volitelné)</FormLabel>
<Textarea
size="sm"
placeholder="Doplňující informace k anketě..."
rows={2}
value={newPollData.description}
onChange={(e) => setNewPollData(prev => ({ ...prev, description: e.target.value }))}
/>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">Typ ankety</FormLabel>
<Select
size="sm"
value={newPollData.type}
onChange={(e) => setNewPollData(prev => ({ ...prev, type: e.target.value as any }))}
>
<option value="single">Jedna odpověď</option>
<option value="multiple">Více odpovědí</option>
<option value="rating">Hodnocení</option>
</Select>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">Styl</FormLabel>
<Select
size="sm"
value={(newPollData as any).style || 'auto'}
onChange={(e) => setNewPollData(prev => ({ ...prev, style: e.target.value as any }))}
>
<option value="auto">Automaticky</option>
{newPollData.type === 'rating' ? (
<>
<option value="rating-stars">Hvězdičky</option>
<option value="rating-scale">Číselná stupnice</option>
</>
) : (
<>
<option value="choices-list">Seznam</option>
<option value="choices-chips">Štítky</option>
<option value="choices-cards">Karty</option>
</>
)}
</Select>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">Možnosti (min. 2)</FormLabel>
<VStack spacing={2} align="stretch">
{newPollData.options.map((option, index) => (
<HStack key={index}>
<Input
size="sm"
placeholder={`Možnost ${index + 1}`}
value={option.text}
onChange={(e) => updateOption(index, e.target.value)}
/>
{newPollData.options.length > 2 && (
<IconButton
aria-label="Odebrat možnost"
icon={<CloseIcon />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => removeOption(index)}
/>
)}
</HStack>
))}
<Button
size="sm"
variant="outline"
leftIcon={<AddIcon />}
onClick={addOption}
>
Přidat možnost
</Button>
</VStack>
</FormControl>
<HStack spacing={4}>
<FormControl display="flex" alignItems="center">
<FormLabel fontSize="sm" mb="0" mr={2}>
Povolit hlasování hostů
</FormLabel>
<Switch
size="sm"
isChecked={newPollData.allow_guest_vote}
onChange={(e) => setNewPollData(prev => ({ ...prev, allow_guest_vote: e.target.checked }))}
/>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel fontSize="sm" mb="0" mr={2}>
Aktivní
</FormLabel>
<Switch
size="sm"
isChecked={newPollData.status === 'active'}
onChange={(e) => setNewPollData(prev => ({ ...prev, status: e.target.checked ? 'active' : 'draft' }))}
/>
</FormControl>
</HStack>
<Button
colorScheme="green"
size="sm"
leftIcon={<AddIcon />}
onClick={handleCreatePoll}
isLoading={createPollMutation.isPending}
>
Vytvořit anketu
</Button>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
<Text fontSize="xs" color="gray.500" mt={2}>
💡 Tip: Ankety se zobrazí automaticky na konci {articleId ? 'článku' : 'aktivity'}
</Text>
</VStack>
</Collapse>
</VStack>
</Box>
);
};
export default PollLinker;