ui(stats): prettier slot drill-down + click a callsign to edit the QSO

The pop-up listing the QSOs behind a Stats band/mode square gets a cleaner header
(band chip, hint), banded/hover rows and roomier spacing. Callsigns are now
clickable and open the QSO editor (App.openEdit threaded through DetailsPanel →
BandSlotGrid → SlotQSOModal as onEditQso), matching the double-click-to-edit of
the log grids.
This commit is contained in:
2026-08-06 10:30:34 +02:00
parent 62cdabe114
commit 20dc3e83a6
4 changed files with 54 additions and 33 deletions
+4 -2
View File
@@ -7,14 +7,16 @@
"PowerGenius XL: the fan mode (Standard / Contest / Broadcast) changes on the amp again. A previous fix dropped the \"setup\" prefix the amp requires (\"setup fanmode=…\"), so the amp rejected the command and the mode snapped straight back. The correct command is restored.", "PowerGenius XL: the fan mode (Standard / Contest / Broadcast) changes on the amp again. A previous fix dropped the \"setup\" prefix the amp requires (\"setup fanmode=…\"), so the amp rejected the command and the mode snapped straight back. The correct command is restored.",
"Performance: fixed a slowdown introduced in 0.23.6. To dim the \"represents nothing\" spots, the DX Cluster grid was redrawing EVERY row on each spot-status update, which pegged the CPU on a busy cluster (some PCs became sluggish). The dimming now updates with a light per-cell refresh instead — same look, no more churn.", "Performance: fixed a slowdown introduced in 0.23.6. To dim the \"represents nothing\" spots, the DX Cluster grid was redrawing EVERY row on each spot-status update, which pegged the CPU on a busy cluster (some PCs became sluggish). The dimming now updates with a light per-cell refresh instead — same look, no more churn.",
"DX Cluster: after you log a QSO, the spots update — a callsign, entity, POTA, prefix or band/mode slot you just worked stops showing its NEW badge, instead of staying \"new\" until a restart. Kept fast: it re-evaluates only while the cluster is actually on screen (nothing runs otherwise — a QSO logged while it's hidden refreshes once when you next open it), and it's debounced so a quick run doesn't re-scan on every QSO.", "DX Cluster: after you log a QSO, the spots update — a callsign, entity, POTA, prefix or band/mode slot you just worked stops showing its NEW badge, instead of staying \"new\" until a restart. Kept fast: it re-evaluates only while the cluster is actually on screen (nothing runs otherwise — a QSO logged while it's hidden refreshes once when you next open it), and it's debounced so a quick run doesn't re-scan on every QSO.",
"Station Control relays (WebSwitch / KMTronic / Dingtian): the Host field now accepts a full URL, so you can reach a network relay board through an HTTPS reverse proxy — e.g. https://relay.yourdomain.com fronted by Nginx Proxy Manager. Before, OpsLog always used plain http://<host>, so a board on the LAN behind a proxy (when your 80/443 already go elsewhere) was unreachable from outside." "Station Control relays (WebSwitch / KMTronic / Dingtian): the Host field now accepts a full URL, so you can reach a network relay board through an HTTPS reverse proxy — e.g. https://relay.yourdomain.com.",
"Stats slot drill-down: the pop-up listing the QSOs behind a band/mode square looks tidier (banded rows, cleaner header), and you can now click a callsign in it to open that QSO for editing."
], ],
"fr": [ "fr": [
"Édition de QSO : ajout du bouton QRZ ↗ à côté de l'indicatif, comme dans le formulaire de saisie — un clic ouvre le profil qrz.com de la station.", "Édition de QSO : ajout du bouton QRZ ↗ à côté de l'indicatif, comme dans le formulaire de saisie — un clic ouvre le profil qrz.com de la station.",
"PowerGenius XL : le mode ventilateur (Standard / Contest / Broadcast) change de nouveau sur l'ampli. Un correctif précédent avait retiré le préfixe « setup » exigé par l'ampli (« setup fanmode=… »), qui rejetait donc la commande et le mode revenait aussitôt en arrière. La bonne commande est rétablie.", "PowerGenius XL : le mode ventilateur (Standard / Contest / Broadcast) change de nouveau sur l'ampli. Un correctif précédent avait retiré le préfixe « setup » exigé par l'ampli (« setup fanmode=… »), qui rejetait donc la commande et le mode revenait aussitôt en arrière. La bonne commande est rétablie.",
"Performance : correction d'un ralentissement apparu en 0.23.6. Pour atténuer les spots « qui ne représentent rien », la grille du DX Cluster redessinait TOUTES les lignes à chaque mise à jour de statut, ce qui saturait le CPU sur un cluster actif (des PC devenaient lents). L'atténuation se met désormais à jour via un rafraîchissement léger par cellule — même rendu, sans le brassage.", "Performance : correction d'un ralentissement apparu en 0.23.6. Pour atténuer les spots « qui ne représentent rien », la grille du DX Cluster redessinait TOUTES les lignes à chaque mise à jour de statut, ce qui saturait le CPU sur un cluster actif (des PC devenaient lents). L'atténuation se met désormais à jour via un rafraîchissement léger par cellule — même rendu, sans le brassage.",
"DX Cluster : après avoir loggué un QSO, les spots se mettent à jour — un indicatif, une entité, un POTA, un préfixe ou un slot bande/mode que vous venez de contacter cesse d'afficher son badge NEW, au lieu de rester « new » jusqu'au redémarrage. Reste rapide : la réévaluation n'a lieu que si le cluster est affiché (sinon rien ne tourne — un QSO loggué cluster caché se rafraîchit une fois à sa réouverture), et c'est débouncé pour ne pas re-scanner à chaque QSO en série.", "DX Cluster : après avoir loggué un QSO, les spots se mettent à jour — un indicatif, une entité, un POTA, un préfixe ou un slot bande/mode que vous venez de contacter cesse d'afficher son badge NEW, au lieu de rester « new » jusqu'au redémarrage. Reste rapide : la réévaluation n'a lieu que si le cluster est affiché (sinon rien ne tourne — un QSO loggué cluster caché se rafraîchit une fois à sa réouverture), et c'est débouncé pour ne pas re-scanner à chaque QSO en série.",
"Relais du Contrôle station (WebSwitch / KMTronic / Dingtian) : le champ Hôte accepte désormais une URL complète, pour joindre une carte relais réseau via un reverse proxy HTTPS — ex. https://relais.tondomaine.com derrière Nginx Proxy Manager. Avant, OpsLog forçait http://<hôte>, donc une carte sur le LAN derrière un proxy (quand vos 80/443 vont déjà ailleurs) était injoignable de l'extérieur." "Relais du Contrôle station (WebSwitch / KMTronic / Dingtian) : le champ Hôte accepte désormais une URL complète ex. https://relais.tondomaine.com derrière Nginx Proxy Manager. Avant, OpsLog forçait http://<hôte>, donc une carte sur le LAN derrière un proxy (quand vos 80/443 vont déjà ailleurs) était injoignable de l'extérieur.",
"Détail dun slot Stats : la fenêtre listant les QSO derrière une case bande/mode est plus soignée (lignes alternées, en-tête plus propre), et vous pouvez maintenant cliquer un indicatif pour ouvrir ce QSO en édition."
] ]
}, },
{ {
+1
View File
@@ -5653,6 +5653,7 @@ export default function App() {
band={band} band={band}
mode={mode} mode={mode}
bands={bands} bands={bands}
onEditQso={openEdit}
{...(!callsign.trim() && selQso ? { {...(!callsign.trim() && selQso ? {
slotCall: selQso.call, slotBand: selQso.band, slotMode: selQso.mode, slotCall: selQso.call, slotBand: selQso.band, slotMode: selQso.mode,
slotWb: selWb, slotWbBusy: selWbBusy, slotWb: selWb, slotWbBusy: selWbBusy,
+44 -29
View File
@@ -22,6 +22,9 @@ interface Props {
// Set when the matrix is showing a QSO picked in the log grid rather than the // Set when the matrix is showing a QSO picked in the log grid rather than the
// entry form — labelled so the two can never be confused. // entry form — labelled so the two can never be confused.
forCall?: string; forCall?: string;
// Open the QSO editor for a contact — makes callsigns in the cell drill-down
// clickable. Threaded down from App.openEdit.
onEditQso?: (id: number) => void;
} }
// Compact column label for a band tag: keep the classic V/U for 2m/70cm, // Compact column label for a band tag: keep the classic V/U for 2m/70cm,
@@ -93,7 +96,7 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`; return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
} }
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall }: Props) { export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
// Cell drill-down: which band+class the operator clicked, or null. // Cell drill-down: which band+class the operator clicked, or null.
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null); const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
// Columns from the operator's configured bands (so the matrix shows only the // Columns from the operator's configured bands (so the matrix shows only the
@@ -340,6 +343,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
band={slot.band} band={slot.band}
cls={slot.cls} cls={slot.cls}
onClose={() => setSlot(null)} onClose={() => setSlot(null)}
onEdit={onEditQso}
/> />
)} )}
</section> </section>
@@ -351,8 +355,8 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
// in the entity, because that is the pair of facts the cell's colour encodes. // in the entity, because that is the pair of facts the cell's colour encodes.
// The call's own QSOs are marked so the two never blur together. // The call's own QSOs are marked so the two never blur together.
function SlotQSOModal({ call, dxcc, entity, band, cls, onClose }: { function SlotQSOModal({ call, dxcc, entity, band, cls, onClose, onEdit }: {
call: string; dxcc: number; entity: string; band: string; cls: string; onClose: () => void; call: string; dxcc: number; entity: string; band: string; cls: string; onClose: () => void; onEdit?: (id: number) => void;
}) { }) {
const [rows, setRows] = useState<any[] | null>(null); const [rows, setRows] = useState<any[] | null>(null);
const [err, setErr] = useState(''); const [err, setErr] = useState('');
@@ -376,18 +380,19 @@ function SlotQSOModal({ call, dxcc, entity, band, cls, onClose }: {
}, [onClose]); }, [onClose]);
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-[2px] p-4" onClick={onClose}>
<div className="bg-card border border-border rounded-lg shadow-xl w-[720px] max-w-[92vw] max-h-[70vh] flex flex-col" <div className="bg-card border border-border rounded-xl shadow-2xl w-[760px] max-w-full max-h-[72vh] flex flex-col overflow-hidden ring-1 ring-black/5"
onClick={(e) => e.stopPropagation()}> onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border"> <div className="flex items-center gap-2.5 px-4 py-2.5 border-b border-border bg-muted/30">
<span className="font-semibold text-sm"> <span className="inline-flex items-center justify-center h-6 min-w-6 px-1.5 rounded-md bg-primary/15 text-primary text-[11px] font-bold shrink-0 tabular-nums">{band}</span>
{band} · {cls} <div className="min-w-0">
{entity && <span className="text-muted-foreground font-normal"> {entity}</span>} <div className="font-semibold text-sm leading-tight truncate">
</span> {cls}{entity && <span className="text-muted-foreground font-normal"> · {entity}</span>}
<span className="ml-auto text-xs text-muted-foreground tabular-nums"> </div>
{rows ? `${rows.length} QSO` : ''} {onEdit && <div className="text-[10px] text-muted-foreground leading-tight">Click a callsign to edit the QSO</div>}
</span> </div>
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground"> <span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{rows ? `${rows.length} QSO${rows.length > 1 ? 's' : ''}` : ''}</span>
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground shrink-0 rounded p-0.5 hover:bg-muted transition-colors">
<X className="size-4" /> <X className="size-4" />
</button> </button>
</div> </div>
@@ -400,30 +405,40 @@ function SlotQSOModal({ call, dxcc, entity, band, cls, onClose }: {
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<p className="p-4 text-xs text-muted-foreground italic">No QSOs.</p> <p className="p-4 text-xs text-muted-foreground italic">No QSOs.</p>
) : ( ) : (
<table className="w-full text-xs"> <table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-muted/60 backdrop-blur text-muted-foreground"> <thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur text-[10px] uppercase tracking-wider text-muted-foreground">
<tr> <tr>
<th className="text-left font-medium px-2 py-1">Date UTC</th> <th className="text-left font-medium px-3 py-1.5">Date UTC</th>
<th className="text-left font-medium px-2 py-1">Callsign</th> <th className="text-left font-medium px-3 py-1.5">Callsign</th>
<th className="text-left font-medium px-2 py-1">Mode</th> <th className="text-left font-medium px-3 py-1.5">Mode</th>
<th className="text-left font-medium px-2 py-1">Freq</th> <th className="text-left font-medium px-3 py-1.5">Freq</th>
<th className="text-left font-medium px-2 py-1">Name</th> <th className="text-left font-medium px-3 py-1.5">Name</th>
<th className="text-left font-medium px-2 py-1">Cfm</th> <th className="text-center font-medium px-3 py-1.5">Cfm</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((q, i) => { {rows.map((q, i) => {
const cfm = q.lotw_rcvd === 'Y' || q.eqsl_rcvd === 'Y' || q.qsl_rcvd === 'Y'; const cfm = q.lotw_rcvd === 'Y' || q.eqsl_rcvd === 'Y' || q.qsl_rcvd === 'Y';
const mine = q.callsign === call;
return ( return (
<tr key={q.id ?? i} className="border-t border-border/40"> <tr key={q.id ?? i} className="border-t border-border/40 even:bg-muted/[0.06] hover:bg-primary/[0.06] transition-colors">
<td className="px-2 py-1 tabular-nums text-muted-foreground whitespace-nowrap"> <td className="px-3 py-1.5 tabular-nums text-muted-foreground whitespace-nowrap">
{String(q.qso_date ?? '').slice(0, 16).replace('T', ' ')} {String(q.qso_date ?? '').slice(0, 16).replace('T', ' ')}
</td> </td>
<td className={cn('px-2 py-1 font-mono', q.callsign === call && 'font-bold text-primary')}>{q.callsign}</td> <td className="px-3 py-1.5">
<td className="px-2 py-1">{q.mode}</td> {onEdit ? (
<td className="px-2 py-1 tabular-nums text-muted-foreground">{q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : ''}</td> <button type="button" onClick={() => { onEdit(q.id as number); onClose(); }} title="Edit this QSO"
<td className="px-2 py-1 text-muted-foreground truncate max-w-[160px]">{q.name}</td> className={cn('font-mono cursor-pointer text-left hover:underline underline-offset-2', mine ? 'font-bold text-primary' : 'text-foreground hover:text-primary')}>
<td className="px-2 py-1">{cfm ? <span className="text-success"></span> : ''}</td> {q.callsign}
</button>
) : (
<span className={cn('font-mono', mine && 'font-bold text-primary')}>{q.callsign}</span>
)}
</td>
<td className="px-3 py-1.5">{q.mode}</td>
<td className="px-3 py-1.5 tabular-nums text-muted-foreground">{q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : ''}</td>
<td className="px-3 py-1.5 text-muted-foreground truncate max-w-[180px]">{q.name}</td>
<td className="px-3 py-1.5 text-center">{cfm ? <span className="text-success"></span> : <span className="text-muted-foreground/30">·</span>}</td>
</tr> </tr>
); );
})} })}
+4 -1
View File
@@ -85,6 +85,8 @@ interface Props {
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is // When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
// shown as Ctrl+F1…F5 instead of F1…F5. // shown as Ctrl+F1…F5 instead of F1…F5.
keyerActive?: boolean; keyerActive?: boolean;
// Open the QSO editor — makes callsigns clickable in the band-slot drill-down.
onEditQso?: (id: number) => void;
} }
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended'; export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
@@ -147,7 +149,7 @@ function Field({ label, span = 1, className, children }: { label: string; span?:
); );
} }
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive }: Props) { export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const [internalOpen, setInternalOpen] = useState<TabName>('stats'); const [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided const open = tab ?? internalOpen; // controlled when `tab` is provided
@@ -288,6 +290,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
bands={bands} bands={bands}
hasCall={slotCall ? true : callsign.trim() !== ''} hasCall={slotCall ? true : callsign.trim() !== ''}
forCall={slotCall} forCall={slotCall}
onEditQso={onEditQso}
lat={dxLL?.lat} lon={dxLL?.lon} /> lat={dxLL?.lat} lon={dxLL?.lon} />
</div> </div>
)} )}