Initial commit

This commit is contained in:
2026-09-02 08:21:27 +02:00
commit 84eec0039e
609 changed files with 94880 additions and 0 deletions
@@ -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>
)
}