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

314 lines
12 KiB
TypeScript

import {
Box,
Button,
Heading,
HStack,
IconButton,
Image,
Input,
Link,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
FormControl,
FormLabel,
Switch,
useDisclosure,
useToast,
VStack,
useColorModeValue,
Text,
Select,
NumberInput,
NumberInputField,
Badge,
} from '@chakra-ui/react';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FiEdit2, FiPlus, FiTrash2, FiUpload, FiExternalLink } from 'react-icons/fi';
import AdminLayout from '../../layouts/AdminLayout';
import { Sponsor, getSponsors, createSponsor, updateSponsor, deleteSponsor } from '../../services/sponsors';
import { uploadFile } from '../../services/articles';
const SponsorsAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const normalizeImageUrl = (url?: string) => {
if (!url || url === '') return '/logo192.png';
if (/^https?:\/\//i.test(url)) return url;
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(apiUrl).origin;
if (url.startsWith('/uploads/')) return `${origin}${url}`;
return `${origin}${url.startsWith('/') ? '' : '/'}${url}`;
};
const toast = useToast();
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['admin-sponsors'], queryFn: getSponsors });
const [editing, setEditing] = useState<Partial<Sponsor> | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const openCreate = () => { setEditing({ name: '', is_active: true, tier: 'standard', display_order: 0 }); onOpen(); };
const openEdit = (s: Sponsor) => { setEditing({ ...s }); onOpen(); };
const closeModal = () => { setEditing(null); onClose(); };
const createMut = useMutation({
mutationFn: (payload: any) => createSponsor(payload),
onSuccess: (created: any) => {
// If backend returned the created sponsor, merge into cache for immediate UI update
try {
qc.setQueryData(['admin-sponsors'], (old: any) => {
const list = Array.isArray(old) ? old : (old?.data || []);
const newList = [created, ...list];
// if cache shape is { data, total, page }, attempt to preserve
if (old && old.data) return { ...old, data: newList };
return newList;
});
} catch (e) {
// ignore cache update errors
}
toast({ title: 'Sponzor vytvořen', status: 'success' });
qc.invalidateQueries({ queryKey: ['admin-sponsors'] });
closeModal();
},
onError: (e: any) => toast({ title: 'Vytvoření selhalo', description: e?.response?.data?.chyba || 'Chyba', status: 'error' }),
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updateSponsor(id, payload),
onSuccess: (updated: any) => {
// Optimistically merge returned sponsor into cache to avoid transient undefined fields
try {
qc.setQueryData(['admin-sponsors'], (old: any) => {
const list = Array.isArray(old) ? old : (old?.data || []);
const newList = (list || []).map((it: any) => (it?.id === updated?.id ? { ...it, ...updated } : it));
if (old && old.data) return { ...old, data: newList };
return newList;
});
} catch {}
toast({ title: 'Sponzor aktualizován', status: 'success' });
qc.invalidateQueries({ queryKey: ['admin-sponsors'] });
closeModal();
},
onError: (e: any) => toast({ title: 'Aktualizace selhala', description: e?.response?.data?.chyba || 'Chyba', status: 'error' }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => deleteSponsor(id),
onSuccess: () => { toast({ title: 'Sponzor smazán', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-sponsors'] }); },
onError: (e: any) => toast({ title: 'Smazání selhalo', description: e?.response?.data?.chyba || 'Chyba', status: 'error' }),
});
const onSubmit = async () => {
if (!editing) return;
const payload = {
name: editing.name || '',
website_url: editing.website_url || '',
logo_url: editing.logo_url || '',
is_active: editing.is_active ?? true,
tier: editing.tier || 'standard',
display_order: editing.display_order ?? 0,
};
if ((editing as any).id != null) {
await updateMut.mutateAsync({ id: (editing as any).id, payload });
} else {
await createMut.mutateAsync(payload);
}
};
const onUpload = async (file?: File | null) => {
if (!file) return;
try {
const res = await uploadFile(file);
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const apiOrigin = new URL(apiUrl).origin;
// If backend returned an absolute URL pointing to the dev host (same origin as app), rewrite to API origin
let url = res.url || '';
try {
const parsed = new URL(url, window.location.origin);
const appOrigin = window.location.origin;
if (parsed.origin === appOrigin) {
// replace with API origin while keeping path
url = apiOrigin + parsed.pathname + parsed.search + parsed.hash;
}
} catch (e) {
// ignore
}
setEditing((prev) => ({ ...(prev || {}), logo_url: url }));
toast({ title: 'Logo nahráno', status: 'success' });
} catch (err) {
toast({ title: 'Nahrání loga selhalo', status: 'error' });
}
};
const toggleActive = async (s: Sponsor) => {
if (s.id == null) return;
try {
await updateMut.mutateAsync({ id: s.id, payload: { is_active: !s.is_active } });
toast({ title: 'Stav sponzora aktualizován', status: 'success' });
} catch (err) {
toast({ title: 'Aktualizace selhala', status: 'error' });
}
};
return (
<AdminLayout requireAdmin={false}>
<Box>
<HStack justify="space-between" mb={4}>
<Heading size="lg">Správa sponzorů</Heading>
<Button leftIcon={<FiPlus />} colorScheme="blue" onClick={openCreate}>
Nový sponzor
</Button>
</HStack>
<Text color="gray.500" mb={6}>
Správa sponzorů a partnerů klubu. Můžete přidávat, upravovat a odebírat sponzory, kteří se zobrazují na webu.
</Text>
<Box
bg={useColorModeValue('white', 'gray.800')}
borderWidth="1px"
borderRadius="lg"
overflowX="auto"
boxShadow="sm"
mb={6}
>
<Table size="sm">
<Thead>
<Tr>
<Th w="80px">Logo</Th>
<Th>Název</Th>
<Th>Úroveň</Th>
<Th>Pořadí</Th>
<Th>Web</Th>
<Th w="120px">Aktivní</Th>
<Th w="160px">Akce</Th>
</Tr>
</Thead>
<Tbody>
{isLoading && (<Tr><Td colSpan={7}>Načítám...</Td></Tr>)}
{!isLoading && (data || []).map((s) => (
<Tr key={s.id}>
<Td>
<Image src={normalizeImageUrl(s.logo_url)} alt={s.name} boxSize="48px" objectFit="contain" />
</Td>
<Td>{s.name}</Td>
<Td>
<Badge colorScheme={s.tier === 'general' ? 'green' : 'blue'}>
{s.tier === 'general' ? 'Hlavní partner' : 'Partner'}
</Badge>
</Td>
<Td>{s.display_order ?? 0}</Td>
<Td>
{s.website_url ? (
<HStack>
<Link href={s.website_url} color="blue.500" isExternal fontSize="sm">{s.website_url}</Link>
<FiExternalLink />
</HStack>
) : '-'}
</Td>
<Td>
<Switch isChecked={!!s.is_active} onChange={() => toggleActive(s)} />
</Td>
<Td>
<HStack spacing={2}>
<IconButton
aria-label="Upravit"
icon={<FiEdit2 />}
size="sm"
onClick={() => openEdit(s)}
/>
<IconButton
aria-label="Smazat"
icon={<FiTrash2 />}
size="sm"
colorScheme="red"
variant="outline"
onClick={async () => {
if (!window.confirm(`Opravdu chcete smazat sponzora "${s.name}"?`)) return;
try {
await deleteMut.mutateAsync(s.id as number);
toast({ title: 'Sponzor smazán', status: 'success' });
} catch (err) {
toast({ title: 'Smazání sponzora selhalo', status: 'error' });
}
}}
/>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
<Modal isOpen={isOpen} onClose={closeModal} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>{(editing as any)?.id ? 'Upravit sponzora' : 'Nový sponzor'}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<FormControl isRequired>
<FormLabel>Název</FormLabel>
<Input value={editing?.name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), name: e.target.value }))} />
</FormControl>
<FormControl>
<FormLabel>Web (URL)</FormLabel>
<Input value={editing?.website_url || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), website_url: e.target.value }))} />
</FormControl>
<FormControl>
<FormLabel>Logo</FormLabel>
<HStack>
<Image src={normalizeImageUrl(editing?.logo_url)} alt="logo" boxSize="56px" objectFit="contain" />
<Button as="label" type="button" leftIcon={<FiUpload />}>Upload
<Input type="file" display="none" accept="image/*" onChange={async (e) => {
const file = e.target.files?.[0];
await onUpload(file);
// reset input to allow selecting the same file again
(e.target as HTMLInputElement).value = '';
}} />
</Button>
</HStack>
</FormControl>
<FormControl>
<FormLabel>Úroveň partnera</FormLabel>
<Select value={editing?.tier || 'standard'} onChange={(e) => setEditing((p) => ({ ...(p as any), tier: e.target.value }))}>
<option value="general">Hlavní partner</option>
<option value="standard">Partner</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>Pořadí zobrazení</FormLabel>
<NumberInput value={editing?.display_order ?? 0} onChange={(val) => setEditing((p) => ({ ...(p as any), display_order: parseInt(val) || 0 }))} min={0}>
<NumberInputField />
</NumberInput>
<Text fontSize="xs" color="gray.500" mt={1}>Menší číslo = vyšší pozice</Text>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Aktivní</FormLabel>
<Switch isChecked={!!editing?.is_active} onChange={(e) => setEditing((p) => ({ ...(p as any), is_active: e.target.checked }))} />
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={closeModal}>Zrušit</Button>
<Button colorScheme="blue" onClick={onSubmit} isLoading={createMut.isLoading || updateMut.isLoading}>Uložit</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
};
export default SponsorsAdminPage;