Initial commit
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
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 { 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<CourseStatus | ''>('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [debounced, setDebounced] = useState('')
|
||||
const [sort, setSort] = useState<SortState>({ 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<Page<Course>>(`/admin/courses?${params}`)).data
|
||||
},
|
||||
})
|
||||
|
||||
const categories = useQuery({
|
||||
queryKey: ['courses', 'categories'],
|
||||
queryFn: async () => (await client.get<string[]>('/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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Kursy</h1>
|
||||
<p className="text-sm text-slate-500">Kursy on-line Akademii</p>
|
||||
</div>
|
||||
<Button icon="plus" onClick={() => navigate(panelPath('/kursy/nowy'))}>
|
||||
Nowy kurs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Input
|
||||
placeholder="Szukaj po tytule..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
aria-label="Szukaj kursu"
|
||||
/>
|
||||
<Select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as CourseStatus | '')}
|
||||
aria-label="Filtr statusu"
|
||||
>
|
||||
<option value="">Wszystkie statusy</option>
|
||||
<option value="DRAFT">Wersja robocza</option>
|
||||
<option value="PUBLISHED">Opublikowany</option>
|
||||
<option value="ARCHIVED">Zarchiwizowany</option>
|
||||
</Select>
|
||||
<Select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
aria-label="Filtr kategorii"
|
||||
>
|
||||
<option value="">Wszystkie kategorie</option>
|
||||
{(categories.data ?? []).map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{courses.isPending && <Spinner />}
|
||||
{courses.isError && (
|
||||
<ErrorState message={errorMessage(courses.error, 'Nie udało się wczytać kursów')} />
|
||||
)}
|
||||
|
||||
{courses.data && courses.data.content.length === 0 && (
|
||||
<EmptyState
|
||||
icon="file-text"
|
||||
title="Nie ma jeszcze żadnego kursu"
|
||||
description="Utwórz pierwszy kurs, dodaj moduły i lekcje, a potem opublikuj."
|
||||
/>
|
||||
)}
|
||||
|
||||
{courses.data && courses.data.content.length > 0 && (
|
||||
<Card className="overflow-hidden">
|
||||
{/* karty do lg, tabela od lg: tabela z min-w rozpychalaby telefon */}
|
||||
<ul className="divide-y divide-slate-100 lg:hidden">
|
||||
{courses.data.content.map((course) => (
|
||||
<li key={course.id} className="py-3 first:pt-0 last:pb-0">
|
||||
<CourseCard
|
||||
course={course}
|
||||
onPublish={() => publish.mutate(course.id)}
|
||||
onArchive={() => archive.mutate(course.id)}
|
||||
onCopy={() => copy.mutate(course.id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 text-left text-xs uppercase tracking-caps text-slate-500">
|
||||
<SortableHeader field="title" sort={sort} onSort={setSort}>
|
||||
Tytuł
|
||||
</SortableHeader>
|
||||
<SortableHeader field="category" sort={sort} onSort={setSort}>
|
||||
Kategoria
|
||||
</SortableHeader>
|
||||
<SortableHeader field="priceGross" sort={sort} onSort={setSort}>
|
||||
Cena
|
||||
</SortableHeader>
|
||||
<SortableHeader field="status" sort={sort} onSort={setSort}>
|
||||
Status
|
||||
</SortableHeader>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<th className="px-3 py-2 font-medium">Zapisani</th>
|
||||
<th className="px-3 py-2 font-medium">Lekcje</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{courses.data.content.map((course) => (
|
||||
<tr key={course.id} className="hover:bg-slate-25">
|
||||
<td className="px-3 py-2.5">
|
||||
<Link
|
||||
to={panelPath(`/kursy/${course.id}`)}
|
||||
className="font-medium text-slate-900 hover:text-blue-700"
|
||||
>
|
||||
{course.title}
|
||||
</Link>
|
||||
{course.subtitle && (
|
||||
<p className="mt-0.5 text-xs text-slate-500">{course.subtitle}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-slate-600">{course.category ?? '-'}</td>
|
||||
<td className="hx-data px-3 py-2.5">{formatPrice(course)}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge tone={COURSE_STATUS_TONE[course.status]}>
|
||||
{COURSE_STATUS_LABEL[course.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="hx-data px-3 py-2.5 text-slate-600">{course.enrolledCount}</td>
|
||||
<td className="hx-data px-3 py-2.5 text-slate-600">{course.lessonCount}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex justify-end gap-1">
|
||||
<RowActions
|
||||
course={course}
|
||||
onPublish={() => publish.mutate(course.id)}
|
||||
onArchive={() => archive.mutate(course.id)}
|
||||
onCopy={() => copy.mutate(course.id)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="pt-3">
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={courses.data.totalPages}
|
||||
totalElements={courses.data.totalElements}
|
||||
onChange={setPage}
|
||||
itemLabel="kursów"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CourseCard({
|
||||
course,
|
||||
onPublish,
|
||||
onArchive,
|
||||
onCopy,
|
||||
}: {
|
||||
course: Course
|
||||
onPublish: () => void
|
||||
onArchive: () => void
|
||||
onCopy: () => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={panelPath(`/kursy/${course.id}`)}
|
||||
className="text-sm font-medium text-slate-900 hover:text-blue-700"
|
||||
>
|
||||
{course.title}
|
||||
</Link>
|
||||
<p className="hx-data mt-0.5 text-xs text-slate-500">
|
||||
{formatPrice(course)} · {course.lessonCount} lekcji · {course.enrolledCount} zapisanych
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">{formatLocal(course.createdAt)}</p>
|
||||
</div>
|
||||
<Badge tone={COURSE_STATUS_TONE[course.status]}>
|
||||
{COURSE_STATUS_LABEL[course.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<RowActions
|
||||
course={course}
|
||||
onPublish={onPublish}
|
||||
onArchive={onArchive}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RowActions({
|
||||
course,
|
||||
onPublish,
|
||||
onArchive,
|
||||
onCopy,
|
||||
}: {
|
||||
course: Course
|
||||
onPublish: () => void
|
||||
onArchive: () => void
|
||||
onCopy: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<IconLink to={panelPath(`/kursy/${course.id}/edycja`)} icon="pencil" label="Edytuj kurs" />
|
||||
|
||||
{course.status !== 'PUBLISHED' && course.status !== 'ARCHIVED' && (
|
||||
<ActionButton
|
||||
icon="upload"
|
||||
label="Publikuj kurs"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Opublikować kurs „${course.title}”?`)) {
|
||||
onPublish()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{course.status !== 'ARCHIVED' && (
|
||||
<ActionButton
|
||||
icon="history"
|
||||
label="Archiwizuj kurs"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Zarchiwizować kurs „${course.title}”?\n\n` +
|
||||
'Kurs zniknie z oferty, ale osoby już zapisane zachowają dostęp.',
|
||||
)
|
||||
) {
|
||||
onArchive()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ActionButton
|
||||
icon="copy"
|
||||
label="Kopiuj kurs"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Skopiować kurs „${course.title}”?\n\n` +
|
||||
'Kopia przejmie program, opis i ustawienia certyfikatu.\n' +
|
||||
'Nie przejdą zapisy kursantów ani ich postęp.',
|
||||
)
|
||||
) {
|
||||
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<typeof Icon>[0]['name']
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} title={label} aria-label={label} className={ACTION_CLASS}>
|
||||
<Icon name={icon} size={18} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function IconLink({
|
||||
to,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
to: string
|
||||
icon: Parameters<typeof Icon>[0]['name']
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<Link to={to} title={label} aria-label={label} className={ACTION_CLASS}>
|
||||
<Icon name={icon} size={18} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user