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
+226
View File
@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { LineChart } from '@mui/x-charts/LineChart'
import { client, errorMessage } from '../api/client'
import type { Dashboard } from '../api/types'
import { Icon } from '../components/Icon'
import { Card, ErrorState, Spinner } from '../components/ui'
import { formatWithZone } from '../lib/dates'
import { WEBINAR_STATUS_LABEL } from '../lib/labels'
import { panelPath } from '../lib/routes'
/**
* Ekran glowny panelu.
*
* Pierwsze, co widzi zalogowany uzytkownik, wiec pokazuje wylacznie rzeczy,
* na ktore mozna zareagowac: co sie zbliza i co czeka na obsluge. Nie ma tu
* liczb dla samych liczb.
*/
const HYDRO_AZURE = '#2C8ABD'
export function DashboardPage() {
const query = useQuery({
queryKey: ['dashboard'],
queryFn: async () => {
const response = await client.get<Dashboard>('/admin/dashboard')
return response.data
},
})
if (query.isPending) return <Spinner />
if (query.isError) {
return <ErrorState message={errorMessage(query.error, 'Nie udało się wczytać podsumowania')} />
}
const data = query.data
const statuses = data.webinarsByStatus
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold text-slate-900">Podsumowanie</h1>
<p className="text-sm text-slate-500">Co się zbliża i co czeka na obsługę</p>
</div>
{/*
Nieobsluzone zapytania na samej gorze i wyroznione: to jedyna pozycja
na tym ekranie, ktora oznacza zalegla prace.
*/}
{data.pendingOffers > 0 && (
<Link
to={panelPath('/zapytania')}
className="flex items-center gap-4 rounded-lg border border-warn-500/30 bg-warn-50 p-4 transition-colors duration-fast ease-standard hover:bg-warn-50/70"
>
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-warn-500/15">
<Icon name="send" size={22} className="text-warn-600" />
</span>
<span className="min-w-0 flex-1">
<span className="hx-data block text-2xl font-semibold text-warn-600">
{data.pendingOffers}
</span>
<span className="block text-sm text-slate-700">
{data.pendingOffers === 1
? 'zapytanie ofertowe czeka na obsługę'
: 'zapytań ofertowych czeka na obsługę'}
</span>
</span>
<Icon name="chevron-right" size={20} className="shrink-0 text-warn-600" />
</Link>
)}
<div className="grid gap-4 lg:grid-cols-2">
<Card title="Najbliższy webinar">
{data.upcoming ? (
<UpcomingCard upcoming={data.upcoming} />
) : (
<p className="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
Nie zaplanowano żadnego webinaru.{' '}
<Link to={panelPath('/webinary/nowy')} className="font-medium text-blue-700 hover:underline">
Dodaj pierwszy
</Link>
.
</p>
)}
</Card>
<Card title="Webinary">
<dl className="divide-y divide-slate-100">
{(['PUBLISHED', 'DRAFT', 'FINISHED', 'CANCELLED'] as const).map((status) => (
<div key={status} className="flex items-center justify-between py-2">
<dt className="text-sm text-slate-600">{WEBINAR_STATUS_LABEL[status]}</dt>
<dd className="hx-data font-medium text-slate-900">{statuses[status] ?? 0}</dd>
</div>
))}
</dl>
<Link
to={panelPath('/webinary')}
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-blue-700 hover:underline"
>
<Icon name="presentation" size={16} />
Pełna lista
</Link>
</Card>
</div>
<Card title="Zapisy z ostatnich 30 dni" description="Liczba nowych zapisów dziennie">
{data.dailyRegistrations.every((d) => d.count === 0) ? (
<p className="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
W ostatnich 30 dniach nikt się nie zapisał.
</p>
) : (
<div className="overflow-x-auto">
<LineChart
height={260}
series={[
{
data: data.dailyRegistrations.map((d) => d.count),
label: 'Zapisy',
color: HYDRO_AZURE,
area: true,
showMark: false,
},
]}
xAxis={[
{
scaleType: 'point',
data: data.dailyRegistrations.map((d) => formatDay(d.day)),
},
]}
yAxis={[{ min: 0 }]}
margin={{ left: 40, right: 16, top: 16, bottom: 40 }}
/>
</div>
)}
</Card>
</div>
)
}
/** Najblizszy webinar wraz z odliczaniem. */
function UpcomingCard({ upcoming }: { upcoming: NonNullable<Dashboard['upcoming']> }) {
const [countdown, setCountdown] = useState(() => describeCountdown(upcoming.startsAt))
useEffect(() => {
// odliczanie odswiezane co minute: sekundy przy odleglosci dni nic nie wnosza
const timer = window.setInterval(() => {
setCountdown(describeCountdown(upcoming.startsAt))
}, 60_000)
return () => window.clearInterval(timer)
}, [upcoming.startsAt])
return (
<div className="space-y-3">
<div>
<Link
to={panelPath(`/webinary/${upcoming.id}`)}
className="font-medium text-slate-900 hover:text-blue-700 hover:underline"
>
{upcoming.title}
</Link>
<p className="hx-data mt-0.5 text-xs text-slate-500">
{formatWithZone(upcoming.startsAt, upcoming.timezone)}
</p>
</div>
<div className="rounded-md bg-blue-50 p-3">
<p className="hx-eyebrow text-blue-700">Rozpocznie się za</p>
<p className="hx-data mt-0.5 text-xl font-semibold text-blue-700">{countdown}</p>
</div>
<div className="flex items-center gap-2 text-sm text-slate-600">
<Icon name="users" size={16} className="text-slate-400" />
<span className="hx-data font-medium text-slate-900">{upcoming.registered}</span>
<span>
{upcoming.capacity !== null ? `z ${upcoming.capacity} miejsc` : 'zapisanych, bez limitu'}
</span>
</div>
</div>
)
}
/**
* Ile zostalo do startu, zwyklym zdaniem.
*
* Bez sekund: przy odleglosci liczonej w dniach sekundy sa szumem, a przy
* odleglosci minut i tak wystarcza dokladnosc do minuty.
*/
function describeCountdown(startsAt: string): string {
const diffMs = new Date(startsAt).getTime() - Date.now()
if (diffMs <= 0) return 'już trwa'
const minutes = Math.floor(diffMs / 60_000)
if (minutes < 60) {
return `${minutes} ${plural(minutes, 'minutę', 'minuty', 'minut')}`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
const rest = minutes % 60
const hourPart = `${hours} ${plural(hours, 'godzinę', 'godziny', 'godzin')}`
return rest > 0 ? `${hourPart} i ${rest} min` : hourPart
}
const days = Math.floor(hours / 24)
return `${days} ${plural(days, 'dzień', 'dni', 'dni')}`
}
/** Odmiana przez liczbę, wedlug reguly polskiej. */
function plural(count: number, one: string, few: string, many: string): string {
if (count === 1) return one
const lastTwo = count % 100
const last = count % 10
if (lastTwo >= 12 && lastTwo <= 14) return many
return last >= 2 && last <= 4 ? few : many
}
/** Dzien i miesiac, bez roku: wykres obejmuje 30 dni. */
function formatDay(iso: string): string {
try {
return new Intl.DateTimeFormat('pl-PL', { day: '2-digit', month: '2-digit' })
.format(new Date(iso))
} catch {
return iso
}
}