mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
509 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|