258 lines
8.7 KiB
TypeScript
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>
|
|
)
|
|
}
|