Files
OpsLog/frontend/src/components/DuplicatesModal.tsx
T

163 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { Copy, Loader2, Trash2, X } from 'lucide-react';
import { FindDuplicates, DeleteQSOs } from '../../wailsjs/go/main/App';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
type QSO = {
id: number; callsign: string; qso_date: string; band?: string; mode?: string;
freq_hz?: number; rst_sent?: string; rst_rcvd?: string; name?: string;
};
type Group = { key: string; qsos: QSO[] };
const fmtDate = (iso: string) => {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
};
const fmtFreq = (hz?: number) => (hz && hz > 0 ? (hz / 1e6).toFixed(3) : '—');
// DuplicatesModal — scans the log for duplicate QSOs (same call+band+mode on the
// same day; optionally same minute), groups them, and lets the operator pick
// which copies to delete. The oldest in each group is left unchecked by default.
export function DuplicatesModal({ onClose, onDeleted }: { onClose: () => void; onDeleted: () => void }) {
const { t } = useI18n();
const [windowMin, setWindowMin] = useState(5); // duplicates land a few minutes apart
const [loading, setLoading] = useState(true);
const [groups, setGroups] = useState<Group[]>([]);
const [sel, setSel] = useState<Set<number>>(new Set());
const [busy, setBusy] = useState(false);
const load = (w: number) => {
setLoading(true);
FindDuplicates(w)
.then((g) => {
const gs = (g ?? []) as Group[];
setGroups(gs);
// Default: select every QSO except the first (oldest) of each group.
const next = new Set<number>();
gs.forEach((grp) => grp.qsos.slice(1).forEach((q) => next.add(q.id)));
setSel(next);
})
.catch(() => { setGroups([]); setSel(new Set()); })
.finally(() => setLoading(false));
};
useEffect(() => { load(windowMin); /* eslint-disable-next-line */ }, [windowMin]);
const toggle = (id: number) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const selectExtras = () => { const n = new Set<number>(); groups.forEach((g) => g.qsos.slice(1).forEach((q) => n.add(q.id))); setSel(n); };
const deselectAll = () => setSel(new Set());
const totalQsos = groups.reduce((a, g) => a + g.qsos.length, 0);
const doDelete = async () => {
if (sel.size === 0) return;
if (!confirm(t('dup.deleteConfirm', { n: sel.size }))) return;
setBusy(true);
try {
await DeleteQSOs(Array.from(sel) as any);
onDeleted();
load(windowMin); // rescan so the list reflects what's left
} catch { /* ignore */ } finally { setBusy(false); }
};
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4" onMouseDown={onClose}>
<div className="w-[820px] max-w-full max-h-[88vh] flex flex-col rounded-2xl border border-border bg-card shadow-2xl"
onMouseDown={(e) => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center gap-2 px-5 py-3 border-b border-border">
<Copy className="size-4 text-primary" />
<span className="font-bold">{t('dup.title')}</span>
<button type="button" onClick={onClose} className="ml-auto rounded-md p-1 hover:bg-muted"><X className="size-4" /></button>
</div>
{/* Controls */}
<div className="px-5 py-3 border-b border-border/60 space-y-2">
<p className="text-xs text-muted-foreground">{t('dup.hint')}</p>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t('dup.window')}
<select value={windowMin} onChange={(e) => setWindowMin(parseInt(e.target.value, 10))}
className="rounded-md border border-border bg-card px-2 py-1 text-xs">
{[2, 3, 5, 10, 15, 30].map((m) => <option key={m} value={m}>{m} {t('dup.minutes')}</option>)}
<option value={0}>{t('dup.sameDay')}</option>
</select>
</label>
<div className="ml-auto flex items-center gap-2">
<button type="button" onClick={selectExtras} className="rounded-md border border-border px-2 py-1 text-[11px] hover:bg-muted">{t('dup.selectExtras')}</button>
<button type="button" onClick={deselectAll} className="rounded-md border border-border px-2 py-1 text-[11px] hover:bg-muted">{t('dup.deselectAll')}</button>
</div>
</div>
<div className="text-[11px] text-muted-foreground">
{t('dup.summary', { groups: groups.length, qsos: totalQsos, sel: sel.size })}
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-3 space-y-3 min-h-0">
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> {t('dup.scanning')}
</div>
) : groups.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">{t('dup.none')}</div>
) : (
groups.map((g) => (
<div key={g.key} className="rounded-lg border border-border overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs font-bold">
<span className="font-mono">{g.qsos[0].callsign}</span>
<span className="text-muted-foreground font-normal">
{g.qsos[0].band} · {g.qsos[0].mode} · {g.qsos.length}×
</span>
</div>
<table className="w-full text-xs">
<thead className="text-[10px] uppercase tracking-wider text-muted-foreground">
<tr className="border-b border-border/60">
<th className="w-8"></th>
<th className="text-left px-2 py-1 font-semibold">{t('dup.colDate')}</th>
<th className="text-left px-2 py-1 font-semibold">{t('dup.colFreq')}</th>
<th className="text-left px-2 py-1 font-semibold">{t('dup.colRst')}</th>
<th className="text-left px-2 py-1 font-semibold">{t('dup.colName')}</th>
</tr>
</thead>
<tbody>
{g.qsos.map((q, i) => {
const checked = sel.has(q.id);
return (
<tr key={q.id} className={cn('border-t border-border/40', checked && 'bg-danger/10')}>
<td className="text-center py-1">
<input type="checkbox" checked={checked} onChange={() => toggle(q.id)} />
</td>
<td className="px-2 py-1 font-mono whitespace-nowrap">
{fmtDate(q.qso_date)}
{i === 0 && <span className="ml-1.5 text-[9px] uppercase tracking-wider text-success">{t('dup.keep')}</span>}
</td>
<td className="px-2 py-1 font-mono">{fmtFreq(q.freq_hz)}</td>
<td className="px-2 py-1 font-mono">{q.rst_sent || '—'}/{q.rst_rcvd || '—'}</td>
<td className="px-2 py-1 truncate max-w-[160px]">{q.name || ''}</td>
</tr>
);
})}
</tbody>
</table>
</div>
))
)}
</div>
{/* Footer */}
<div className="flex items-center gap-2 px-5 py-3 border-t border-border">
<button type="button" onClick={onClose} className="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted">{t('dup.close')}</button>
<button type="button" onClick={doDelete} disabled={busy || sel.size === 0}
className="ml-auto inline-flex items-center gap-1.5 rounded-md bg-destructive px-3 py-1.5 text-sm font-bold text-destructive-foreground hover:bg-destructive disabled:opacity-40">
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />}
{t('dup.deleteSel', { n: sel.size })}
</button>
</div>
</div>
</div>
);
}