Add document lifecycle tracking with order/production status

- Extended PDFStatus with full lifecycle: pending → uploading → in_basket →
  ordering → in_production → sent/canceled
- Added shipDocument() and getDocumentStatus() API methods
- Grouped UI sections: Ready to Upload, In Basket, In Production, Completed
- Order button for documents in basket to place production order
- Refresh button to check current status from Binect server
- Display price and recipient address for uploaded documents
- Status icons and color-coded indicators for each state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 10:17:28 +01:00
parent 724940ebf7
commit 3a48d4f497
5 changed files with 815 additions and 78 deletions

View File

@@ -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<DocumentStatusInfo> {
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<DocumentStatusInfo> {
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';
}
}

View File

@@ -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<PDFQueueEntry | null> {
// 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<PDFQueueEntry | null> {
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<void> {
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<void> {
}
/**
* 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<PDFQueueEntry[]> {
export async function getAllPDFs(): Promise<PDFQueueEntry[]> {
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<PDFQueueEntry[]> {
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<number> {
const actionable = await getActionablePDFs();
return actionable.length;
}
/**
* Legacy: Get pending PDFs (for backward compatibility)
*/
export async function getPendingPDFs(): Promise<PDFQueueEntry[]> {
return getActionablePDFs();
}
/**
* Legacy: Get pending count (for backward compatibility)
*/
export async function getPendingCount(): Promise<number> {
const pending = await getPendingPDFs();
return pending.length;
return getActionableCount();
}
/**
@@ -157,15 +227,21 @@ export async function cleanupOldEntries(): Promise<void> {
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<void> {
}
/**
* Enforce maximum entries by removing oldest uploaded/failed entries
* Enforce maximum entries by removing oldest terminal entries
*/
async function enforceMaxEntries(state: PDFQueueState): Promise<void> {
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;
}