83 lines
3.3 KiB
TypeScript
83 lines
3.3 KiB
TypeScript
|
|
import { useEffect, useMemo, useState } from 'react';
|
||
|
|
import PageHeader from '../../components/PageHeader';
|
||
|
|
import TicketTable from '../../components/TicketTable';
|
||
|
|
import TicketDetailModal from '../../components/TicketDetailModal';
|
||
|
|
import { getTickets } from '../../services/ticket.service';
|
||
|
|
import { formatCost, formatMinutes } from '../../utils/format.utils';
|
||
|
|
import type { Ticket } from '../../types/ticket.types';
|
||
|
|
import styles from './Closed.module.scss';
|
||
|
|
|
||
|
|
export default function Closed({ search, refreshKey }: { search: string; refreshKey: number }) {
|
||
|
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [error, setError] = useState(false);
|
||
|
|
const [selected, setSelected] = useState<Ticket | null>(null);
|
||
|
|
const [brandFilter, setBrandFilter] = useState('');
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
let alive = true;
|
||
|
|
setLoading(true);
|
||
|
|
setError(false);
|
||
|
|
getTickets({ status: 'closed', q: search || undefined })
|
||
|
|
.then(t => { if (alive) setTickets(t); })
|
||
|
|
.catch(() => { if (alive) setError(true); })
|
||
|
|
.finally(() => { if (alive) setLoading(false); });
|
||
|
|
return () => { alive = false; };
|
||
|
|
}, [search, refreshKey]);
|
||
|
|
|
||
|
|
const brands = useMemo(
|
||
|
|
() => [...new Set(tickets.map(t => t.brand).filter((b): b is string => !!b))].sort(),
|
||
|
|
[tickets],
|
||
|
|
);
|
||
|
|
const filtered = useMemo(
|
||
|
|
() => tickets.filter(t => !brandFilter || t.brand === brandFilter),
|
||
|
|
[tickets, brandFilter],
|
||
|
|
);
|
||
|
|
|
||
|
|
const totalCost = useMemo(
|
||
|
|
() => filtered.reduce((sum, t) => sum + (t.finalCost ?? 0), 0),
|
||
|
|
[filtered],
|
||
|
|
);
|
||
|
|
const avgTtfr = useMemo(() => {
|
||
|
|
const vals = filtered.map(t => t.ttfrMinutes).filter((v): v is number => v != null);
|
||
|
|
return vals.length ? Math.round(vals.reduce((a, b) => a + b, 0) / vals.length) : null;
|
||
|
|
}, [filtered]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={styles.page}>
|
||
|
|
<PageHeader title="Closed tickets" subtitle={`${filtered.length} fulfilled requests`}>
|
||
|
|
<select className={styles.select} value={brandFilter} onChange={e => setBrandFilter(e.target.value)}>
|
||
|
|
<option value="">All brands</option>
|
||
|
|
{brands.map(b => <option key={b} value={b}>{b}</option>)}
|
||
|
|
</select>
|
||
|
|
</PageHeader>
|
||
|
|
|
||
|
|
{!loading && !error && filtered.length > 0 && (
|
||
|
|
<div className={styles.summary}>
|
||
|
|
<Stat label="Tickets" value={String(filtered.length)} />
|
||
|
|
<Stat label="Total cost" value={formatCost(totalCost)} />
|
||
|
|
<Stat label="Avg. time to first reply" value={formatMinutes(avgTtfr)} />
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{loading && <div className={styles.state}>Loading tickets…</div>}
|
||
|
|
{error && <div className={styles.stateError}>Failed to load tickets. Is the server running?</div>}
|
||
|
|
{!loading && !error && filtered.length === 0 && <div className={styles.state}>No closed tickets found.</div>}
|
||
|
|
{!loading && !error && filtered.length > 0 && (
|
||
|
|
<TicketTable tickets={filtered} variant="closed" onSelect={setSelected} />
|
||
|
|
)}
|
||
|
|
|
||
|
|
{selected && <TicketDetailModal ticket={selected} onClose={() => setSelected(null)} />}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function Stat({ label, value }: { label: string; value: string }) {
|
||
|
|
return (
|
||
|
|
<div className={styles.stat}>
|
||
|
|
<div className={styles.statValue}>{value}</div>
|
||
|
|
<div className={styles.statLabel}>{label}</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|