import { useEffect, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { client, errorMessage } from '../../api/client' import type { Course, CourseStatus } from '../../api/types' import type { Page } from '../../api/types' import { Badge, Button, Card, EmptyState, ErrorState, Input, Select, Spinner, } from '../../components/ui' import { Icon } from '../../components/Icon' import { PAGE_SIZE, Pagination } from '../../components/Pagination' import { SortableHeader } from '../../components/SortableHeader' import type { SortState } from '../../components/SortableHeader' import { useConfirm } from '../../components/Confirm' import { useToasts } from '../../components/Toasts' import { formatLocal } from '../../lib/dates' import { COURSE_STATUS_LABEL, COURSE_STATUS_TONE, formatPrice } from './courseLabels' import { panelPath } from '../../lib/routes' /** * Lista kursow. * * Filtrowanie i paginacja po stronie backendu, tak samo jak przy webinarach: * baza kursow rosnie, a filtrowanie pobranej calosci w przegladarce przestaje * dzialac dokladnie wtedy, gdy zaczyna byc potrzebne. */ export function CourseListPage() { const navigate = useNavigate() const queryClient = useQueryClient() const toasts = useToasts() const [page, setPage] = useState(0) const [status, setStatus] = useState('') const [category, setCategory] = useState('') const [search, setSearch] = useState('') const [debounced, setDebounced] = useState('') const [sort, setSort] = useState({ field: 'createdAt', direction: 'desc' }) // wpisywanie frazy nie moze wysylac zadania przy kazdej literze useEffect(() => { const timer = setTimeout(() => setDebounced(search.trim()), 300) return () => clearTimeout(timer) }, [search]) // zmiana filtru cofa na pierwsza strone: inaczej po zawezeniu wyniku // uzytkownik stojacy na stronie 2 widzi pusta tabele useEffect(() => { setPage(0) }, [status, category, debounced, sort]) const courses = useQuery({ queryKey: ['courses', page, status, category, debounced, sort], queryFn: async () => { const params = new URLSearchParams({ page: String(page), size: String(PAGE_SIZE), sort: sort.field, direction: sort.direction, }) if (status) params.set('status', status) if (category) params.set('category', category) if (debounced) params.set('search', debounced) return (await client.get>(`/admin/courses?${params}`)).data }, }) const categories = useQuery({ queryKey: ['courses', 'categories'], queryFn: async () => (await client.get('/admin/courses/categories')).data, }) const invalidate = () => queryClient.invalidateQueries({ queryKey: ['courses'] }) const publish = useMutation({ mutationFn: (id: number) => client.post(`/admin/courses/${id}/publish`), onSuccess: () => { toasts.success('Kurs został opublikowany') invalidate() }, onError: (error) => toasts.error(errorMessage(error, 'Nie udało się opublikować kursu')), }) const archive = useMutation({ mutationFn: (id: number) => client.post(`/admin/courses/${id}/archive`), onSuccess: () => { toasts.success('Kurs został zarchiwizowany') invalidate() }, onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zarchiwizować kursu')), }) const copy = useMutation({ mutationFn: (id: number) => client.post<{ id: number; slug: string; title: string }>(`/admin/courses/${id}/copy`, {}), onSuccess: (response) => { toasts.success('Kurs skopiowany. Kopia jest wersją roboczą.') invalidate() // od razu do edycji kopii: zwykle trzeba poprawic tytul i program navigate(panelPath(`/kursy/${response.data.id}/edycja`)) }, onError: (error) => toasts.error(errorMessage(error, 'Nie udało się skopiować kursu')), }) return (

Kursy

Kursy on-line Akademii

setSearch(e.target.value)} aria-label="Szukaj kursu" />
{courses.isPending && } {courses.isError && ( )} {courses.data && courses.data.content.length === 0 && ( )} {courses.data && courses.data.content.length > 0 && ( {/* karty do lg, tabela od lg: tabela z min-w rozpychalaby telefon */}
    {courses.data.content.map((course) => (
  • publish.mutate(course.id)} onArchive={() => archive.mutate(course.id)} onCopy={() => copy.mutate(course.id)} />
  • ))}
Tytuł Kategoria Cena Status {/* Zapisani i Lekcje celowo nie sa sortowalne: obie liczby powstaja osobnym zapytaniem do juz pobranej strony, wiec baza nie potrafi po nich sortowac, a sortowanie samej biezacej strony wprowadzaloby w blad. */} {courses.data.content.map((course) => ( ))}
Zapisani Lekcje Akcje
{course.title} {course.subtitle && (

{course.subtitle}

)}
{course.category ?? '-'} {formatPrice(course)} {COURSE_STATUS_LABEL[course.status]} {course.enrolledCount} {course.lessonCount}
publish.mutate(course.id)} onArchive={() => archive.mutate(course.id)} onCopy={() => copy.mutate(course.id)} />
)}
) } function CourseCard({ course, onPublish, onArchive, onCopy, }: { course: Course onPublish: () => void onArchive: () => void onCopy: () => void }) { return (
{course.title}

{formatPrice(course)} · {course.lessonCount} lekcji · {course.enrolledCount} zapisanych

{formatLocal(course.createdAt)}

{COURSE_STATUS_LABEL[course.status]}
) } function RowActions({ course, onPublish, onArchive, onCopy, }: { course: Course onPublish: () => void onArchive: () => void onCopy: () => void }) { const confirm = useConfirm() return ( <> {course.status !== 'PUBLISHED' && course.status !== 'ARCHIVED' && ( { const ok = await confirm({ title: 'Opublikować kurs?', message: ( <> Kurs {course.title} pojawi się w ofercie Akademii. ), confirmLabel: 'Opublikuj kurs', }) if (ok) onPublish() }} /> )} {course.status !== 'ARCHIVED' && ( { const ok = await confirm({ title: 'Zarchiwizować kurs?', message: ( <> Kurs {course.title} zniknie z oferty, ale osoby już zapisane zachowają dostęp. ), confirmLabel: 'Zarchiwizuj kurs', }) if (ok) onArchive() }} /> )} { const ok = await confirm({ title: 'Skopiować kurs?', message: ( <> Kopia kursu {course.title} przejmie program, opis i ustawienia certyfikatu. Nie przejdą zapisy kursantów ani ich postęp. ), confirmLabel: 'Skopiuj kurs', }) if (ok) onCopy() }} /> ) } const ACTION_CLASS = 'flex h-11 w-11 items-center justify-center rounded-md text-slate-500 transition duration-fast ease-standard hover:bg-slate-50 hover:text-slate-900' function ActionButton({ icon, label, onClick, }: { icon: Parameters[0]['name'] label: string onClick: () => void }) { return ( ) } function IconLink({ to, icon, label, }: { to: string icon: Parameters[0]['name'] label: string }) { return ( ) }