From 18dfd48c23a884b767dabbecabce6be67f4d7f41 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Mon, 18 May 2026 11:12:19 -0500 Subject: [PATCH] feat(dashboard): replace QSL chip with 2x2 status matrix per row Each log row now shows LoTW and QRZ upload/download status as four direction-arrow pips (ok / pending / bad / none) instead of a single chip + hidden xl-only icon pair. Matches the new-design/dashboard.html mockup. The LoTW upload pip keeps the existing click-to-upload action. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/dashboard/page.tsx | 65 ++++++------ src/components/QslMatrix.tsx | 199 +++++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 35 deletions(-) create mode 100644 src/components/QslMatrix.tsx diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index b903c7f..5f76ee9 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -9,8 +9,7 @@ import Navbar from '@/components/Navbar'; import DynamicContactMap from '@/components/DynamicContactMap'; import EditContactDialog from '@/components/EditContactDialog'; import DXpeditionWidget from '@/components/DXpeditionWidget'; -import LotwSyncIndicator from '@/components/LotwSyncIndicator'; -import QRZSyncIndicator from '@/components/QRZSyncIndicator'; +import QslMatrix from '@/components/QslMatrix'; import Pagination from '@/components/Pagination'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; @@ -289,14 +288,6 @@ export default function DashboardPage() { const greetName = user?.name?.split(' ')[0] ?? 'operator'; const userCallsign = user?.callsign; - const qslChip = (contact: Contact) => { - const lotwOk = contact.qsl_lotw === true || contact.lotw_qsl_rcvd === 'Y'; - const qrzOk = contact.qrz_qsl_rcvd === 'Y'; - if (lotwOk) return ✓ LoTW; - if (qrzOk) return ✓ QRZ; - return pending; - }; - return (
@@ -513,7 +504,7 @@ export default function DashboardPage() { Freq RST Operator - QSL + QSL · LoTW / QRZ @@ -574,29 +565,20 @@ export default function DashboardPage() { ) : null} -
e.stopPropagation()}> - {qslChip(contact)} - - fetchContacts()} - size="sm" - /> - - -
+ fetchContacts()} + />
)) @@ -628,7 +610,20 @@ export default function DashboardPage() { {contact.callsign} - {qslChip(contact)} + fetchContacts()} + />
diff --git a/src/components/QslMatrix.tsx b/src/components/QslMatrix.tsx new file mode 100644 index 0000000..6d14dae --- /dev/null +++ b/src/components/QslMatrix.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useState } from 'react'; +import { ArrowDown, ArrowUp, Loader2, X } from 'lucide-react'; + +type PipState = 'ok' | 'pending' | 'bad' | 'none'; +type Direction = 'up' | 'down'; + +interface QslMatrixProps { + // LoTW + lotw_qsl_sent?: string; + lotw_qsl_rcvd?: string; + qsl_lotw?: boolean; + qsl_lotw_date?: string; + lotw_match_status?: 'confirmed' | 'partial' | 'mismatch' | null; + + // QRZ + qrz_qsl_sent?: string; + qrz_qsl_sent_date?: string; + qrz_qsl_rcvd?: string; + qrz_qsl_rcvd_date?: string; + + // For click-to-upload (LoTW only — existing capability) + contact_id?: number; + station_id?: number; + onStatusChange?: () => void; +} + +const PIP_CLASSES: Record = { + ok: 'text-ok border-ok/30 bg-ok/10', + pending: 'text-warn border-warn/25 bg-warn/10', + bad: 'text-bad border-bad/30 bg-bad/10', + none: 'text-fg-3 border-dashed border-line bg-transparent', +}; + +function formatDate(date?: string) { + if (!date) return ''; + return ` on ${new Date(date).toLocaleDateString()}`; +} + +function Pip({ + state, + direction, + tooltip, + onClick, + loading, +}: { + state: PipState; + direction: Direction; + tooltip: string; + onClick?: (e: React.MouseEvent) => void; + loading?: boolean; +}) { + const Icon = loading + ? Loader2 + : state === 'bad' && direction === 'up' + ? X + : direction === 'up' + ? ArrowUp + : ArrowDown; + + const clickable = Boolean(onClick); + const isPendingUp = state === 'pending' && direction === 'up' && !loading; + + return ( + + + {isPendingUp && ( + + )} + + ); +} + +export default function QslMatrix({ + lotw_qsl_sent, + lotw_qsl_rcvd, + qsl_lotw, + qsl_lotw_date, + lotw_match_status, + qrz_qsl_sent, + qrz_qsl_sent_date, + qrz_qsl_rcvd, + qrz_qsl_rcvd_date, + contact_id, + station_id, + onStatusChange, +}: QslMatrixProps) { + const [uploadingLotw, setUploadingLotw] = useState(false); + + // ── LoTW upload ── + let lotwUp: { state: PipState; tooltip: string }; + if (lotw_qsl_sent === 'Y') { + lotwUp = { state: 'ok', tooltip: 'Uploaded to LoTW' }; + } else if (lotw_qsl_sent === 'R') { + lotwUp = { state: 'pending', tooltip: 'LoTW upload queued' }; + } else { + lotwUp = { state: 'none', tooltip: 'Not uploaded to LoTW' }; + } + + // ── LoTW confirmation ── + let lotwDown: { state: PipState; tooltip: string }; + const lotwConfirmed = qsl_lotw === true || lotw_qsl_rcvd === 'Y'; + if (lotwConfirmed) { + if (lotw_match_status === 'mismatch') { + lotwDown = { state: 'pending', tooltip: `LoTW mismatch found${formatDate(qsl_lotw_date)}` }; + } else if (lotw_match_status === 'partial') { + lotwDown = { state: 'pending', tooltip: `LoTW partial match${formatDate(qsl_lotw_date)}` }; + } else { + lotwDown = { state: 'ok', tooltip: `Confirmed via LoTW${formatDate(qsl_lotw_date)}` }; + } + } else if (lotw_qsl_sent === 'Y') { + lotwDown = { state: 'pending', tooltip: 'Awaiting LoTW confirmation' }; + } else { + lotwDown = { state: 'none', tooltip: 'Awaiting confirmation' }; + } + + // ── QRZ upload ── + let qrzUp: { state: PipState; tooltip: string }; + if (qrz_qsl_sent === 'Y') { + qrzUp = { state: 'ok', tooltip: `Uploaded to QRZ${formatDate(qrz_qsl_sent_date)}` }; + } else if (qrz_qsl_sent === 'R') { + qrzUp = { state: 'bad', tooltip: 'QRZ upload failed' }; + } else { + qrzUp = { state: 'none', tooltip: 'Not uploaded to QRZ' }; + } + + // ── QRZ confirmation ── + let qrzDown: { state: PipState; tooltip: string }; + if (qrz_qsl_rcvd === 'Y') { + qrzDown = { state: 'ok', tooltip: `Confirmed via QRZ${formatDate(qrz_qsl_rcvd_date)}` }; + } else if (qrz_qsl_sent === 'Y') { + qrzDown = { state: 'pending', tooltip: 'Awaiting QRZ confirmation' }; + } else if (qrz_qsl_sent === 'R') { + qrzDown = { state: 'none', tooltip: 'No data — upload failed' }; + } else { + qrzDown = { state: 'none', tooltip: 'Awaiting confirmation' }; + } + + const canUploadLotw = + Boolean(contact_id) && Boolean(station_id) && lotw_qsl_sent !== 'Y' && !uploadingLotw; + + const handleLotwUpload = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!canUploadLotw) return; + setUploadingLotw(true); + try { + const response = await fetch('/api/lotw/upload-contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contact_id }), + }); + const data = await response.json(); + if (response.ok && data.success) { + onStatusChange?.(); + } else { + console.error('LoTW upload failed:', data.error); + } + } catch (err) { + console.error('LoTW upload error:', err); + } finally { + setUploadingLotw(false); + } + }; + + return ( +
e.stopPropagation()} + > + LoTW + + + + QRZ + + +
+ ); +}