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