Init
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { max-width: none; }
|
||||
|
||||
.select {
|
||||
height: 32px;
|
||||
padding: 0 28px 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 9px center;
|
||||
|
||||
&:hover { border-color: var(--border-strong); }
|
||||
&:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-bg); }
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: $s-3;
|
||||
margin-bottom: $s-4;
|
||||
}
|
||||
.stat {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
padding: $s-4;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.statValue { font-size: 22px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.statLabel { margin-top: 2px; font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
.state, .stateError {
|
||||
padding: $s-8;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -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