Files
akademiawodociagowca/frontend/src/pages/webinars/OfferSection.tsx
T
2026-09-02 08:21:27 +02:00

258 lines
8.7 KiB
TypeScript

import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Page } from '../../api/types'
import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { useToasts } from '../../components/Toasts'
import { Badge, Button, EmptyState, ErrorState, Select, Spinner, Textarea } from '../../components/ui'
import { formatLocal } from '../../lib/dates'
import { panelPath } from '../../lib/routes'
/**
* Lista zapytan ofertowych wraz z ich obsluga.
*
* Jeden komponent obsluguje dwa miejsca: zakladke konkretnego webinaru
* (`webinarId` podany) i ekran zbiorczy ze wszystkich webinarow (`webinarId`
* puste). Dzieki temu handlowiec widzi zapytanie tak samo niezaleznie od tego,
* ktora droga do niego trafil, a poprawka w obsludze nie wymaga zmiany
* w dwoch plikach.
*
* Gdy komponent jest osadzony przy webinarze, znika filtr po webinarze
* i nazwa webinaru przy kazdej pozycji: jedno i drugie powtarzaloby to,
* co uzytkownik ma w naglowku strony.
*/
export type OfferView = {
id: number
webinarId: number
webinarTitle: string
content: string
topic: string | null
status: 'NEW' | 'HANDLED' | 'REJECTED' | 'APPROVED' | 'ANSWERED'
handlerNote: string | null
firstName: string | null
lastName: string | null
email: string | null
phone: string | null
company: string | null
consentVersion: string | null
consentAt: string | null
createdAt: string
}
const STATUS_LABEL: Record<string, string> = {
NEW: 'Nowe',
HANDLED: 'Załatwione',
}
export function OfferSection({ webinarId }: { webinarId?: number }) {
const [status, setStatus] = useState('')
const [page, setPage] = useState(0)
const [exporting, setExporting] = useState(false)
const toasts = useToasts()
const offers = useQuery({
queryKey: ['offers', status, webinarId ?? '', page],
queryFn: async () => {
const response = await client.get<Page<OfferView>>('/admin/offers', {
params: {
status: status || undefined,
webinarId: webinarId ?? undefined,
page,
size: PAGE_SIZE,
},
})
return response.data
},
})
async function handleExport() {
setExporting(true)
try {
const response = await client.get('/admin/offers/export', {
params: { status: status || undefined, webinarId: webinarId ?? undefined },
responseType: 'blob',
})
const url = URL.createObjectURL(response.data as Blob)
const link = document.createElement('a')
link.href = url
link.download = 'zapytania.csv'
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
toasts.success('Plik CSV został pobrany')
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się pobrać pliku CSV'))
} finally {
setExporting(false)
}
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="w-full sm:w-48">
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Status</label>
<Select
value={status}
onChange={(e) => {
setStatus(e.target.value)
setPage(0)
}}
>
<option value="">Wszystkie</option>
<option value="NEW">Nowe</option>
<option value="HANDLED">Załatwione</option>
</Select>
</div>
<Button icon="download" variant="secondary" onClick={handleExport} disabled={exporting}>
{exporting ? 'Pobieranie...' : 'Eksport CSV'}
</Button>
</div>
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white">
{offers.isPending && <Spinner />}
{offers.isError && (
<div className="p-5">
<ErrorState message={errorMessage(offers.error, 'Nie udało się wczytać zapytań')} />
</div>
)}
{offers.data && offers.data.content.length === 0 && (
<EmptyState
icon="send"
title="Brak zapytań"
description="Nikt jeszcze nie wysłał zapytania ofertowego."
/>
)}
{offers.data && offers.data.content.length > 0 && (
<ul className="divide-y divide-slate-100">
{offers.data.content.map((offer) => (
<OfferRow key={offer.id} offer={offer} showWebinar={webinarId === undefined} />
))}
</ul>
)}
{offers.data && (
<Pagination
page={offers.data.number}
totalPages={offers.data.totalPages}
totalElements={offers.data.totalElements}
onChange={setPage}
itemLabel="zapytań"
/>
)}
</div>
</div>
)
}
/**
* Jedno zapytanie wraz z obsluga.
*
* `showWebinar` odpowiada za nazwe webinaru: przy zakladce konkretnego
* webinaru jest zbedna, na ekranie zbiorczym niezbedna.
*/
function OfferRow({ offer, showWebinar }: { offer: OfferView; showWebinar: boolean }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const [noting, setNoting] = useState(false)
const [note, setNote] = useState(offer.handlerNote ?? '')
const handle = useMutation({
mutationFn: () => client.post(`/admin/offers/${offer.id}/handled`, { note }),
onSuccess: () => {
toasts.success('Zapytanie oznaczone jako załatwione')
queryClient.invalidateQueries({ queryKey: ['offers'] })
// licznik zapytan przy webinarze pochodzi z listy webinarow
queryClient.invalidateQueries({ queryKey: ['webinars'] })
setNoting(false)
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać')),
})
return (
<li className="space-y-2 p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium text-slate-900">
{offer.firstName} {offer.lastName}
{offer.company && <span className="text-slate-500"> - {offer.company}</span>}
</p>
<div className="flex flex-wrap gap-3 text-xs">
<a href={`mailto:${offer.email}`} className="hx-data text-blue-700 hover:underline">
{offer.email}
</a>
{offer.phone && (
<a href={`tel:${offer.phone}`} className="hx-data text-slate-600 hover:underline">
{offer.phone}
</a>
)}
</div>
</div>
<Badge tone={offer.status === 'HANDLED' ? 'ok' : 'warn'}>
{STATUS_LABEL[offer.status] ?? offer.status}
</Badge>
</div>
<p className="whitespace-pre-wrap rounded-md bg-slate-25 p-2.5 text-sm text-slate-700">
{offer.content}
</p>
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
{showWebinar && (
<Link to={panelPath(`/webinary/${offer.webinarId}`)} className="text-blue-700 hover:underline">
{offer.webinarTitle}
</Link>
)}
{offer.topic && <span>temat: {offer.topic}</span>}
<span className="hx-data">{formatLocal(offer.createdAt)}</span>
{offer.consentVersion && (
<span className="inline-flex items-center gap-1">
<Icon name="check-circle" size={12} className="text-ok-500" />
zgoda {offer.consentVersion}
{offer.consentAt && <span className="hx-data"> z {formatLocal(offer.consentAt)}</span>}
</span>
)}
</div>
{offer.handlerNote && !noting && (
<p className="rounded-md bg-ok-50 p-2 text-xs text-slate-700">
<span className="font-medium text-ok-600">Notatka: </span>
{offer.handlerNote}
</p>
)}
{noting ? (
<div className="space-y-2">
<Textarea
rows={2}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Notatka handlowca, np. wysłano ofertę 12.09"
/>
<div className="flex gap-2">
<Button size="sm" icon="check" onClick={() => handle.mutate()} disabled={handle.isPending}>
Oznacz jako załatwione
</Button>
<Button size="sm" variant="ghost" onClick={() => setNoting(false)}>
Anuluj
</Button>
</div>
</div>
) : (
offer.status !== 'HANDLED' && (
<Button size="sm" variant="secondary" icon="check" onClick={() => setNoting(true)}>
Oznacz jako załatwione
</Button>
)
)}
</li>
)
}