227 lines
7.9 KiB
TypeScript
227 lines
7.9 KiB
TypeScript
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
|
|
}
|
|
}
|