diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index 6a03e8b..a9e3b71 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -7,13 +7,16 @@ import { startPDFDetection, DetectedPDF } from '../utils/pdf-detector'; import { loadCredentials } from '../utils/storage'; import { addPDF, - getPendingCount, + getActionableCount, + getAllPDFs, getPendingPDFs, updatePDFStatus, removePDF, cleanupOldEntries, - PDFStatus + PDFStatus, + PDFStatusMeta } from '../utils/pdf-queue'; +import { shipDocument, getDocumentStatus } from '../utils/binect-api'; /** * Initialize extension on install @@ -78,10 +81,10 @@ async function checkAndDeleteExpiredCredentials() { } /** - * Update badge with pending PDF count + * Update badge with actionable PDF count */ async function updateBadge() { - const count = await getPendingCount(); + const count = await getActionableCount(); const text = count > 0 ? count.toString() : 'โ€ข'; chrome.action.setBadgeText({ text }); chrome.action.setBadgeBackgroundColor({ color: '#4A90E2' }); // Binect Blue @@ -117,6 +120,16 @@ startPDFDetection(async (pdf: DetectedPDF) => { chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { console.log('[Service Worker] Message received:', request.action); + // Get all PDFs (including completed ones for display) + if (request.action === 'getAllPDFs') { + getAllPDFs().then(entries => { + console.log('[Service Worker] Returning all PDFs:', entries.length, 'entries'); + sendResponse({ entries }); + }); + return true; + } + + // Legacy: Get only actionable PDFs if (request.action === 'getPDFQueue') { getPendingPDFs().then(entries => { console.log('[Service Worker] Returning PDF queue:', entries.length, 'entries'); @@ -126,7 +139,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { } if (request.action === 'updatePDFStatus') { - const { id, status, meta } = request as { id: string; status: PDFStatus; meta?: object }; + const { id, status, meta } = request as { id: string; status: PDFStatus; meta?: PDFStatusMeta }; updatePDFStatus(id, status, meta).then(() => { return updateBadge(); }).then(() => { @@ -144,9 +157,50 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { return true; } + // Ship a document (place order for production) + if (request.action === 'shipDocument') { + const { documentId, username, password } = request as { + documentId: number; + username: string; + password: string; + }; + + shipDocument(documentId, username, password) + .then(result => { + sendResponse({ success: true, ...result }); + }) + .catch(error => { + sendResponse({ + success: false, + error: error instanceof Error ? error.message : 'Failed to ship document' + }); + }); + return true; + } + + // Get document status from Binect + if (request.action === 'getDocumentStatus') { + const { documentId, username, password } = request as { + documentId: number; + username: string; + password: string; + }; + + getDocumentStatus(documentId, username, password) + .then(result => { + sendResponse({ success: true, ...result }); + }) + .catch(error => { + sendResponse({ + success: false, + error: error instanceof Error ? error.message : 'Failed to get status' + }); + }); + return true; + } + // Legacy handlers for backward compatibility if (request.action === 'getLastPDF') { - // Return the first pending PDF for backward compatibility getPendingPDFs().then(entries => { const pdf = entries.length > 0 ? entries[0] : null; sendResponse({ pdf }); diff --git a/src/popup/popup.css b/src/popup/popup.css index 47bb47a..083c9ff 100644 --- a/src/popup/popup.css +++ b/src/popup/popup.css @@ -311,16 +311,34 @@ body { background: var(--border-color); } +/* Status-specific item styles */ .pdf-list-item.uploading { opacity: 0.7; } -.pdf-list-item.uploaded { +.pdf-list-item.in-basket { + background: rgba(74, 144, 226, 0.1); + border-left: 3px solid var(--binect-blue); +} + +.pdf-list-item.in-production { + background: rgba(0, 188, 212, 0.1); + border-left: 3px solid var(--cyan); +} + +.pdf-list-item.sent { background: rgba(76, 175, 80, 0.1); + border-left: 3px solid var(--signal-green); +} + +.pdf-list-item.canceled { + background: rgba(153, 153, 153, 0.1); + border-left: 3px solid var(--text-light); } .pdf-list-item.failed { background: rgba(229, 57, 53, 0.1); + border-left: 3px solid var(--red); } .pdf-item-icon { @@ -361,6 +379,66 @@ body { color: var(--red); } +.pdf-item-status.in-basket { + color: var(--binect-blue); +} + +.pdf-item-status.in-production { + color: var(--cyan); +} + +.pdf-item-status.sent { + color: var(--signal-green); +} + +.pdf-item-status.canceled { + color: var(--text-light); +} + +/* Price display */ +.pdf-price { + font-weight: 600; + color: var(--binect-blue-deep); +} + +/* Recipient address */ +.pdf-item-recipient { + font-size: 11px; + color: var(--text-secondary); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Section headers */ +.pdf-section { + margin-bottom: var(--spacing-md); +} + +.pdf-section:last-child { + margin-bottom: 0; +} + +.pdf-section-header { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: var(--spacing-sm); + padding-bottom: var(--spacing-xs); + border-bottom: 1px solid var(--border-color); +} + +.pdf-section-completed { + opacity: 0.8; +} + +.pdf-section-completed .pdf-section-header { + color: var(--text-light); +} + .pdf-item-actions { display: flex; flex-direction: column; @@ -405,6 +483,52 @@ body { color: var(--text-secondary); } +/* Order button */ +.btn-order-item { + padding: 6px 12px; + font-size: 11px; + min-height: auto; + background: var(--signal-green); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background 0.2s; + font-weight: 500; +} + +.btn-order-item:hover { + background: #43a047; +} + +.btn-order-item:disabled { + background: var(--border-color); + cursor: not-allowed; +} + +/* Refresh button */ +.btn-refresh-item { + padding: 6px 12px; + font-size: 11px; + min-height: auto; + background: var(--light-bg); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.btn-refresh-item:hover { + background: var(--border-color); + border-color: var(--text-light); +} + +/* Remove button */ +.btn-remove { + opacity: 0.7; +} + /* Status Messages */ .status-message { padding: var(--spacing-md); diff --git a/src/popup/popup.ts b/src/popup/popup.ts index e4fbaa2..73b87ce 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -4,10 +4,10 @@ import './popup.css'; import { loadCredentials, saveCredentials, deleteCredentials, updateLastUse } from '../utils/storage'; -import { uploadPDF, testConnection, BinectAPIError } from '../utils/binect-api'; +import { uploadPDF, testConnection, BinectAPIError, Document } from '../utils/binect-api'; import { fetchPDFBytes, DetectedPDF } from '../utils/pdf-detector'; import { addTrackingEntry } from '../tracking/tracker'; -import { PDFQueueEntry, PDFStatus } from '../utils/pdf-queue'; +import { PDFQueueEntry, PDFStatus, PDFStatusMeta } from '../utils/pdf-queue'; // DOM Elements const authView = document.getElementById('authView')!; @@ -152,8 +152,8 @@ async function handleLogin(e: Event) { async function loadPDFQueue() { console.log('[Popup] Loading PDF queue...'); - // Get queue from background script - const response = await chrome.runtime.sendMessage({ action: 'getPDFQueue' }); + // Get all PDFs from background script (including completed ones) + const response = await chrome.runtime.sendMessage({ action: 'getAllPDFs' }); pdfQueue = response?.entries || []; console.log('[Popup] Got', pdfQueue.length, 'entries from background'); @@ -238,13 +238,19 @@ async function checkRecentDownloads(): Promise { } /** - * Render the PDF list + * Render the PDF list with grouped sections */ function renderPDFList() { - // Filter to only pending and failed PDFs - const pending = pdfQueue.filter(p => p.status === 'pending' || p.status === 'failed'); + // Group PDFs by status category + const pendingUpload = pdfQueue.filter(p => p.status === 'pending' || p.status === 'uploading' || p.status === 'failed'); + const inBasket = pdfQueue.filter(p => p.status === 'in_basket' || p.status === 'ordering'); + const inProduction = pdfQueue.filter(p => p.status === 'in_production'); + const completed = pdfQueue.filter(p => p.status === 'sent' || p.status === 'canceled'); - if (pending.length === 0) { + // Count actionable items + const actionableCount = pendingUpload.length + inBasket.length; + + if (pdfQueue.length === 0) { showNoPDF(); return; } @@ -253,27 +259,117 @@ function renderPDFList() { pdfListView.style.display = 'block'; // Update count - pdfCount.textContent = `${pending.length} PDF${pending.length > 1 ? 's' : ''} ready`; + if (actionableCount > 0) { + pdfCount.textContent = `${actionableCount} PDF${actionableCount > 1 ? 's' : ''} need attention`; + } else if (inProduction.length > 0) { + pdfCount.textContent = `${inProduction.length} in production`; + } else { + pdfCount.textContent = `${completed.length} completed`; + } - // Render list items - pdfList.innerHTML = pending.map(pdf => ` -
-
๐Ÿ“„
-
-
${escapeHtml(pdf.filename)}
-
${formatFileSize(pdf.size)} ยท ${escapeHtml(pdf.sourceDomain)}
-
${getStatusText(pdf)}
-
-
+ // Build HTML for each section + let html = ''; + + if (pendingUpload.length > 0) { + html += `
+
Ready to Upload
+ ${pendingUpload.map(pdf => renderPDFItem(pdf, 'pending')).join('')} +
`; + } + + if (inBasket.length > 0) { + html += `
+
In Basket
+ ${inBasket.map(pdf => renderPDFItem(pdf, 'basket')).join('')} +
`; + } + + if (inProduction.length > 0) { + html += `
+
In Production
+ ${inProduction.map(pdf => renderPDFItem(pdf, 'production')).join('')} +
`; + } + + if (completed.length > 0) { + // Show only last 5 completed items + const recentCompleted = completed.slice(0, 5); + html += `
+
Recently Completed
+ ${recentCompleted.map(pdf => renderPDFItem(pdf, 'completed')).join('')} +
`; + } + + pdfList.innerHTML = html; + + // Add event listeners + setupPDFListEventListeners(); + hideStatus(); +} + +/** + * Render a single PDF item + */ +function renderPDFItem(pdf: PDFQueueEntry, section: 'pending' | 'basket' | 'production' | 'completed'): string { + const statusClass = getStatusClass(pdf.status); + const statusText = getStatusText(pdf); + const priceText = pdf.price ? `${(pdf.price / 100).toFixed(2)} โ‚ฌ` : ''; + + let actionsHtml = ''; + + switch (section) { + case 'pending': + actionsHtml = ` + `; + break; + case 'basket': + actionsHtml = ` + + + `; + break; + case 'production': + actionsHtml = ` + + `; + break; + case 'completed': + actionsHtml = ` + + `; + break; + } + + return ` +
+
${getStatusIcon(pdf.status)}
+
+
${escapeHtml(pdf.filename)}
+
+ ${formatFileSize(pdf.size)} ยท ${escapeHtml(pdf.sourceDomain)} + ${priceText ? ` ยท ${priceText}` : ''} +
+ ${pdf.recipientAddress ? `
${escapeHtml(pdf.recipientAddress.split('\n')[0])}
` : ''} +
${statusText}
+
+
+ ${actionsHtml}
- `).join(''); + `; +} - // Add event listeners to buttons +/** + * Setup event listeners for PDF list items + */ +function setupPDFListEventListeners() { + // Upload/Send buttons pdfList.querySelectorAll('.btn-send-item').forEach(btn => { btn.addEventListener('click', (e) => { const id = (e.target as HTMLElement).dataset.id; @@ -281,14 +377,29 @@ function renderPDFList() { }); }); + // Order buttons + pdfList.querySelectorAll('.btn-order-item').forEach(btn => { + btn.addEventListener('click', (e) => { + const id = (e.target as HTMLElement).dataset.id; + if (id) handleOrderPDF(id); + }); + }); + + // Refresh buttons + pdfList.querySelectorAll('.btn-refresh-item').forEach(btn => { + btn.addEventListener('click', (e) => { + const id = (e.target as HTMLElement).dataset.id; + if (id) handleRefreshStatus(id); + }); + }); + + // Dismiss/Cancel/Remove buttons pdfList.querySelectorAll('.btn-dismiss').forEach(btn => { btn.addEventListener('click', (e) => { const id = (e.target as HTMLElement).dataset.id; if (id) handleDismissPDF(id); }); }); - - hideStatus(); } /** @@ -296,17 +407,80 @@ function renderPDFList() { */ function getStatusText(pdf: PDFQueueEntry): string { switch (pdf.status) { + case 'pending': + return formatTimestamp(pdf.timestamp); case 'uploading': return 'Uploading...'; case 'failed': return pdf.errorMessage || 'Upload failed'; + case 'in_basket': + return pdf.binectStatusText || 'Ready to order'; + case 'ordering': + return 'Ordering...'; + case 'in_production': + return pdf.binectStatusText || 'In production'; + case 'sent': + return 'Sent successfully'; + case 'canceled': + return 'Canceled'; default: return formatTimestamp(pdf.timestamp); } } /** - * Handle send PDF + * Get CSS class for status + */ +function getStatusClass(status: PDFStatus): string { + switch (status) { + case 'pending': + return 'pending'; + case 'uploading': + case 'ordering': + return 'uploading'; + case 'failed': + return 'failed'; + case 'in_basket': + return 'in-basket'; + case 'in_production': + return 'in-production'; + case 'sent': + return 'sent'; + case 'canceled': + return 'canceled'; + default: + return ''; + } +} + +/** + * Get icon for status + */ +function getStatusIcon(status: PDFStatus): string { + switch (status) { + case 'pending': + return '๐Ÿ“„'; + case 'uploading': + return 'โณ'; + case 'failed': + return 'โŒ'; + case 'in_basket': + return '๐Ÿ›’'; + case 'ordering': + return 'โณ'; + case 'in_production': + return '๐Ÿญ'; + case 'sent': + return 'โœ…'; + case 'canceled': + return '๐Ÿšซ'; + default: + return '๐Ÿ“„'; + } +} + +/** + * Handle send PDF (upload to Binect) */ async function handleSendPDF(id: string) { const pdf = pdfQueue.find(p => p.id === id); @@ -349,19 +523,38 @@ async function handleSendPDF(id: string) { // Update last use timestamp await updateLastUse(); - // Update status to uploaded + // Extract price and recipient from document response + const meta: PDFStatusMeta = { + binectDocumentId: document.id, + binectStatus: document.status.code, + binectStatusText: document.status.text + }; + + if (document.letter?.letterData) { + meta.price = document.letter.letterData.price?.priceAfterTax; + meta.recipientAddress = document.letter.letterData.recipientAddress; + } + + // Update status to in_basket (document is now SHIPPABLE) await chrome.runtime.sendMessage({ action: 'updatePDFStatus', id, - status: 'uploaded', - meta: { binectDocumentId: document.id } + status: 'in_basket', + meta }); - // Remove from local queue - pdfQueue = pdfQueue.filter(p => p.id !== id); + // Update local state + pdf.status = 'in_basket'; + pdf.binectDocumentId = document.id; + pdf.binectStatus = document.status.code; + pdf.binectStatusText = document.status.text; + if (document.letter?.letterData) { + pdf.price = document.letter.letterData.price?.priceAfterTax; + pdf.recipientAddress = document.letter.letterData.recipientAddress; + } renderPDFList(); - showStatus(`Sent! Document ID: ${document.id}`, 'success'); + showStatus(`Uploaded! Ready to order (${(pdf.price || 0) / 100} โ‚ฌ)`, 'success'); // Hide status after 3 seconds setTimeout(() => { @@ -412,6 +605,154 @@ async function handleSendPDF(id: string) { } } +/** + * Handle order PDF (ship document to production) + */ +async function handleOrderPDF(id: string) { + const pdf = pdfQueue.find(p => p.id === id); + if (!pdf || !currentCredentials || !pdf.binectDocumentId) { + return; + } + + // Update local state + pdf.status = 'ordering'; + renderPDFList(); + + // Notify background + await chrome.runtime.sendMessage({ + action: 'updatePDFStatus', + id, + status: 'ordering' + }); + + try { + // Ship the document + const result = await chrome.runtime.sendMessage({ + action: 'shipDocument', + documentId: pdf.binectDocumentId, + username: currentCredentials.username, + password: currentCredentials.password + }); + + if (!result.success) { + throw new Error(result.error || 'Failed to ship document'); + } + + // Update status to in_production + const meta: PDFStatusMeta = { + binectStatus: result.status, + binectStatusText: result.statusText + }; + + await chrome.runtime.sendMessage({ + action: 'updatePDFStatus', + id, + status: 'in_production', + meta + }); + + // Update local state + pdf.status = 'in_production'; + pdf.binectStatus = result.status; + pdf.binectStatusText = result.statusText; + renderPDFList(); + + showStatus('Order placed! Document is in production.', 'success'); + + // Hide status after 3 seconds + setTimeout(() => { + hideStatus(); + }, 3000); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Order failed'; + + // Revert to in_basket status + await chrome.runtime.sendMessage({ + action: 'updatePDFStatus', + id, + status: 'in_basket', + meta: { errorMessage } + }); + + // Update local state + pdf.status = 'in_basket'; + pdf.errorMessage = errorMessage; + renderPDFList(); + + showStatus(errorMessage, 'error'); + } +} + +/** + * Handle refresh document status + */ +async function handleRefreshStatus(id: string) { + const pdf = pdfQueue.find(p => p.id === id); + if (!pdf || !currentCredentials || !pdf.binectDocumentId) { + return; + } + + try { + // Get current status from Binect + const result = await chrome.runtime.sendMessage({ + action: 'getDocumentStatus', + documentId: pdf.binectDocumentId, + username: currentCredentials.username, + password: currentCredentials.password + }); + + if (!result.success) { + throw new Error(result.error || 'Failed to get status'); + } + + // Determine new local status based on Binect status code + let newStatus: PDFStatus = pdf.status; + if (result.status === 5) { + newStatus = 'sent'; + } else if (result.status === 6) { + newStatus = 'canceled'; + } else if (result.status === 3 || result.status === 4) { + newStatus = 'in_production'; + } else if (result.status === 2) { + newStatus = 'in_basket'; + } + + const meta: PDFStatusMeta = { + binectStatus: result.status, + binectStatusText: result.statusText, + price: result.price, + recipientAddress: result.recipientAddress + }; + + // Update in background + await chrome.runtime.sendMessage({ + action: 'updatePDFStatus', + id, + status: newStatus, + meta + }); + + // Update local state + pdf.status = newStatus; + pdf.binectStatus = result.status; + pdf.binectStatusText = result.statusText; + if (result.price) pdf.price = result.price; + if (result.recipientAddress) pdf.recipientAddress = result.recipientAddress; + renderPDFList(); + + showStatus(`Status: ${result.statusText}`, 'success'); + + setTimeout(() => { + hideStatus(); + }, 3000); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to refresh'; + showStatus(errorMessage, 'error'); + } +} + /** * Handle dismiss PDF */ diff --git a/src/utils/binect-api.ts b/src/utils/binect-api.ts index be7b629..cfcbb66 100644 --- a/src/utils/binect-api.ts +++ b/src/utils/binect-api.ts @@ -325,3 +325,151 @@ export async function testConnection(username: string, password: string): Promis return false; } } + +/** + * Document status information returned by getDocumentStatus + */ +export interface DocumentStatusInfo { + status: number; + statusText: string; + price?: number; + recipientAddress?: string; +} + +/** + * Ship a document (place order for production) + * + * This announces the document for delivery, transitioning it from + * SHIPPABLE to PRODUCTION_QUEUE. + * + * @param documentId - Binect document ID + * @param username - Binect username + * @param password - Binect password + * @returns Updated status info + */ +export async function shipDocument( + documentId: number, + username: string, + password: string +): Promise { + console.log('[Binect API] Shipping document:', documentId); + + try { + const client = new BinectClient({ + username, + password, + }); + + // Send the document for production + const sending = await client.sendings.send(String(documentId)); + + console.log('[Binect API] Document shipped successfully'); + console.log('[Binect API] New status:', sending.status); + + return { + status: sending.status, + statusText: getStatusText(sending.status), + price: sending.price, + }; + } catch (error) { + console.error('[Binect API] Ship error:', error); + + if (error instanceof BinectAuthError) { + throw new BinectAPIError('Invalid credentials', 401); + } + + if (error instanceof BinectApiError) { + // Check for insufficient balance (error code 2330) + if (error.message.includes('2330') || error.message.includes('balance')) { + throw new BinectAPIError('Insufficient account balance', 402); + } + throw BinectAPIError.fromBinectError(error); + } + + if (error instanceof BinectAPIError) { + throw error; + } + + throw new BinectAPIError( + `Failed to ship document: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get current status of a document + * + * @param documentId - Binect document ID + * @param username - Binect username + * @param password - Binect password + * @returns Current status info + */ +export async function getDocumentStatus( + documentId: number, + username: string, + password: string +): Promise { + console.log('[Binect API] Getting document status:', documentId); + + try { + const client = new BinectClient({ + username, + password, + }); + + // Fetch document details + const doc = await client.documents.get(String(documentId)); + + console.log('[Binect API] Document status:', doc.status); + + // Extract price and recipient if available + let price: number | undefined; + let recipientAddress: string | undefined; + + if (doc.letter?.letterData) { + price = doc.letter.letterData.price?.priceAfterTax; + recipientAddress = doc.letter.letterData.recipientAddress; + } + + return { + status: doc.status.code, + statusText: doc.status.text || getStatusText(doc.status.code), + price, + recipientAddress, + }; + } catch (error) { + console.error('[Binect API] Get status error:', error); + + if (error instanceof BinectAuthError) { + throw new BinectAPIError('Invalid credentials', 401); + } + + if (error instanceof BinectApiError) { + throw BinectAPIError.fromBinectError(error); + } + + if (error instanceof BinectAPIError) { + throw error; + } + + throw new BinectAPIError( + `Failed to get document status: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Get human-readable status text for a Binect status code + */ +function getStatusText(statusCode: number): string { + switch (statusCode) { + case 1: return 'In preparation'; + case 2: return 'Ready to ship'; + case 3: return 'In production queue'; + case 4: return 'Printing'; + case 5: return 'Sent'; + case 6: return 'Canceled'; + case 7: return 'Has errors'; + default: return 'Unknown status'; + } +} diff --git a/src/utils/pdf-queue.ts b/src/utils/pdf-queue.ts index 0e2aa08..0606c31 100644 --- a/src/utils/pdf-queue.ts +++ b/src/utils/pdf-queue.ts @@ -9,16 +9,29 @@ import { DetectedPDF } from './pdf-detector'; const STORAGE_KEY = 'pdfQueue'; const MAX_ENTRIES = 50; -const UPLOADED_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const SENT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days const FAILED_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours -export type PDFStatus = 'pending' | 'uploading' | 'uploaded' | 'failed'; +export type PDFStatus = + | 'pending' // Not yet uploaded + | 'uploading' // Upload in progress + | 'failed' // Upload failed + | 'in_basket' // Uploaded, SHIPPABLE, awaiting order + | 'ordering' // Order in progress + | 'in_production' // PRODUCTION_QUEUE or PRINTING + | 'sent' // SENT - terminal + | 'canceled'; // CANCELED - terminal export interface PDFQueueEntry extends DetectedPDF { status: PDFStatus; - uploadedAt?: number; binectDocumentId?: number; + binectStatus?: number; // DocumentStatus code from Binect (1-7) + binectStatusText?: string; // Human-readable status from Binect + price?: number; // Price in euro cents + recipientAddress?: string; // Extracted recipient address errorMessage?: string; + uploadedAt?: number; + orderedAt?: number; } interface PDFQueueState { @@ -57,7 +70,9 @@ export async function addPDF(pdf: DetectedPDF): Promise { // Check for duplicate by URL const existing = state.entries.find(e => e.url === pdf.url); if (existing) { - if (existing.status === 'uploaded') { + // Skip if already uploaded (in basket, production, or completed) + const uploadedStatuses: PDFStatus[] = ['in_basket', 'ordering', 'in_production', 'sent', 'canceled']; + if (uploadedStatuses.includes(existing.status)) { console.log('[PDF Queue] PDF already uploaded, skipping:', pdf.filename); return null; } @@ -82,13 +97,25 @@ export async function addPDF(pdf: DetectedPDF): Promise { return entry; } +/** + * Metadata for status updates + */ +export interface PDFStatusMeta { + binectDocumentId?: number; + binectStatus?: number; + binectStatusText?: string; + price?: number; + recipientAddress?: string; + errorMessage?: string; +} + /** * Update the status of a PDF in the queue */ export async function updatePDFStatus( id: string, status: PDFStatus, - meta?: { binectDocumentId?: number; errorMessage?: string } + meta?: PDFStatusMeta ): Promise { const state = await loadQueue(); const entry = state.entries.find(e => e.id === id); @@ -100,15 +127,32 @@ export async function updatePDFStatus( entry.status = status; - if (status === 'uploaded') { - entry.uploadedAt = Date.now(); - if (meta?.binectDocumentId) { - entry.binectDocumentId = meta.binectDocumentId; - } + // Update Binect-specific fields + if (meta?.binectDocumentId !== undefined) { + entry.binectDocumentId = meta.binectDocumentId; + } + if (meta?.binectStatus !== undefined) { + entry.binectStatus = meta.binectStatus; + } + if (meta?.binectStatusText !== undefined) { + entry.binectStatusText = meta.binectStatusText; + } + if (meta?.price !== undefined) { + entry.price = meta.price; + } + if (meta?.recipientAddress !== undefined) { + entry.recipientAddress = meta.recipientAddress; + } + if (meta?.errorMessage !== undefined) { + entry.errorMessage = meta.errorMessage; } - if (status === 'failed' && meta?.errorMessage) { - entry.errorMessage = meta.errorMessage; + // Set timestamps based on status + if (status === 'in_basket' && !entry.uploadedAt) { + entry.uploadedAt = Date.now(); + } + if (status === 'in_production' && !entry.orderedAt) { + entry.orderedAt = Date.now(); } await saveQueue(state); @@ -133,19 +177,45 @@ export async function removePDF(id: string): Promise { } /** - * Get all pending and failed PDFs (for display in popup) + * Get all PDFs for display in popup (all non-terminal statuses + recent terminal) */ -export async function getPendingPDFs(): Promise { +export async function getAllPDFs(): Promise { const state = await loadQueue(); - return state.entries.filter(e => e.status === 'pending' || e.status === 'failed'); + return state.entries; } /** - * Get count of pending PDFs (for badge) + * Get PDFs that need user action (pending, failed, in_basket) + */ +export async function getActionablePDFs(): Promise { + const state = await loadQueue(); + return state.entries.filter(e => + e.status === 'pending' || + e.status === 'failed' || + e.status === 'in_basket' + ); +} + +/** + * Get count of PDFs needing action (for badge) + */ +export async function getActionableCount(): Promise { + const actionable = await getActionablePDFs(); + return actionable.length; +} + +/** + * Legacy: Get pending PDFs (for backward compatibility) + */ +export async function getPendingPDFs(): Promise { + return getActionablePDFs(); +} + +/** + * Legacy: Get pending count (for backward compatibility) */ export async function getPendingCount(): Promise { - const pending = await getPendingPDFs(); - return pending.length; + return getActionableCount(); } /** @@ -157,15 +227,21 @@ export async function cleanupOldEntries(): Promise { const initialCount = state.entries.length; state.entries = state.entries.filter(entry => { - // Always keep pending entries - if (entry.status === 'pending' || entry.status === 'uploading') { + // Always keep active entries + if ( + entry.status === 'pending' || + entry.status === 'uploading' || + entry.status === 'in_basket' || + entry.status === 'ordering' || + entry.status === 'in_production' + ) { return true; } - // Remove uploaded entries older than 7 days - if (entry.status === 'uploaded' && entry.uploadedAt) { - const age = now - entry.uploadedAt; - if (age > UPLOADED_MAX_AGE_MS) { + // Remove sent/canceled entries older than 7 days + if (entry.status === 'sent' || entry.status === 'canceled') { + const age = now - (entry.orderedAt || entry.uploadedAt || entry.timestamp); + if (age > SENT_MAX_AGE_MS) { return false; } } @@ -188,36 +264,30 @@ export async function cleanupOldEntries(): Promise { } /** - * Enforce maximum entries by removing oldest uploaded/failed entries + * Enforce maximum entries by removing oldest terminal entries */ async function enforceMaxEntries(state: PDFQueueState): Promise { + const terminalStatuses: PDFStatus[] = ['sent', 'canceled', 'failed']; + while (state.entries.length > MAX_ENTRIES) { - // Find oldest uploaded entry let removeIndex = -1; let oldestTime = Infinity; + // Find oldest terminal entry (sent, canceled, failed) for (let i = state.entries.length - 1; i >= 0; i--) { const entry = state.entries[i]; - if (entry.status === 'uploaded' && entry.uploadedAt && entry.uploadedAt < oldestTime) { - oldestTime = entry.uploadedAt; - removeIndex = i; - } - } - - // If no uploaded entries, find oldest failed - if (removeIndex === -1) { - for (let i = state.entries.length - 1; i >= 0; i--) { - const entry = state.entries[i]; - if (entry.status === 'failed' && entry.timestamp < oldestTime) { - oldestTime = entry.timestamp; + if (terminalStatuses.includes(entry.status)) { + const entryTime = entry.orderedAt || entry.uploadedAt || entry.timestamp; + if (entryTime < oldestTime) { + oldestTime = entryTime; removeIndex = i; } } } - // If still nothing, we can't remove any more (all pending) + // If no terminal entries, we can't remove any more if (removeIndex === -1) { - console.warn('[PDF Queue] Max entries reached, but all are pending'); + console.warn('[PDF Queue] Max entries reached, but all are active'); break; }