mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
645 lines
23 KiB
TypeScript
645 lines
23 KiB
TypeScript
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í (1–10)',
|
||
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 }))
|
||
}))}>1–10</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;
|