Files
akademiawodociagowca/frontend/src/pages/courses/CourseListPage.tsx
T
debian 1599c7b0c1
Deploy PROD / deploy (push) Successful in 25s
Potwierdzenia operacji jako okno panelu, nie okno przegladarki
Panel pytal o potwierdzenie natywnym `window.confirm`. Takie okno wyglada
inaczej w kazdej przegladarce i w kazdym systemie, wyskakuje przy gornej
krawedzi zamiast na srodku i pokazuje nad trescia adres domeny ("Komunikat
ze strony ..."). W panelu, ktory poza tym jest spojnym interfejsem, czyta sie
to jak blad strony, a nie jak pytanie aplikacji.

CLAUDE.md wymagalo juz, zeby kazde okno modalne uzywalo komponentu `Modal`,
i tlumaczylo przy otwartym mikrofonie, dlaczego nie `window.confirm`. Regula
obowiazywala w jednym miejscu, a w 42 innych nie.

HOOK, A NIE OKNO W KAZDYM MIEJSCU Z OSOBNA
Potwierdzen jest ponad czterdziesci. Wlasny stan i wlasny `Modal` do kazdego
oznaczalyby kilkanascie linii na jedno pytanie i te sama pewnosc, ze predzej
czy pozniej ktores bedzie wygladalo inaczej. `useConfirm` oddaje funkcje
o ksztalcie zblizonym do `confirm`, wiec zamiana jest jednolinijkowa. Wynik
przychodzi obietnica, bo okno musi sie najpierw wyrenderowac.

TRESC MOWI, CO SIE STANIE
Przy okazji poprawione komunikaty: zamiast "Czy na pewno?" pytania nazywaja
obiekt i skutek. Plik zniknie ze skladu instancji, odbiorca straci dostep do
API, razem z banerem przepadnie jego statystyka wraz z liczbami. Etykieta
przycisku nazywa czynnosc ("Usun nagranie"), a nie mowi "OK".

NIEODWRACALNE WYROZNIONE, ODWRACALNE NIE
`destructive` daje czerwony przycisk i ostrzezenie "Tej operacji nie da sie
cofnac". Odwolanie webinaru i dezaktywacja konta go NIE dostaja: sa
odwracalne, a straszenie tam, gdzie nic nie przepada, rozmydla sygnal tam,
gdzie strata jest prawdziwa. Zgodnie z reguła, ze usuniecie webinaru ma
podpowiadac odwolanie jako lagodniejsza alternatywe.

Sprawdzone `npx tsc -b --force` (pelne typowanie bez cache) i `npm run build`.
Przy okazji poprawione dwa em dash sprzed tej zmiany, w `ApiKeyListPage`
i `ProviderPicker`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:13:16 +02:00

419 lines
14 KiB
TypeScript

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<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
}) {
const confirm = useConfirm()
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={async () => {
const ok = await confirm({
title: 'Opublikować kurs?',
message: (
<>
Kurs <strong>{course.title}</strong> pojawi się w ofercie Akademii.
</>
),
confirmLabel: 'Opublikuj kurs',
})
if (ok) onPublish()
}}
/>
)}
{course.status !== 'ARCHIVED' && (
<ActionButton
icon="history"
label="Archiwizuj kurs"
onClick={async () => {
const ok = await confirm({
title: 'Zarchiwizować kurs?',
message: (
<>
Kurs <strong>{course.title}</strong> zniknie z oferty, ale osoby już zapisane
zachowają dostęp.
</>
),
confirmLabel: 'Zarchiwizuj kurs',
})
if (ok) onArchive()
}}
/>
)}
<ActionButton
icon="copy"
label="Kopiuj kurs"
onClick={async () => {
const ok = await confirm({
title: 'Skopiować kurs?',
message: (
<>
Kopia kursu <strong>{course.title}</strong> 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<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>
)
}