// email-modal.js — ES Module // Modal to review and send invoice emails via AWS SES // With Stripe Payment Link integration import { showSpinner, hideSpinner, formatDate } from '../utils/helpers.js'; let currentInvoice = null; let quillInstance = null; // ============================================================ // DOM & Render // ============================================================ function ensureModalElement() { let modal = document.getElementById('email-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'email-modal'; modal.className = 'modal fixed inset-0 bg-black bg-opacity-50 z-50 justify-center items-start pt-10 overflow-y-auto hidden'; document.body.appendChild(modal); } } function getEmailModalTitle(invoice, isOverdue) { if (isOverdue) return `⏰ Send Reminder for Invoice #${invoice.invoice_number || invoice.id}`; if (invoice.email_status === 'sent') return `🔁 Resend Invoice #${invoice.invoice_number || invoice.id}`; return `📤 Send Invoice #${invoice.invoice_number || invoice.id}`; } function parseLocalDate(dateValue) { if (!dateValue) return null; if (dateValue instanceof Date) { return new Date(dateValue.getFullYear(), dateValue.getMonth(), dateValue.getDate()); } const dateStr = String(dateValue).split('T')[0]; const parts = dateStr.split('-').map(Number); if (parts.length === 3 && parts.every(Number.isFinite)) { return new Date(parts[0], parts[1] - 1, parts[2]); } const d = new Date(dateValue); return Number.isNaN(d.getTime()) ? null : d; } function getFirstSentDate(invoice) { const sentDates = Array.isArray(invoice.sent_dates) ? invoice.sent_dates.filter(Boolean) : []; if (sentDates.length === 0) { return null; } const sortedDates = sentDates .map(d => String(d).split('T')[0]) .sort(); return sortedDates[0]; } function getTermDays(invoice) { const terms = String(invoice.terms || '').toLowerCase(); if (terms.includes('due on receipt') || terms.includes('upon receipt')) { return 0; } const match = terms.match(/net\s*(\d+)/i); if (match) { return parseInt(match[1], 10); } return 30; } function addDays(dateValue, days) { const d = parseLocalDate(dateValue); if (!d) return null; d.setDate(d.getDate() + days); return d; } function getEffectiveDueDate(invoice) { const firstSentDate = getFirstSentDate(invoice); if (!firstSentDate) { return null; } return addDays(firstSentDate, getTermDays(invoice)); } function getOverdueDays(invoice) { const dueDate = getEffectiveDueDate(invoice); if (!dueDate) return 0; const today = new Date(); today.setHours(0, 0, 0, 0); dueDate.setHours(0, 0, 0, 0); return Math.max(0, Math.floor((today - dueDate) / 86400000)); } function isInvoiceOverdue(invoice) { const dueDate = getEffectiveDueDate(invoice); if (!dueDate) return false; const today = new Date(); today.setHours(0, 0, 0, 0); dueDate.setHours(0, 0, 0, 0); return !invoice.paid_date && dueDate < today; } function renderModalContent() { const modal = document.getElementById('email-modal'); if (!modal) return; const defaultEmail = currentInvoice.email || ''; const existingStripeUrl = currentInvoice.stripe_payment_link_url || ''; const stripeStatus = currentInvoice.stripe_payment_status || ''; // Detect overdue: unpaid + older than 30 days const isOverdue = isInvoiceOverdue(currentInvoice); const daysOverdue = getOverdueDays(currentInvoice); const modalTitle = getEmailModalTitle(currentInvoice, isOverdue); // Status indicator for existing link let stripeBadgeHtml = ''; if (existingStripeUrl && stripeStatus === 'paid') { stripeBadgeHtml = 'Paid'; } else if (existingStripeUrl && stripeStatus === 'processing') { stripeBadgeHtml = 'Processing'; } else if (existingStripeUrl) { stripeBadgeHtml = 'Active'; } modal.innerHTML = `

${modalTitle}

${renderRecipientSelector(currentInvoice)}
📎
Invoice_${currentInvoice.invoice_number || currentInvoice.id}.pdf will be generated and attached automatically.
`; // Initialize Quill const editorDiv = document.getElementById('email-message-editor'); quillInstance = new Quill(editorDiv, { theme: 'snow', modules: { toolbar: [ ['bold', 'italic', 'underline'], [{ 'list': 'ordered'}, { 'list': 'bullet' }], ['clean'] ] } }); // Variablen für den Text aufbereiten const invoiceNum = currentInvoice.invoice_number || currentInvoice.id; const totalDue = parseFloat(currentInvoice.balance ?? currentInvoice.total).toFixed(2); const customerName = currentInvoice.customer_name || 'Valued Customer'; // Datum formatieren let dueDateStr = 'Upon Receipt'; if (currentInvoice.due_date) { const d = new Date(currentInvoice.due_date); dueDateStr = d.toLocaleDateString('en-US', { timeZone: 'UTC' }); } // Dynamischer Text für die Fälligkeit let paymentText = ''; if (currentInvoice.terms && currentInvoice.terms.toLowerCase().includes('receipt')) { paymentText = 'Our terms are Net 14.'; } else if (dueDateStr !== 'Upon Receipt') { paymentText = `payable by ${dueDateStr}.`; } else { paymentText = 'Our terms are Net 14.'; } let defaultHtml = ''; if (isOverdue) { // Reminder / Overdue template defaultHtml = `

Dear ${customerName},

We hope this message finds you well. Our records indicate that invoice #${invoiceNum} in the amount of $${totalDue}, dated ${formatDate(currentInvoice.invoice_date)}, remains unpaid.

This invoice is now ${daysOverdue} days overdue. We kindly request prompt payment at your earliest convenience.

For your convenience, you can pay securely online using the payment link included below. We accept both Credit Card and ACH bank transfer.

If payment has already been sent, please disregard this notice. Should you have any questions or need to discuss payment arrangements, please do not hesitate to reply to this email.

Thank you for your attention to this matter. We value your business and look forward to continuing our partnership.

Best regards,

Claudia Knuth

Bay Area Affiliates, Inc.

accounting@bayarea-cc.com

`; } else { // Standard template defaultHtml = `

Dear ${customerName},

Attached is invoice #${invoiceNum} for service performed at your location. The total amount due is $${totalDue}. ${paymentText}

Please pay at your earliest convenience. We appreciate your continued business.

If you have any questions about the invoice, feel free to reply to this email.

Best regards,

Claudia Knuth

Bay Area Affiliates, Inc.

accounting@bayarea-cc.com

`; } quillInstance.root.innerHTML = defaultHtml; // Initiale Recipient-Summary updateRecipientSummary(); // Listener für das Custom-Field const customEl = document.getElementById('email-recipient-custom'); if (customEl) { customEl.addEventListener('input', updateRecipientSummary); } // Bind Submit Handler document.getElementById('email-send-form').addEventListener('submit', submitEmail); } // ============================================================ // Stripe Payment Link Generation // ============================================================ async function generateStripeLink() { const btn = document.getElementById('stripe-generate-btn'); const input = document.getElementById('email-stripe-link'); const info = document.getElementById('stripe-link-info'); const originalBtnText = btn.innerHTML; btn.innerHTML = '⏳ Creating...'; btn.disabled = true; try { const response = await fetch(`/api/invoices/${currentInvoice.id}/create-payment-link`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const result = await response.json(); if (response.ok) { input.value = result.paymentLinkUrl; currentInvoice.stripe_payment_link_url = result.paymentLinkUrl; currentInvoice.stripe_payment_link_id = result.paymentLinkId; currentInvoice.stripe_payment_status = 'pending'; btn.innerHTML = '♻️ Regenerate'; info.innerHTML = `✅ Payment link created for $${result.amount.toFixed(2)}. Will be included in the email.`; info.classList.remove('text-gray-400'); info.classList.add('text-green-600'); } else { info.textContent = `❌ ${result.error}`; info.classList.remove('text-gray-400'); info.classList.add('text-red-500'); } } catch (e) { console.error('Stripe link generation error:', e); info.textContent = '❌ Network error creating payment link.'; info.classList.remove('text-gray-400'); info.classList.add('text-red-500'); } finally { btn.disabled = false; if (btn.innerHTML === '⏳ Creating...') { btn.innerHTML = originalBtnText; } } } // ============================================================ // Logic & API // ============================================================ export async function openEmailModal(invoiceId) { ensureModalElement(); if (typeof showSpinner === 'function') showSpinner('Loading invoice data...'); try { const res = await fetch(`/api/invoices/${invoiceId}`); const data = await res.json(); if (!data.invoice) throw new Error('Invoice not found'); currentInvoice = data.invoice; renderModalContent(); document.getElementById('email-modal').classList.remove('hidden'); document.getElementById('email-modal').classList.add('flex'); } catch (e) { console.error('Error loading invoice for email:', e); alert('Could not load invoice details.'); } finally { if (typeof hideSpinner === 'function') hideSpinner(); } } export function closeEmailModal() { const modal = document.getElementById('email-modal'); if (modal) { modal.classList.add('hidden'); modal.classList.remove('flex'); } currentInvoice = null; quillInstance = null; } async function submitEmail(e) { e.preventDefault(); const recipients = getSelectedRecipients(); const customText = quillInstance.root.innerHTML; if (recipients.length === 0) { alert('Please select or enter at least one recipient email.'); return; } // Confirm bei Mehrfach-Empfängern, damit der User sieht, an wen es geht if (recipients.length > 1) { const ok = confirm(`Send invoice to ${recipients.length} recipients?\n\n${recipients.join('\n')}`); if (!ok) return; } const submitBtn = document.getElementById('email-submit-btn'); submitBtn.innerHTML = '⏳ Sending...'; submitBtn.disabled = true; if (typeof showSpinner === 'function') showSpinner('Generating PDF and sending email...'); try { const response = await fetch(`/api/invoices/${currentInvoice.id}/send-email`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recipientEmails: recipients, customText }) }); const result = await response.json(); if (response.ok) { const sentTo = (result.recipients || recipients).join(', '); alert(`✅ Invoice sent to: ${sentTo}`); closeEmailModal(); if (window.invoiceView) window.invoiceView.loadInvoices(); } else { alert(`❌ Error: ${result.error}`); } } catch (e) { console.error('Send email error:', e); alert('Network error while sending email.'); } finally { submitBtn.innerHTML = 'Send via AWS SES'; submitBtn.disabled = false; if (typeof hideSpinner === 'function') hideSpinner(); } } function renderRecipientSelector(invoice) { const primary = (invoice.email || '').trim(); const secondary = (invoice.secondary_email || '').trim(); const hasPrimary = !!primary; const hasSecondary = !!secondary; // Default-Auswahl: // - "Both" wenn beide vorhanden // - "Primary" wenn nur primary // - "Secondary" wenn nur secondary // - "Custom" wenn keine let defaultMode; if (hasPrimary && hasSecondary) defaultMode = 'both'; else if (hasPrimary) defaultMode = 'primary'; else if (hasSecondary) defaultMode = 'secondary'; else defaultMode = 'custom'; // Falls weder primary noch secondary — direkt eine Warnung let warningHtml = ''; if (!hasPrimary && !hasSecondary) { warningHtml = `

⚠️ This customer has no email address on file. Please enter one below.

`; } return `

${warningHtml}
`; } function escapeAttr(s) { return String(s || '') .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); } /** * Liefert die aktuell ausgewählten Empfänger als Array. * Wird beim Submit aufgerufen. */ function getSelectedRecipients() { const modeEl = document.getElementById('email-recipient-mode'); const customEl = document.getElementById('email-recipient-custom'); if (!modeEl) return []; const mode = modeEl.value; const primary = (currentInvoice.email || '').trim(); const secondary = (currentInvoice.secondary_email || '').trim(); if (mode === 'primary') return primary ? [primary] : []; if (mode === 'secondary') return secondary ? [secondary] : []; if (mode === 'both') { return [primary, secondary].filter(Boolean); } if (mode === 'custom') { return (customEl?.value || '') .split(',') .map(s => s.trim()) .filter(Boolean); } return []; } /** * Wird vom