This commit is contained in:
Dmytro Tkachenko
2026-08-29 11:59:28 +03:00
commit 28d817ebe9
147 changed files with 17534 additions and 0 deletions
@@ -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); }
+82
View File
@@ -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>
);
}