Compare commits

...

4 Commits

Author SHA1 Message Date
b40d3639f7 feat: add callsign to DLD award entity details
The DLD award detail page was only showing the mode in QSO entries
because the entity breakdown didn't include the callsign field.

Changes:
- Backend: Add callsign field to DOK award entity details

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-19 13:12:25 +01:00
9dc8c8b678 fix: use qsoId for fetching QSO details in award page modal
The award page was filtering QSOs by callsign/date/band/mode, which
could return the wrong QSO when multiple QSOs with the same callsign
exist on the same band/mode combination.

Changes:
- Backend: Add qsoId field to award entity breakdown responses
- Backend: Add GET /api/qsos/:id endpoint to fetch QSO by ID
- Backend: Implement getQSOById() function in lotw.service.js
- Frontend: Update openQSODetailModal() to fetch by qsoId instead of filtering
- Frontend: Include qsoId in QSO entry objects for modal click handler

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-19 13:08:19 +01:00
b332989844 fix: include band field in QSO entries for award detail modal
The QSO entries on the award detail page were missing the band field,
causing the openQSODetailModal() function to fail with "QSO not found"
when trying to fetch full QSO details. The band field is now included
in the QSO entry objects so the filter parameters match correctly.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-19 13:00:10 +01:00
86e486aea6 feat: add QSO detail modal
- Click on any QSO row to view detailed information in modal
- Display all QSO fields organized by section:
  - Basic information (callsign, date, time, band, mode)
  - Location (entity, DXCC, grid, continent, zones, state, county)
  - Satellite info (when applicable)
  - DOK information (partner's and my DOK)
  - Confirmation status (LoTW and DCL with dates and status)
- Keyboard accessible (Enter to open, Escape to close)
- Close by clicking backdrop, pressing Escape, or × button
- Hover effect on rows to indicate clickability

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-19 12:51:17 +01:00
5 changed files with 957 additions and 2 deletions

View File

@@ -13,6 +13,7 @@ import {
getUserQSOs,
getQSOStats,
deleteQSOs,
getQSOById,
} from './services/lotw.service.js';
import {
enqueueJob,
@@ -489,6 +490,44 @@ const app = new Elysia()
}
})
/**
* GET /api/qsos/:id
* Get a single QSO by ID (requires authentication)
*/
.get('/api/qsos/:id', async ({ user, params, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const qsoId = parseInt(params.id);
if (isNaN(qsoId)) {
set.status = 400;
return { success: false, error: 'Invalid QSO ID' };
}
const qso = await getQSOById(user.id, qsoId);
if (!qso) {
set.status = 404;
return { success: false, error: 'QSO not found' };
}
return {
success: true,
qso,
};
} catch (error) {
logger.error('Failed to fetch QSO by ID', { error: error.message, userId: user?.id, qsoId: params.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch QSO',
};
}
})
/**
* GET /api/qsos/stats
* Get QSO statistics (requires authentication)

View File

@@ -210,11 +210,13 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
// Initialize combination if not exists
if (!dokCombinations.has(combinationKey)) {
dokCombinations.set(combinationKey, {
qsoId: qso.id,
entity: dok,
entityId: null,
entityName: dok,
band,
mode,
callsign: qso.callsign,
worked: false,
confirmed: false,
qsoDate: qso.qsoDate,
@@ -335,6 +337,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
if (!combinationMap.has(combinationKey)) {
combinationMap.set(combinationKey, {
qsoId: qso.id,
callsign,
band,
mode,
@@ -373,6 +376,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
if (!stationMap.has(callsign)) {
stationMap.set(callsign, {
qsoId: qso.id,
callsign,
points,
worked: true,
@@ -410,6 +414,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
if (qso.lotwQslRstatus === 'Y') {
totalPoints += points;
stationDetails.push({
qsoId: qso.id,
callsign,
points,
worked: true,
@@ -446,6 +451,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
const entities = stationDetails.map((detail) => {
if (countMode === 'perBandMode') {
return {
qsoId: detail.qsoId,
entity: `${detail.callsign}/${detail.band}/${detail.mode}`,
entityId: null,
entityName: `${detail.callsign} (${detail.band}/${detail.mode})`,
@@ -460,6 +466,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
};
} else if (countMode === 'perStation') {
return {
qsoId: detail.qsoId,
entity: detail.callsign,
entityId: null,
entityName: detail.callsign,
@@ -474,6 +481,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
};
} else {
return {
qsoId: detail.qsoId,
entity: `${detail.callsign}-${detail.qsoDate}`,
entityId: null,
entityName: `${detail.callsign} on ${detail.qsoDate}`,
@@ -673,6 +681,7 @@ export async function getAwardEntityBreakdown(userId, awardId) {
}
entityMap.set(entity, {
qsoId: qso.id,
entity,
entityId: qso.entityId,
entityName: displayName,

View File

@@ -451,3 +451,18 @@ export async function deleteQSOs(userId) {
return result;
}
/**
* Get a single QSO by ID for a specific user
* @param {number} userId - User ID
* @param {number} qsoId - QSO ID
* @returns {Object|null} QSO object or null if not found
*/
export async function getQSOById(userId, qsoId) {
const result = await db
.select()
.from(qsos)
.where(and(eq(qsos.userId, userId), eq(qsos.id, qsoId)));
return result.length > 0 ? result[0] : null;
}

View File

@@ -10,6 +10,11 @@
let groupedData = [];
let bands = [];
// QSO detail modal state
let selectedQSO = null;
let showQSODetailModal = false;
let loadingQSO = false;
onMount(async () => {
await loadAwardData();
});
@@ -78,8 +83,10 @@
// Add QSO info to this band
entityData.bands.get(entity.band).push({
qsoId: entity.qsoId,
callsign: entity.callsign,
mode: entity.mode,
band: entity.band,
confirmed: entity.confirmed,
qsoDate: entity.qsoDate,
});
@@ -125,8 +132,10 @@
}
entityData.bands.get(entity.band).push({
qsoId: entity.qsoId,
callsign: entity.callsign,
mode: entity.mode,
band: entity.band,
confirmed: entity.confirmed,
qsoDate: entity.qsoDate,
});
@@ -154,6 +163,71 @@
$: if (entities.length > 0) {
applyFilter();
}
// QSO Detail Modal Functions
async function openQSODetailModal(qso) {
loadingQSO = true;
showQSODetailModal = true;
selectedQSO = null;
try {
// Fetch full QSO details by ID
const response = await fetch(`/api/qsos/${qso.qsoId}`, {
headers: {
'Authorization': `Bearer ${$auth.token}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch QSO details');
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to fetch QSO details');
}
if (data.qso) {
selectedQSO = data.qso;
} else {
throw new Error('QSO not found');
}
} catch (err) {
console.error('Failed to load QSO details:', err);
alert('Failed to load QSO details: ' + err.message);
showQSODetailModal = false;
} finally {
loadingQSO = false;
}
}
function closeQSODetailModal() {
selectedQSO = null;
showQSODetailModal = false;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
// ADIF format: YYYYMMDD
const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8);
return `${day}/${month}/${year}`;
}
function formatTime(timeStr) {
if (!timeStr) return '-';
// ADIF format: HHMMSS or HHMM
return timeStr.substring(0, 2) + ':' + timeStr.substring(2, 4);
}
function getConfirmationStatus(status) {
if (status === 'Y') return { label: 'Confirmed', class: 'confirmed' };
if (status === 'N') return { label: 'Not Confirmed', class: 'not-confirmed' };
if (status === '?') return { label: 'Unknown', class: 'unknown' };
return { label: 'No Data', class: 'no-data' };
}
</script>
<div class="container">
@@ -247,7 +321,13 @@
{#if qsos.length > 0}
<div class="qso-list">
{#each qsos as qso}
<div class="qso-entry {qso.confirmed ? 'qso-confirmed' : 'qso-worked'}">
<div
class="qso-entry {qso.confirmed ? 'qso-confirmed' : 'qso-worked'}"
on:click={() => openQSODetailModal(qso)}
on:keydown={(e) => e.key === 'Enter' && openQSODetailModal(qso)}
role="button"
tabindex="0"
>
<span class="callsign">{qso.callsign}</span>
<span class="mode">{qso.mode}</span>
</div>
@@ -267,6 +347,188 @@
{/if}
</div>
<!-- QSO Detail Modal -->
{#if showQSODetailModal && selectedQSO}
<div class="modal-backdrop" on:click={closeQSODetailModal} on:keydown={(e) => e.key === 'Escape' && closeQSODetailModal()} role="dialog" aria-modal="true">
<div class="modal-content" on:click|stopPropagation>
<div class="modal-header">
<h2>QSO Details</h2>
<button class="modal-close" on:click={closeQSODetailModal} aria-label="Close modal">×</button>
</div>
<div class="modal-body">
{#if loadingQSO}
<div class="loading-modal">Loading QSO details...</div>
{:else}
<div class="qso-detail-section">
<h3>Basic Information</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Callsign:</span>
<span class="detail-value">{selectedQSO.callsign}</span>
</div>
<div class="detail-item">
<span class="detail-label">Date:</span>
<span class="detail-value">{formatDate(selectedQSO.qsoDate)}</span>
</div>
<div class="detail-item">
<span class="detail-label">Time:</span>
<span class="detail-value">{formatTime(selectedQSO.timeOn)}</span>
</div>
<div class="detail-item">
<span class="detail-label">Band:</span>
<span class="detail-value">{selectedQSO.band || '-'}</span>
</div>
<div class="detail-item">
<span class="detail-label">Mode:</span>
<span class="detail-value">{selectedQSO.mode || '-'}</span>
</div>
</div>
</div>
<div class="qso-detail-section">
<h3>Location</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Entity:</span>
<span class="detail-value">{selectedQSO.entity || '-'}</span>
</div>
{#if selectedQSO.entityId}
<div class="detail-item">
<span class="detail-label">DXCC ID:</span>
<span class="detail-value">{selectedQSO.entityId}</span>
</div>
{/if}
<div class="detail-item">
<span class="detail-label">Grid Square:</span>
<span class="detail-value">{selectedQSO.grid || '-'}</span>
</div>
{#if selectedQSO.gridSource}
<div class="detail-item">
<span class="detail-label">Grid Source:</span>
<span class="detail-value">{selectedQSO.gridSource}</span>
</div>
{/if}
<div class="detail-item">
<span class="detail-label">Continent:</span>
<span class="detail-value">{selectedQSO.continent || '-'}</span>
</div>
{#if selectedQSO.cqZone}
<div class="detail-item">
<span class="detail-label">CQ Zone:</span>
<span class="detail-value">{selectedQSO.cqZone}</span>
</div>
{/if}
{#if selectedQSO.ituZone}
<div class="detail-item">
<span class="detail-label">ITU Zone:</span>
<span class="detail-value">{selectedQSO.ituZone}</span>
</div>
{/if}
{#if selectedQSO.state}
<div class="detail-item">
<span class="detail-label">State:</span>
<span class="detail-value">{selectedQSO.state}</span>
</div>
{/if}
{#if selectedQSO.county}
<div class="detail-item">
<span class="detail-label">County:</span>
<span class="detail-value">{selectedQSO.county}</span>
</div>
{/if}
</div>
</div>
{#if selectedQSO.satName}
<div class="qso-detail-section">
<h3>Satellite</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Satellite:</span>
<span class="detail-value">{selectedQSO.satName}</span>
</div>
{#if selectedQSO.satMode}
<div class="detail-item">
<span class="detail-label">Mode:</span>
<span class="detail-value">{selectedQSO.satMode}</span>
</div>
{/if}
</div>
</div>
{/if}
<div class="qso-detail-section">
<h3>DOK Information</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Partner's DOK:</span>
<span class="detail-value">{selectedQSO.darcDok || '-'}</span>
</div>
<div class="detail-item">
<span class="detail-label">My DOK:</span>
<span class="detail-value">{selectedQSO.myDarcDok || '-'}</span>
</div>
</div>
</div>
<div class="qso-detail-section">
<h3>Confirmation Status</h3>
<div class="confirmation-details">
<div class="confirmation-service">
<h4>LoTW</h4>
<div class="confirmation-status-item">
<span class="detail-label">Status:</span>
<span class="status-badge {getConfirmationStatus(selectedQSO.lotwQslRstatus).class}">
{getConfirmationStatus(selectedQSO.lotwQslRstatus).label}
</span>
</div>
{#if selectedQSO.lotwQslRdate}
<div class="confirmation-status-item">
<span class="detail-label">Confirmed:</span>
<span class="detail-value">{formatDate(selectedQSO.lotwQslRdate)}</span>
</div>
{/if}
{#if selectedQSO.lotwSyncedAt}
<div class="confirmation-status-item">
<span class="detail-label">Last Synced:</span>
<span class="detail-value">{new Date(selectedQSO.lotwSyncedAt).toLocaleString()}</span>
</div>
{/if}
</div>
<div class="confirmation-service">
<h4>DCL</h4>
<div class="confirmation-status-item">
<span class="detail-label">Status:</span>
<span class="status-badge {getConfirmationStatus(selectedQSO.dclQslRstatus).class}">
{getConfirmationStatus(selectedQSO.dclQslRstatus).label}
</span>
</div>
{#if selectedQSO.dclQslRdate}
<div class="confirmation-status-item">
<span class="detail-label">Confirmed:</span>
<span class="detail-value">{formatDate(selectedQSO.dclQslRdate)}</span>
</div>
{/if}
</div>
</div>
</div>
<div class="qso-detail-section meta-info">
<span class="meta-label">QSO ID:</span>
<span class="meta-value">{selectedQSO.id}</span>
{#if selectedQSO.createdAt}
<span class="meta-label">Created:</span>
<span class="meta-value">{new Date(selectedQSO.createdAt).toLocaleString()}</span>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
.container {
max-width: 1200px;
@@ -498,4 +760,224 @@
.btn:hover {
background-color: #357abd;
}
/* QSO Detail Modal Styles */
.qso-entry {
cursor: pointer;
transition: background-color 0.2s;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.qso-entry:hover {
background-color: #f0f7ff;
}
.qso-entry:focus {
outline: 2px solid #4a90e2;
outline-offset: -2px;
}
/* Modal Backdrop */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
/* Modal Content */
.modal-content {
background: white;
border-radius: 8px;
max-width: 700px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
/* Modal Header */
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e0e0e0;
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
color: #333;
}
.modal-close {
background: none;
border: none;
font-size: 2rem;
line-height: 1;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
color: #666;
border-radius: 4px;
transition: all 0.2s;
}
.modal-close:hover {
background-color: #f0f0f0;
color: #333;
}
/* Modal Body */
.modal-body {
padding: 1.5rem;
}
/* Loading State */
.loading-modal {
text-align: center;
padding: 3rem;
color: #666;
}
/* Detail Sections */
.qso-detail-section {
margin-bottom: 2rem;
}
.qso-detail-section:last-child {
margin-bottom: 0;
}
.qso-detail-section h3 {
font-size: 1.1rem;
color: #4a90e2;
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e0e0e0;
}
/* Detail Grid */
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.detail-label {
font-size: 0.75rem;
color: #666;
text-transform: uppercase;
font-weight: 600;
}
.detail-value {
font-size: 0.95rem;
color: #333;
font-weight: 500;
}
/* Confirmation Details */
.confirmation-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
}
.confirmation-service h4 {
font-size: 1rem;
color: #333;
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid #e0e0e0;
}
.confirmation-status-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
/* Status Badges */
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.confirmed {
background-color: #4a90e2;
color: white;
}
.status-badge.not-confirmed,
.status-badge.no-data {
background-color: #e0e0e0;
color: #666;
}
.status-badge.unknown {
background-color: #fff3cd;
color: #856404;
}
/* Meta Info */
.meta-info {
display: flex;
gap: 2rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 4px;
font-size: 0.8rem;
}
.meta-label {
color: #666;
font-weight: 600;
}
.meta-value {
color: #333;
font-family: monospace;
}
/* Scrollbar Styling for Modal */
.modal-content::-webkit-scrollbar {
width: 8px;
}
.modal-content::-webkit-scrollbar-track {
background: #f1f1f1;
}
.modal-content::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.modal-content::-webkit-scrollbar-thumb:hover {
background: #555;
}
</style>

View File

@@ -33,6 +33,10 @@
let deleteConfirmText = '';
let deleting = false;
// QSO detail modal state
let selectedQSO = null;
let showQSODetailModal = false;
let filters = {
band: '',
mode: '',
@@ -274,6 +278,23 @@
loadQSOs();
}
function openQSODetailModal(qso) {
selectedQSO = qso;
showQSODetailModal = true;
}
function closeQSODetailModal() {
selectedQSO = null;
showQSODetailModal = false;
}
function getConfirmationStatus(status) {
if (status === 'Y') return { label: 'Confirmed', class: 'confirmed' };
if (status === 'N') return { label: 'Not Confirmed', class: 'not-confirmed' };
if (status === '?') return { label: 'Unknown', class: 'unknown' };
return { label: 'No Data', class: 'no-data' };
}
function goToPage(page) {
currentPage = page;
loadQSOs();
@@ -603,7 +624,7 @@
</thead>
<tbody>
{#each qsos as qso}
<tr>
<tr on:click={() => openQSODetailModal(qso)} class="qso-row" on:keydown={(e) => e.key === 'Enter' && openQSODetailModal(qso)} role="button" tabindex="0">
<td>{formatDate(qso.qsoDate)}</td>
<td>{formatTime(qso.timeOn)}</td>
<td class="callsign">{qso.callsign}</td>
@@ -685,6 +706,184 @@
{/if}
</div>
<!-- QSO Detail Modal -->
{#if showQSODetailModal && selectedQSO}
<div class="modal-backdrop" on:click={closeQSODetailModal} on:keydown={(e) => e.key === 'Escape' && closeQSODetailModal()} role="dialog" aria-modal="true">
<div class="modal-content" on:click|stopPropagation>
<div class="modal-header">
<h2>QSO Details</h2>
<button class="modal-close" on:click={closeQSODetailModal} aria-label="Close modal">×</button>
</div>
<div class="modal-body">
<div class="qso-detail-section">
<h3>Basic Information</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Callsign:</span>
<span class="detail-value">{selectedQSO.callsign}</span>
</div>
<div class="detail-item">
<span class="detail-label">Date:</span>
<span class="detail-value">{formatDate(selectedQSO.qsoDate)}</span>
</div>
<div class="detail-item">
<span class="detail-label">Time:</span>
<span class="detail-value">{formatTime(selectedQSO.timeOn)}</span>
</div>
<div class="detail-item">
<span class="detail-label">Band:</span>
<span class="detail-value">{selectedQSO.band || '-'}</span>
</div>
<div class="detail-item">
<span class="detail-label">Mode:</span>
<span class="detail-value">{selectedQSO.mode || '-'}</span>
</div>
</div>
</div>
<div class="qso-detail-section">
<h3>Location</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Entity:</span>
<span class="detail-value">{selectedQSO.entity || '-'}</span>
</div>
{#if selectedQSO.entityId}
<div class="detail-item">
<span class="detail-label">DXCC ID:</span>
<span class="detail-value">{selectedQSO.entityId}</span>
</div>
{/if}
<div class="detail-item">
<span class="detail-label">Grid Square:</span>
<span class="detail-value">{selectedQSO.grid || '-'}</span>
</div>
{#if selectedQSO.gridSource}
<div class="detail-item">
<span class="detail-label">Grid Source:</span>
<span class="detail-value">{selectedQSO.gridSource}</span>
</div>
{/if}
<div class="detail-item">
<span class="detail-label">Continent:</span>
<span class="detail-value">{selectedQSO.continent || '-'}</span>
</div>
{#if selectedQSO.cqZone}
<div class="detail-item">
<span class="detail-label">CQ Zone:</span>
<span class="detail-value">{selectedQSO.cqZone}</span>
</div>
{/if}
{#if selectedQSO.ituZone}
<div class="detail-item">
<span class="detail-label">ITU Zone:</span>
<span class="detail-value">{selectedQSO.ituZone}</span>
</div>
{/if}
{#if selectedQSO.state}
<div class="detail-item">
<span class="detail-label">State:</span>
<span class="detail-value">{selectedQSO.state}</span>
</div>
{/if}
{#if selectedQSO.county}
<div class="detail-item">
<span class="detail-label">County:</span>
<span class="detail-value">{selectedQSO.county}</span>
</div>
{/if}
</div>
</div>
{#if selectedQSO.satName}
<div class="qso-detail-section">
<h3>Satellite</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Satellite:</span>
<span class="detail-value">{selectedQSO.satName}</span>
</div>
{#if selectedQSO.satMode}
<div class="detail-item">
<span class="detail-label">Mode:</span>
<span class="detail-value">{selectedQSO.satMode}</span>
</div>
{/if}
</div>
</div>
{/if}
<div class="qso-detail-section">
<h3>DOK Information</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Partner's DOK:</span>
<span class="detail-value">{selectedQSO.darcDok || '-'}</span>
</div>
<div class="detail-item">
<span class="detail-label">My DOK:</span>
<span class="detail-value">{selectedQSO.myDarcDok || '-'}</span>
</div>
</div>
</div>
<div class="qso-detail-section">
<h3>Confirmation Status</h3>
<div class="confirmation-details">
<div class="confirmation-service">
<h4>LoTW</h4>
<div class="confirmation-status-item">
<span class="detail-label">Status:</span>
<span class="status-badge {getConfirmationStatus(selectedQSO.lotwQslRstatus).class}">
{getConfirmationStatus(selectedQSO.lotwQslRstatus).label}
</span>
</div>
{#if selectedQSO.lotwQslRdate}
<div class="confirmation-status-item">
<span class="detail-label">Confirmed:</span>
<span class="detail-value">{formatDate(selectedQSO.lotwQslRdate)}</span>
</div>
{/if}
{#if selectedQSO.lotwSyncedAt}
<div class="confirmation-status-item">
<span class="detail-label">Last Synced:</span>
<span class="detail-value">{new Date(selectedQSO.lotwSyncedAt).toLocaleString()}</span>
</div>
{/if}
</div>
<div class="confirmation-service">
<h4>DCL</h4>
<div class="confirmation-status-item">
<span class="detail-label">Status:</span>
<span class="status-badge {getConfirmationStatus(selectedQSO.dclQslRstatus).class}">
{getConfirmationStatus(selectedQSO.dclQslRstatus).label}
</span>
</div>
{#if selectedQSO.dclQslRdate}
<div class="confirmation-status-item">
<span class="detail-label">Confirmed:</span>
<span class="detail-value">{formatDate(selectedQSO.dclQslRdate)}</span>
</div>
{/if}
</div>
</div>
</div>
<div class="qso-detail-section meta-info">
<span class="meta-label">QSO ID:</span>
<span class="meta-value">{selectedQSO.id}</span>
{#if selectedQSO.createdAt}
<span class="meta-label">Created:</span>
<span class="meta-value">{new Date(selectedQSO.createdAt).toLocaleString()}</span>
{/if}
</div>
</div>
</div>
</div>
{/if}
<style>
.container {
max-width: 1200px;
@@ -1140,4 +1339,215 @@
font-weight: 600;
color: #4a90e2;
}
/* QSO Detail Modal Styles */
.qso-row {
cursor: pointer;
transition: background-color 0.2s;
}
.qso-row:hover {
background-color: #f0f7ff !important;
}
.qso-row:focus {
outline: 2px solid #4a90e2;
outline-offset: -2px;
}
/* Modal Backdrop */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
/* Modal Content */
.modal-content {
background: white;
border-radius: 8px;
max-width: 700px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
/* Modal Header */
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e0e0e0;
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
color: #333;
}
.modal-close {
background: none;
border: none;
font-size: 2rem;
line-height: 1;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
color: #666;
border-radius: 4px;
transition: all 0.2s;
}
.modal-close:hover {
background-color: #f0f0f0;
color: #333;
}
/* Modal Body */
.modal-body {
padding: 1.5rem;
}
/* Detail Sections */
.qso-detail-section {
margin-bottom: 2rem;
}
.qso-detail-section:last-child {
margin-bottom: 0;
}
.qso-detail-section h3 {
font-size: 1.1rem;
color: #4a90e2;
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e0e0e0;
}
/* Detail Grid */
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.detail-label {
font-size: 0.75rem;
color: #666;
text-transform: uppercase;
font-weight: 600;
}
.detail-value {
font-size: 0.95rem;
color: #333;
font-weight: 500;
}
/* Confirmation Details */
.confirmation-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
}
.confirmation-service h4 {
font-size: 1rem;
color: #333;
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid #e0e0e0;
}
.confirmation-status-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
/* Status Badges */
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.confirmed {
background-color: #4a90e2;
color: white;
}
.status-badge.not-confirmed,
.status-badge.no-data {
background-color: #e0e0e0;
color: #666;
}
.status-badge.unknown {
background-color: #fff3cd;
color: #856404;
}
/* Meta Info */
.meta-info {
display: flex;
gap: 2rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 4px;
font-size: 0.8rem;
}
.meta-label {
color: #666;
font-weight: 600;
}
.meta-value {
color: #333;
font-family: monospace;
}
/* Scrollbar Styling for Modal */
.modal-content::-webkit-scrollbar {
width: 8px;
}
.modal-content::-webkit-scrollbar-track {
background: #f1f1f1;
}
.modal-content::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.modal-content::-webkit-scrollbar-thumb:hover {
background: #555;
}
</style>