Initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user