awards
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Award as AwardIcon, RefreshCw, Loader2, CheckCircle2, Search, Pencil } from 'lucide-react';
|
||||
import { GetAwards } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AwardEditor } from '@/components/AwardEditor';
|
||||
|
||||
type BandCount = { band: string; worked: number; confirmed: number };
|
||||
type AwardRef = {
|
||||
ref: string; name?: string; worked: boolean; confirmed: boolean;
|
||||
bands: string[]; confirmed_bands: string[];
|
||||
};
|
||||
type AwardResult = {
|
||||
code: string; name: string; dimension: string;
|
||||
worked: number; confirmed: number; total: number;
|
||||
bands: BandCount[]; refs: AwardRef[];
|
||||
};
|
||||
|
||||
function pct(n: number, total: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return Math.min(100, Math.round((n / total) * 100));
|
||||
}
|
||||
|
||||
// Two-segment progress: confirmed (solid green) over worked (light amber).
|
||||
function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed: number; total: number }) {
|
||||
if (total <= 0) return null;
|
||||
return (
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
|
||||
<div className="bg-emerald-500" style={{ width: `${pct(confirmed, total)}%` }} />
|
||||
<div className="bg-amber-400/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AwardsPanel() {
|
||||
const [results, setResults] = useState<AwardResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [selected, setSelected] = useState<string>('DXCC');
|
||||
const [refSearch, setRefSearch] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
setResults((await GetAwards()) as any);
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const current = results.find((r) => r.code === selected) ?? results[0];
|
||||
|
||||
const filteredRefs = useMemo(() => {
|
||||
if (!current) return [];
|
||||
const q = refSearch.trim().toUpperCase();
|
||||
if (!q) return current.refs;
|
||||
return current.refs.filter((r) => r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q));
|
||||
}, [current, refSearch]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
{/* Award list */}
|
||||
<div className="w-72 shrink-0 border-r border-border flex flex-col min-h-0">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
|
||||
<AwardIcon className="size-4 text-primary" />
|
||||
<span className="text-sm font-semibold">Awards</span>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={load} disabled={loading} title="Recalculate">
|
||||
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={load} />
|
||||
<div className="flex-1 overflow-auto">
|
||||
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
|
||||
{results.map((r) => (
|
||||
<button
|
||||
key={r.code}
|
||||
onClick={() => { setSelected(r.code); setRefSearch(''); }}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 border-b border-border/40 hover:bg-accent/40 transition-colors',
|
||||
current?.code === r.code && 'bg-accent/60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-semibold text-sm">{r.code}</span>
|
||||
<span className="text-[11px] font-mono text-muted-foreground">
|
||||
<span className="text-emerald-600">{r.confirmed}</span>
|
||||
/<span className="text-foreground">{r.worked}</span>
|
||||
{r.total > 0 && <span className="text-muted-foreground/70"> of {r.total}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground truncate mb-1">{r.name}</div>
|
||||
<ProgressBar worked={r.worked} confirmed={r.confirmed} total={r.total} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{!current ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||
{loading ? 'Computing…' : 'No data'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-4 py-3 border-b border-border/60">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h3 className="text-lg font-bold">{current.code}</h3>
|
||||
<span className="text-sm text-muted-foreground">{current.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-4 text-sm">
|
||||
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">worked</span></span>
|
||||
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">confirmed</span></span>
|
||||
{current.total > 0 && (
|
||||
<span className="text-muted-foreground">of {current.total} · {pct(current.confirmed, current.total)}% confirmed</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 max-w-md"><ProgressBar worked={current.worked} confirmed={current.confirmed} total={current.total} /></div>
|
||||
</div>
|
||||
|
||||
{/* Band breakdown */}
|
||||
{current.bands.length > 0 && (
|
||||
<div className="px-4 py-2 border-b border-border/60">
|
||||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{current.bands.map((b) => (
|
||||
<div key={b.band} className="rounded-md border border-border bg-card px-2 py-1 text-xs">
|
||||
<span className="font-mono font-semibold">{b.band}</span>{' '}
|
||||
<span className="text-emerald-600 font-mono">{b.confirmed}</span>
|
||||
<span className="text-muted-foreground font-mono">/{b.worked}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* References table */}
|
||||
<div className="flex items-center gap-2 px-4 py-2">
|
||||
<div className="relative">
|
||||
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input className="h-7 w-56 pl-7 text-xs" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} reference{filteredRefs.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1 pr-2 font-medium w-24">Ref</th>
|
||||
<th className="py-1 pr-2 font-medium">Name</th>
|
||||
<th className="py-1 pr-2 font-medium w-20">Status</th>
|
||||
<th className="py-1 font-medium">Bands</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRefs.map((r) => (
|
||||
<tr key={r.ref} className="border-b border-border/30">
|
||||
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
|
||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[260px]">{r.name}</td>
|
||||
<td className="py-1 pr-2">
|
||||
{r.confirmed ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600"><CheckCircle2 className="size-3" /> conf.</span>
|
||||
) : (
|
||||
<span className="text-amber-600">worked</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1 font-mono text-muted-foreground">
|
||||
{r.bands.map((b) => (
|
||||
<span key={b} className={cn('mr-1', r.confirmed_bands.includes(b) && 'text-emerald-600 font-semibold')}>{b}</span>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user