Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 30 additions & 35 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <Chip variant="ok" size="sm">✓ LoTW</Chip>;
if (qrzOk) return <Chip variant="ok" size="sm">✓ QRZ</Chip>;
return <Chip variant="warn" size="sm"><Dot tone="warn" /> pending</Chip>;
};

return (
<div className="min-h-screen">
<Navbar />
Expand Down Expand Up @@ -513,7 +504,7 @@ export default function DashboardPage() {
<TableHead>Freq</TableHead>
<TableHead>RST</TableHead>
<TableHead>Operator</TableHead>
<TableHead>QSL</TableHead>
<TableHead>QSL · LoTW / QRZ</TableHead>
</TableRow>
</TableHeader>
<TableBody>
Expand Down Expand Up @@ -574,29 +565,20 @@ export default function DashboardPage() {
) : null}
</TableCell>
<TableCell>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
{qslChip(contact)}
<span className="hidden xl:flex items-center gap-1.5">
<LotwSyncIndicator
lotwQslSent={contact.lotw_qsl_sent}
lotwQslRcvd={contact.lotw_qsl_rcvd}
qslLotw={contact.qsl_lotw}
qslLotwDate={contact.qsl_lotw_date}
lotwMatchStatus={contact.lotw_match_status}
contactId={contact.id}
stationId={contact.station_id}
onStatusChange={() => fetchContacts()}
size="sm"
/>
<QRZSyncIndicator
qrzQslSent={contact.qrz_qsl_sent}
qrzQslSentDate={contact.qrz_qsl_sent_date}
qrzQslRcvd={contact.qrz_qsl_rcvd}
qrzQslRcvdDate={contact.qrz_qsl_rcvd_date}
size="sm"
/>
</span>
</div>
<QslMatrix
lotw_qsl_sent={contact.lotw_qsl_sent}
lotw_qsl_rcvd={contact.lotw_qsl_rcvd}
qsl_lotw={contact.qsl_lotw}
qsl_lotw_date={contact.qsl_lotw_date}
lotw_match_status={contact.lotw_match_status}
qrz_qsl_sent={contact.qrz_qsl_sent}
qrz_qsl_sent_date={contact.qrz_qsl_sent_date}
qrz_qsl_rcvd={contact.qrz_qsl_rcvd}
qrz_qsl_rcvd_date={contact.qrz_qsl_rcvd_date}
contact_id={contact.id}
station_id={contact.station_id}
onStatusChange={() => fetchContacts()}
/>
</TableCell>
</TableRow>
))
Expand Down Expand Up @@ -628,7 +610,20 @@ export default function DashboardPage() {
<span className="font-mono font-semibold text-fg text-lg">
{contact.callsign}
</span>
{qslChip(contact)}
<QslMatrix
lotw_qsl_sent={contact.lotw_qsl_sent}
lotw_qsl_rcvd={contact.lotw_qsl_rcvd}
qsl_lotw={contact.qsl_lotw}
qsl_lotw_date={contact.qsl_lotw_date}
lotw_match_status={contact.lotw_match_status}
qrz_qsl_sent={contact.qrz_qsl_sent}
qrz_qsl_sent_date={contact.qrz_qsl_sent_date}
qrz_qsl_rcvd={contact.qrz_qsl_rcvd}
qrz_qsl_rcvd_date={contact.qrz_qsl_rcvd_date}
contact_id={contact.id}
station_id={contact.station_id}
onStatusChange={() => fetchContacts()}
/>
</div>
<MobileCardRow label="When">
<span>
Expand Down
199 changes: 199 additions & 0 deletions src/components/QslMatrix.tsx
Original file line number Diff line number Diff line change
@@ -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<PipState, string> = {
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 (
<span
title={tooltip}
onClick={onClick}
className={[
'relative inline-flex items-center justify-center w-[26px] h-[22px] rounded-[5px] border',
PIP_CLASSES[state],
clickable ? 'cursor-pointer hover:opacity-80' : 'cursor-help',
].join(' ')}
>
<Icon className={loading ? 'h-3 w-3 animate-spin' : 'h-3 w-3'} strokeWidth={2.2} />
{isPendingUp && (
<span
aria-hidden
className="absolute top-[3px] right-[3px] w-[4px] h-[4px] rounded-full bg-warn animate-pulse"
style={{ boxShadow: '0 0 6px var(--warn)' }}
/>
)}
</span>
);
}

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 (
<div
className="inline-grid items-center font-mono"
style={{ gridTemplateColumns: '36px repeat(2, 26px)', gap: '4px 6px' }}
onClick={(e) => e.stopPropagation()}
>
<span className="text-[10px] font-semibold text-fg-1 tracking-[0.04em]">LoTW</span>
<Pip
state={lotwUp.state}
direction="up"
tooltip={lotwUp.tooltip}
loading={uploadingLotw}
onClick={canUploadLotw ? handleLotwUpload : undefined}
/>
<Pip state={lotwDown.state} direction="down" tooltip={lotwDown.tooltip} />

<span className="text-[10px] font-semibold text-fg-1 tracking-[0.04em]">QRZ</span>
<Pip state={qrzUp.state} direction="up" tooltip={qrzUp.tooltip} />
<Pip state={qrzDown.state} direction="down" tooltip={qrzDown.tooltip} />
</div>
);
}
Loading