Init
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user