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
+
+
+
+ );
+}