Files
MyClub/frontend/src/pages/admin/MessagesAdminPage.tsx
T
Tomáš Dvořák 12cba639b9 upload
2025-10-16 13:32:05 +02:00

509 lines
16 KiB
TypeScript

import { useState, useMemo } from 'react';
import {
Box,
Button,
Checkbox,
Flex,
Heading,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
Select,
Stack,
Table,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
useDisclosure,
useToast,
Badge,
InputGroup,
InputLeftElement,
Input,
HStack,
useColorModeValue,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
FormControl,
FormLabel,
VStack
} from '@chakra-ui/react';
import { AddIcon, DeleteIcon, EmailIcon, Search2Icon, StarIcon, ArrowForwardIcon } from '@chakra-ui/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { cs } from 'date-fns/locale';
import AdminLayout from '../../layouts/AdminLayout';
import {
getContactMessages,
markAsRead,
deleteMessage,
deleteMultipleMessages,
forwardAllMessages,
ContactMessage
} from '../../services/admin/contactMessages';
import Pagination from '../../components/common/Pagination';
import MessageDetailModal from '../../components/admin/MessageDetailModal';
import ConfirmationDialog from '../../components/common/ConfirmationDialog';
export default function MessagesAdminPage() {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const [selectedMessages, setSelectedMessages] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'read' | 'unread'>('all');
const [sortBy, setSortBy] = useState<{ field: string; order: 'asc' | 'desc' }>({
field: 'createdAt',
order: 'desc',
});
const [pagination, setPagination] = useState({
page: 1,
limit: 10,
});
const {
isOpen: isDetailOpen,
onOpen: onDetailOpen,
onClose: onDetailClose
} = useDisclosure();
const {
isOpen: isDeleteOpen,
onOpen: onDeleteOpen,
onClose: onDeleteClose
} = useDisclosure();
const {
isOpen: isForwardAllOpen,
onOpen: onForwardAllOpen,
onClose: onForwardAllClose
} = useDisclosure();
const [forwardAllEmail, setForwardAllEmail] = useState('');
const [selectedMessage, setSelectedMessage] = useState<ContactMessage | null>(null);
const toast = useToast();
const queryClient = useQueryClient();
const { data, isLoading, isError } = useQuery({
queryKey: ['admin', 'contact-messages', { ...pagination, searchTerm, statusFilter, sortBy }],
queryFn: () =>
getContactMessages({
page: pagination.page,
limit: pagination.limit,
search: searchTerm,
isRead: statusFilter === 'all' ? undefined : statusFilter === 'read',
sortBy: sortBy.field,
sortOrder: sortBy.order,
}),
keepPreviousData: true,
});
const markAsReadMutation = useMutation({
mutationFn: markAsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'contact-messages'] });
toast({
title: 'Zpráva označena jako přečtená',
status: 'success',
duration: 3000,
isClosable: true,
});
},
});
const deleteMessageMutation = useMutation({
mutationFn: deleteMessage,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'contact-messages'] });
toast({
title: 'Zpráva smazána',
status: 'success',
duration: 3000,
isClosable: true,
});
},
});
const deleteMultipleMutation = useMutation({
mutationFn: deleteMultipleMessages,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'contact-messages'] });
setSelectedMessages([]);
toast({
title: 'Vybrané zprávy byly smazány',
status: 'success',
duration: 3000,
isClosable: true,
});
},
});
const forwardAllMutation = useMutation({
mutationFn: forwardAllMessages,
onSuccess: (data) => {
toast({
title: 'Zprávy se přeposílají',
description: data.message || 'Všechny zprávy budou přeposlány na zadaný e-mail',
status: 'success',
duration: 5000,
isClosable: true,
});
setForwardAllEmail('');
onForwardAllClose();
},
onError: () => {
toast({
title: 'Chyba',
description: 'Nepodařilo se přeposlat zprávy',
status: 'error',
duration: 3000,
isClosable: true,
});
},
});
const handleViewMessage = (message: ContactMessage) => {
setSelectedMessage(message);
if (!message.isRead) {
markAsReadMutation.mutate(message.id);
}
onDetailOpen();
};
const handleDeleteClick = (message: ContactMessage) => {
setSelectedMessage(message);
onDeleteOpen();
};
const handleDeleteConfirm = () => {
if (selectedMessage) {
deleteMessageMutation.mutate(selectedMessage.id);
}
onDeleteClose();
};
const handleBulkDelete = () => {
if (selectedMessages.length > 0) {
deleteMultipleMutation.mutate(selectedMessages);
}
};
const handleForwardAll = () => {
if (!forwardAllEmail || !forwardAllEmail.includes('@')) {
toast({
title: 'Chyba',
description: 'Zadejte platnou e-mailovou adresu',
status: 'error',
duration: 3000,
});
return;
}
forwardAllMutation.mutate(forwardAllEmail);
};
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedMessages(data?.data.map((msg) => msg.id) || []);
} else {
setSelectedMessages([]);
}
};
const handleSelectMessage = (id: string, isSelected: boolean) => {
if (isSelected) {
setSelectedMessages((prev) => [...prev, id]);
} else {
setSelectedMessages((prev) => prev.filter((msgId) => msgId !== id));
}
};
const handleSort = (field: string) => {
setSortBy((prev) => ({
field,
order: prev.field === field && prev.order === 'asc' ? 'desc' : 'asc',
}));
};
const formatDate = (dateString: string) => {
return format(new Date(dateString), 'd. M. yyyy HH:mm', { locale: cs });
};
const getSortIcon = (field: string) => {
if (sortBy.field !== field) return null;
return sortBy.order === 'asc' ? '↑' : '↓';
};
return (
<AdminLayout>
<Box p={6}>
<Flex justify="space-between" align="center" mb={6}>
<Box>
<Heading size="lg" mb={2}>
Příchozí zprávy
</Heading>
<Text color="gray.600">
Spravujte příchozí zprávy z kontaktního formuláře
</Text>
</Box>
<HStack spacing={3} flexWrap="wrap">
<Button
colorScheme="teal"
variant="outline"
leftIcon={<ArrowForwardIcon />}
onClick={onForwardAllOpen}
size={{ base: "sm", md: "md" }}
>
Přeposlat vše
</Button>
{selectedMessages.length > 0 && (
<Button
colorScheme="red"
variant="outline"
leftIcon={<DeleteIcon />}
onClick={handleBulkDelete}
isLoading={deleteMultipleMutation.isLoading}
size={{ base: "sm", md: "md" }}
>
Smazat vybrané ({selectedMessages.length})
</Button>
)}
</HStack>
</Flex>
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={4} mb={6}>
<Flex mb={4} gap={4} flexWrap="wrap">
<InputGroup maxW="md">
<InputLeftElement pointerEvents="none">
<Search2Icon color="gray.400" />
</InputLeftElement>
<Input
placeholder="Hledat v zprávách..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</InputGroup>
<Select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as any)}
maxW="200px"
>
<option value="all">Všechny zprávy</option>
<option value="unread">Nepřečtené</option>
<option value="read">Přečtené</option>
</Select>
</Flex>
<Box overflowX="auto">
<Table variant="simple">
<Thead>
<Tr>
<Th w="40px">
<Checkbox
isChecked={selectedMessages.length > 0 && selectedMessages.length === data?.data.length}
onChange={handleSelectAll}
/>
</Th>
<Th
cursor="pointer"
onClick={() => handleSort('name')}
_hover={{ textDecoration: 'underline' }}
>
Jméno {getSortIcon('name')}
</Th>
<Th
cursor="pointer"
onClick={() => handleSort('email')}
_hover={{ textDecoration: 'underline' }}
>
E-mail {getSortIcon('email')}
</Th>
<Th>Předmět</Th>
<Th>Zdroj</Th>
<Th
cursor="pointer"
onClick={() => handleSort('createdAt')}
_hover={{ textDecoration: 'underline' }}
>
Datum {getSortIcon('createdAt')}
</Th>
<Th>Stav</Th>
<Th w="120px">Akce</Th>
</Tr>
</Thead>
<Tbody>
{isLoading ? (
<Tr>
<Td colSpan={8} textAlign="center" py={8}>
Načítání...
</Td>
</Tr>
) : isError ? (
<Tr>
<Td colSpan={8} textAlign="center" py={8} color="red.500">
Chyba při načítání zpráv
</Td>
</Tr>
) : data?.data.length === 0 ? (
<Tr>
<Td colSpan={8} textAlign="center" py={8} color="gray.500">
Žádné zprávy nenalezeny
</Td>
</Tr>
) : (
data?.data.map((message) => (
<Tr
key={message.id}
bg={!message.isRead ? 'blue.50' : 'transparent'}
_hover={{ bg: !message.isRead ? 'blue.50' : 'gray.50' }}
>
<Td>
<Checkbox
isChecked={selectedMessages.includes(message.id)}
onChange={(e) => handleSelectMessage(message.id, e.target.checked)}
/>
</Td>
<Td fontWeight={!message.isRead ? 'semibold' : 'normal'}>
{message.name}
</Td>
<Td>{message.email}</Td>
<Td maxW="200px" isTruncated title={message.subject || 'Bez předmětu'}>
{message.subject || '—'}
</Td>
<Td>
{message.source === 'sponsor' ? (
<Badge colorScheme="purple">Sponzor</Badge>
) : (
<Badge colorScheme="gray">Kontakt</Badge>
)}
</Td>
<Td whiteSpace="nowrap">
{formatDate(message.createdAt)}
</Td>
<Td>
{message.isRead ? (
<Badge colorScheme="green">Přečteno</Badge>
) : (
<Badge colorScheme="blue">Nová zpráva</Badge>
)}
</Td>
<Td>
<HStack spacing={2}>
<IconButton
aria-label="Zobrazit zprávu"
icon={<EmailIcon />}
size="sm"
colorScheme="blue"
variant="ghost"
onClick={() => handleViewMessage(message)}
/>
<IconButton
aria-label="Smazat zprávu"
icon={<DeleteIcon />}
size="sm"
colorScheme="red"
variant="ghost"
onClick={() => handleDeleteClick(message)}
isLoading={
deleteMessageMutation.isLoading &&
deleteMessageMutation.variables === message.id
}
/>
</HStack>
</Td>
</Tr>
))
)}
</Tbody>
</Table>
</Box>
{data && data.total > 0 && (
<Flex justify="space-between" mt={4} alignItems="center">
<Text color="gray.600" fontSize="sm">
Zobrazeno {data.data.length} z {data.total} zpráv
</Text>
<Pagination
currentPage={pagination.page}
totalPages={data.totalPages || 1}
onPageChange={(page) => setPagination((p) => ({ ...p, page }))}
/>
</Flex>
)}
</Box>
</Box>
{selectedMessage && (
<MessageDetailModal
isOpen={isDetailOpen}
onClose={onDetailClose}
message={selectedMessage}
onDelete={() => {
onDetailClose();
handleDeleteClick(selectedMessage);
}}
onMarkAsRead={() => markAsReadMutation.mutate(selectedMessage.id)}
/>
)}
<ConfirmationDialog
isOpen={isDeleteOpen}
onClose={onDeleteClose}
onConfirm={handleDeleteConfirm}
title="Smazat zprávu"
message="Opravdu chcete smazat tuto zprávu? Tato akce je nevratná."
confirmText="Smazat"
cancelText="Zrušit"
isDanger
isLoading={deleteMessageMutation.isLoading}
/>
<Modal isOpen={isForwardAllOpen} onClose={onForwardAllClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Přeposlat všechny zprávy</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<Text>
Všechny příchozí zprávy budou přeposlány na zadanou e-mailovou adresu.
</Text>
<FormControl isRequired>
<FormLabel>E-mailová adresa</FormLabel>
<Input
type="email"
placeholder="prijemce@email.cz"
value={forwardAllEmail}
onChange={(e) => setForwardAllEmail(e.target.value)}
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onForwardAllClose}>
Zrušit
</Button>
<Button
colorScheme="teal"
onClick={handleForwardAll}
isLoading={forwardAllMutation.isLoading}
>
Přeposlat
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</AdminLayout>
);
}