1599c7b0c1
Deploy PROD / deploy (push) Successful in 25s
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>
419 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|