diff --git a/assets/css/woodoo.css b/assets/css/woodoo.css index 84a5ff8..300b097 100644 --- a/assets/css/woodoo.css +++ b/assets/css/woodoo.css @@ -72,9 +72,9 @@ } .woodoo-table { width: 100%; - min-width: 820px; /* never compress below this — scroll horizontally instead */ + min-width: 700px; /* never compress below this — scroll horizontally instead */ border-collapse: collapse; - font-size: .875rem; + font-size: .8rem; /* No table-layout:fixed — let the browser size columns to content */ } .woodoo-table th, @@ -92,13 +92,17 @@ white-space: nowrap; } /* Force key invoice columns to never wrap */ -.woodoo-invoices-table .col-number { white-space: nowrap; font-weight: 600; min-width: 160px; } -.woodoo-invoices-table .col-date { white-space: nowrap; min-width: 90px; } -.woodoo-invoices-table .col-due { white-space: nowrap; min-width: 110px; } -.woodoo-invoices-table .col-amount { white-space: nowrap; min-width: 100px; text-align: right; } -.woodoo-invoices-table .col-balance { white-space: nowrap; min-width: 120px; text-align: right; } -.woodoo-invoices-table .col-status { white-space: nowrap; min-width: 90px; } -.woodoo-invoices-table .col-download{ min-width: 60px; text-align: center; } +.woodoo-invoices-table .col-number { white-space: nowrap; font-weight: 600; min-width: 160px; } +.woodoo-invoices-table .col-date { white-space: nowrap; min-width: 90px; } +.woodoo-invoices-table .col-due { white-space: nowrap; min-width: 110px; } +.woodoo-invoices-table .col-amount { white-space: nowrap; min-width: 100px; text-align: right; } +.woodoo-invoices-table .col-status { white-space: nowrap; min-width: 90px; } +.woodoo-invoices-table .col-action { min-width: 90px; text-align: center; } + +/* Inline feedback rows (send invoice email) */ +.woodoo-inline-msg td { font-size: .8rem; padding: 6px 16px; } +.woodoo-inline-msg--ok { color: #065f46; background: #ecfdf5; } +.woodoo-inline-msg--err { color: #991b1b; background: #fef2f2; } /* Utility: never wrap content in a cell */ .woodoo-nowrap { white-space: nowrap; } diff --git a/assets/js/woodoo-frontend.js b/assets/js/woodoo-frontend.js index 3554c8e..e13d3e9 100644 --- a/assets/js/woodoo-frontend.js +++ b/assets/js/woodoo-frontend.js @@ -1,36 +1,112 @@ -/* WooDoo Frontend JS – Calendar Booking */ +/* WooDoo Frontend JS */ ( function () { 'use strict'; + // ══════════════════════════════════════════════════════════════════════ + // INVOICE EMAIL + // ══════════════════════════════════════════════════════════════════════ + const inv = window.WooDooInvoices; + if ( inv ) { + document.querySelectorAll( '.woodoo-send-invoice' ).forEach( function ( btn ) { + btn.addEventListener( 'click', function () { + const invoiceId = this.dataset.id; + const $btn = this; + const row = $btn.closest( 'tr' ); + + // Remove any previous feedback in this row + const prev = row.querySelector( '.woodoo-inline-msg' ); + if ( prev ) prev.remove(); + + $btn.disabled = true; + $btn.textContent = 'Enviando…'; + + const body = new URLSearchParams( { + action : 'woodoo_send_invoice_email', + nonce : inv.nonce, + invoice_id : invoiceId, + } ); + + fetch( inv.ajax_url, { method: 'POST', body } ) + .then( r => r.json() ) + .then( function ( res ) { + if ( res.success ) { + $btn.textContent = '✓ Enviado'; + $btn.style.color = '#065f46'; + // Show success message below the row + const td = document.createElement( 'td' ); + td.colSpan = 6; + td.className = 'woodoo-inline-msg woodoo-inline-msg--ok'; + td.textContent = res.data.message; + const msgRow = document.createElement( 'tr' ); + msgRow.className = 'woodoo-inline-msg'; + msgRow.appendChild( td ); + row.insertAdjacentElement( 'afterend', msgRow ); + // Auto-hide after 6s + setTimeout( function () { + msgRow.remove(); + $btn.disabled = false; + $btn.textContent = '✉ Reenviar'; + $btn.style.color = ''; + }, 6000 ); + } else { + showInvoiceError( row, res.data || 'Error desconocido.' ); + $btn.disabled = false; + $btn.textContent = '✉ Reenviar'; + } + } ) + .catch( function () { + showInvoiceError( row, 'Error de conexión. Inténtalo de nuevo.' ); + $btn.disabled = false; + $btn.textContent = '✉ Reenviar'; + } ); + } ); + } ); + + function showInvoiceError( row, msg ) { + const prev = row.querySelector( '.woodoo-inline-msg' ); + if ( prev ) prev.remove(); + const td = document.createElement( 'td' ); + td.colSpan = 6; + td.className = 'woodoo-inline-msg woodoo-inline-msg--err'; + td.textContent = msg; + const msgRow = document.createElement( 'tr' ); + msgRow.className = 'woodoo-inline-msg'; + msgRow.appendChild( td ); + row.insertAdjacentElement( 'afterend', msgRow ); + } + } + + // ══════════════════════════════════════════════════════════════════════ + // CALENDAR BOOKING + // ══════════════════════════════════════════════════════════════════════ const cfg = window.WooDooCalendar; - if ( ! cfg ) return; // not on calendar page + if ( ! cfg ) return; - const i18n = cfg.i18n; - const ajaxUrl = cfg.ajax_url; - const nonce = cfg.nonce; + const i18n = cfg.i18n; + const ajaxUrl = cfg.ajax_url; + const nonce = cfg.nonce; - // ── Element refs ──────────────────────────────────────────────────── - const dateInput = document.getElementById( 'woodoo-booking-date' ); - const slotsWrap = document.getElementById( 'woodoo-slots-wrap' ); - const slotsGrid = document.getElementById( 'woodoo-slots-grid' ); - const confirmBlock = document.getElementById( 'woodoo-booking-confirm' ); - const bookBtn = document.getElementById( 'woodoo-book-btn' ); - const notesArea = document.getElementById( 'woodoo-booking-notes' ); - const slotLabel = document.getElementById( 'woodoo-selected-slot-label' ); - const successMsg = document.getElementById( 'woodoo-booking-success' ); - const errorMsg = document.getElementById( 'woodoo-booking-error' ); + const dateInput = document.getElementById( 'woodoo-booking-date' ); + const slotsWrap = document.getElementById( 'woodoo-slots-wrap' ); + const slotsGrid = document.getElementById( 'woodoo-slots-grid' ); + const confirmBlock = document.getElementById( 'woodoo-booking-confirm' ); + const bookBtn = document.getElementById( 'woodoo-book-btn' ); + const notesArea = document.getElementById( 'woodoo-booking-notes' ); + const slotLabel = document.getElementById( 'woodoo-selected-slot-label' ); + const successMsg = document.getElementById( 'woodoo-booking-success' ); + const errorMsg = document.getElementById( 'woodoo-booking-error' ); let selectedStart = null; let selectedEnd = null; - // ── Date change → load slots ──────────────────────────────────────── + // ── Date change → load slots ───────────────────────────────────────── if ( dateInput ) { dateInput.addEventListener( 'change', function () { const date = this.value; if ( ! date ) return; slotsGrid.innerHTML = '' + esc( i18n.loading ) + ''; - slotsWrap.style.display = 'block'; + slotsWrap.style.display = 'block'; confirmBlock.style.display = 'none'; successMsg.style.display = 'none'; errorMsg.style.display = 'none'; @@ -43,19 +119,17 @@ slotsGrid.innerHTML = '' + esc( i18n.error ) + ''; return; } - const slots = res.data; if ( ! slots.length ) { slotsGrid.innerHTML = '' + esc( i18n.no_slots ) + ''; confirmBlock.style.display = 'none'; return; } - slotsGrid.innerHTML = ''; slots.forEach( slot => { const btn = document.createElement( 'button' ); - btn.type = 'button'; - btn.className = 'woodoo-slot'; + btn.type = 'button'; + btn.className = 'woodoo-slot'; btn.textContent = formatTime( slot.start ) + ' – ' + formatTime( slot.end ); btn.dataset.start = slot.start; btn.dataset.end = slot.end; @@ -63,27 +137,21 @@ slotsGrid.appendChild( btn ); } ); } ) - .catch( () => { - slotsGrid.innerHTML = '' + esc( i18n.error ) + ''; - } ); + .catch( () => { slotsGrid.innerHTML = '' + esc( i18n.error ) + ''; } ); } ); } - // ── Slot selection ─────────────────────────────────────────────────── function onSlotClick( e ) { - document.querySelectorAll( '.woodoo-slot' ) - .forEach( b => b.classList.remove( 'selected' ) ); - + document.querySelectorAll( '.woodoo-slot' ).forEach( b => b.classList.remove( 'selected' ) ); const btn = e.currentTarget; btn.classList.add( 'selected' ); - selectedStart = btn.dataset.start; selectedEnd = btn.dataset.end; slotLabel.textContent = - new Date( selectedStart ).toLocaleDateString( undefined, { + new Date( selectedStart ).toLocaleDateString( 'es-ES', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', - } ) + ' ' + formatTime( selectedStart ) + ' – ' + formatTime( selectedEnd ); + } ) + ', ' + formatTime( selectedStart ) + ' – ' + formatTime( selectedEnd ); confirmBlock.style.display = 'block'; bookBtn.disabled = false; @@ -95,8 +163,7 @@ if ( bookBtn ) { bookBtn.addEventListener( 'click', function () { if ( ! selectedStart || ! selectedEnd ) return; - - bookBtn.disabled = true; + bookBtn.disabled = true; bookBtn.textContent = i18n.booking; errorMsg.style.display = 'none'; @@ -107,24 +174,22 @@ } ) .then( res => { if ( res.success ) { - successMsg.textContent = res.data.message; + successMsg.textContent = res.data.message; successMsg.style.display = 'block'; confirmBlock.style.display = 'none'; slotsWrap.style.display = 'none'; - dateInput.value = ''; + dateInput.value = ''; selectedStart = selectedEnd = null; - - // Re-fetch meetings after booking refreshMeetings(); } else { - showError( res.data || i18n.error ); - bookBtn.disabled = false; + showCalError( res.data || i18n.error ); + bookBtn.disabled = false; bookBtn.textContent = i18n.book_btn; } } ) .catch( () => { - showError( i18n.error ); - bookBtn.disabled = false; + showCalError( i18n.error ); + bookBtn.disabled = false; bookBtn.textContent = i18n.book_btn; } ); } ); @@ -134,28 +199,26 @@ document.querySelectorAll( '.woodoo-cancel-meeting' ).forEach( btn => { btn.addEventListener( 'click', function () { if ( ! confirm( i18n.cancel_confirm ) ) return; - const eventId = this.dataset.eventId; const card = this.closest( '.woodoo-meeting-card' ); - - this.disabled = true; + this.disabled = true; this.textContent = i18n.cancelling; post( 'woodoo_cancel_meeting', { event_id: eventId } ) .then( res => { if ( res.success ) { - card.style.opacity = '0.4'; + card.style.opacity = '0.4'; card.style.transition = 'opacity .3s'; setTimeout( () => card.remove(), 350 ); } else { alert( res.data || i18n.error ); - this.disabled = false; - this.textContent = 'Cancel'; + this.disabled = false; + this.textContent = 'Cancelar'; } } ) .catch( () => { - this.disabled = false; - this.textContent = 'Cancel'; + this.disabled = false; + this.textContent = 'Cancelar'; } ); } ); } ); @@ -164,84 +227,59 @@ function refreshMeetings() { const listEl = document.getElementById( 'woodoo-meetings-list' ); if ( ! listEl ) return; - post( 'woodoo_get_meetings', {} ) .then( res => { - if ( ! res.success ) return; - const meetings = res.data; - if ( ! meetings.length ) { - listEl.innerHTML = '

' + esc( 'No upcoming meetings scheduled.' ) + '

'; + if ( ! res.success || ! res.data.length ) { + listEl.innerHTML = '

No tienes reuniones programadas.

'; return; } - + const meses = [ '', 'ene', 'feb', 'mar', 'abr', 'may', 'jun', + 'jul', 'ago', 'sep', 'oct', 'nov', 'dic' ]; let html = '
'; - meetings.forEach( ev => { + res.data.forEach( ev => { const start = new Date( ev.start.replace( ' ', 'T' ) ); const end = new Date( ev.stop.replace( ' ', 'T' ) ); html += '
' + '
' + - '' + pad( start.getDate() ) + '' + - '' + monthShort( start ) + '' + + '' + pad( start.getDate() ) + '' + + '' + ( meses[ start.getMonth() + 1 ] || '' ) + '' + '
' + '
' + '' + esc( ev.name ) + '' + - '' + - timeStr( start ) + ' – ' + timeStr( end ) + - '' + + '' + timeStr( start ) + ' – ' + timeStr( end ) + '' + ( ev.videocall_location - ? 'Join Video Call' + ? 'Unirse a la videollamada' : '' ) + '
' + '
' + - '' + + '' + '
' + '
'; } ); html += '
'; listEl.innerHTML = html; - - // Re-bind cancel buttons - listEl.querySelectorAll( '.woodoo-cancel-meeting' ).forEach( btn => { - btn.addEventListener( 'click', function () { - // same handler as above – re-use via custom event trick - btn.dispatchEvent( new Event( 'woodoo:cancel' ) ); - } ); - } ); } ); } // ── Helpers ────────────────────────────────────────────────────────── function post( action, data ) { const body = new URLSearchParams( Object.assign( { action, nonce }, data ) ); - return fetch( ajaxUrl, { method: 'POST', body } ) - .then( r => r.json() ); + return fetch( ajaxUrl, { method: 'POST', body } ).then( r => r.json() ); } - function formatTime( datetimeStr ) { - return datetimeStr.slice( 11, 16 ); - } - - function timeStr( d ) { - return pad( d.getHours() ) + ':' + pad( d.getMinutes() ); - } - - function pad( n ) { return String( n ).padStart( 2, '0' ); } - - function monthShort( d ) { - return d.toLocaleDateString( undefined, { month: 'short' } ); - } + function formatTime( dt ) { return dt.slice( 11, 16 ); } + function timeStr( d ) { return pad( d.getHours() ) + ':' + pad( d.getMinutes() ); } + function pad( n ) { return String( n ).padStart( 2, '0' ); } function esc( str ) { return String( str ) - .replace( /&/g, '&' ) - .replace( //g, '>' ) - .replace( /"/g, '"' ); + .replace( /&/g, '&' ).replace( //g, '>' ).replace( /"/g, '"' ); } - function showError( msg ) { - errorMsg.textContent = msg; + function showCalError( msg ) { + errorMsg.textContent = msg; errorMsg.style.display = 'block'; } diff --git a/includes/class-woodoo-invoices.php b/includes/class-woodoo-invoices.php index d8d45d0..d8b46d4 100644 --- a/includes/class-woodoo-invoices.php +++ b/includes/class-woodoo-invoices.php @@ -15,9 +15,8 @@ class WooDoo_Invoices { add_action( 'woocommerce_account_' . self::ENDPOINT . '_endpoint', [ __CLASS__, 'render' ] ); add_filter( 'woocommerce_get_query_vars', [ __CLASS__, 'add_query_var' ] ); - // PDF download endpoint - add_action( 'wp_ajax_woodoo_invoice_pdf', [ __CLASS__, 'ajax_download_pdf' ] ); - add_action( 'wp_ajax_nopriv_woodoo_invoice_pdf', [ __CLASS__, 'ajax_download_pdf' ] ); + // Send invoice by email (AJAX, logged-in users only) + add_action( 'wp_ajax_woodoo_send_invoice_email', [ __CLASS__, 'ajax_send_invoice_email' ] ); } public static function add_query_var( array $vars ): array { @@ -93,78 +92,97 @@ class WooDoo_Invoices { include WOODOO_DIR . 'templates/myaccount-invoices.php'; } - // ── PDF Proxy ───────────────────────────────────────────────────────── + // ── Send Invoice by Email ───────────────────────────────────────────── /** - * Download an invoice PDF from Odoo and serve it to the logged-in user. - * ?action=woodoo_invoice_pdf&invoice_id=123&nonce=... + * Ask Odoo to resend the invoice email to the customer. + * Uses mail.template.send_mail() with the standard invoice email template. */ - public static function ajax_download_pdf(): void { - $nonce = sanitize_text_field( wp_unslash( $_GET['nonce'] ?? '' ) ); - if ( ! wp_verify_nonce( $nonce, 'woodoo_invoice_pdf' ) ) { - wp_die( esc_html__( 'Security check failed.', 'woodoo' ) ); - } + public static function ajax_send_invoice_email(): void { + check_ajax_referer( 'woodoo_invoice_email', 'nonce' ); if ( ! is_user_logged_in() ) { - wp_die( esc_html__( 'Please log in.', 'woodoo' ) ); + wp_send_json_error( 'No autenticado.', 401 ); } - $invoice_id = absint( $_GET['invoice_id'] ?? 0 ); - if ( ! $invoice_id ) wp_die( 'Invalid invoice ID.' ); + $invoice_id = absint( $_POST['invoice_id'] ?? 0 ); + if ( ! $invoice_id ) { + wp_send_json_error( 'ID de factura no válido.' ); + } - // Verify the invoice belongs to this user $user_id = get_current_user_id(); $partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true ); - if ( ! $partner_id ) wp_die( esc_html__( 'Account not linked.', 'woodoo' ) ); + if ( ! $partner_id ) { + wp_send_json_error( 'Tu cuenta no está vinculada a Odoo.' ); + } $api = woodoo_api(); - if ( ! $api ) wp_die( 'API not configured.' ); + if ( ! $api ) { + wp_send_json_error( 'Integración con Odoo no configurada.' ); + } + // Verify invoice belongs to this customer $invoices = $api->search( 'account.move', [ [ 'id', '=', $invoice_id ], [ 'partner_id', '=', $partner_id ] ], 1 ); - if ( empty( $invoices ) ) { - wp_die( esc_html__( 'Invoice not found.', 'woodoo' ) ); + wp_send_json_error( 'Factura no encontrada.' ); } - // Use the authenticated JSON-RPC connection to render the PDF. - // Odoo's /report/pdf/ HTTP endpoint only accepts session cookies, not API keys. - // Calling ir.actions.report.render_qweb_pdf() via execute_kw works with any - // valid authenticated user and returns the PDF content base64-encoded. + // Find the Odoo invoice email template ID (cached 24h) + $template_id = (int) get_transient( 'woodoo_invoice_tpl_id' ); + if ( ! $template_id ) { + $ref = $api->search_read( + 'ir.model.data', + [ + [ 'module', '=', 'account' ], + [ 'name', '=', 'email_template_edi_invoice' ], + ], + [ 'res_id' ], + 1 + ); + $template_id = ! empty( $ref ) ? (int) $ref[0]['res_id'] : 0; + + if ( ! $template_id ) { + // Fallback: search templates by model + $tpl = $api->search_read( + 'mail.template', + [ [ 'model', '=', 'account.move' ] ], + [ 'id', 'name' ], + 1 + ); + $template_id = ! empty( $tpl ) ? (int) $tpl[0]['id'] : 0; + } + + if ( $template_id ) { + set_transient( 'woodoo_invoice_tpl_id', $template_id, DAY_IN_SECONDS ); + } + } + + if ( ! $template_id ) { + wp_send_json_error( 'No se encontró la plantilla de correo de facturas en Odoo.' ); + } + + // Call mail.template.send_mail([template_id], invoice_id, force_send=True) + // First arg list [[template_id]] is the recordset selector in execute_kw, + // remaining positional args are passed to the method itself. $result = $api->execute_kw( - 'ir.actions.report', - 'render_qweb_pdf', - [ 'account.report_invoice', [ $invoice_id ] ] + 'mail.template', + 'send_mail', + [ [ $template_id ], $invoice_id ], + [ 'force_send' => true ] ); if ( is_wp_error( $result ) ) { - wp_die( 'Error de Odoo al generar el PDF: ' . esc_html( $result->get_error_message() ) ); + wp_send_json_error( 'Error de Odoo: ' . $result->get_error_message() ); } - // Result is [base64_pdf_string, 'pdf'] - $b64 = is_array( $result ) ? ( $result[0] ?? '' ) : $result; - if ( empty( $b64 ) ) { - wp_die( 'Odoo devolvió una respuesta vacía al generar el PDF.' ); - } - - $pdf_body = base64_decode( $b64, true ); - - if ( $pdf_body === false || substr( $pdf_body, 0, 4 ) !== '%PDF' ) { - wp_die( 'El contenido recibido de Odoo no es un PDF válido. Comprueba que el usuario de la API tenga permisos de impresión en Odoo.' ); - } - - header( 'Content-Type: application/pdf' ); - header( 'Content-Disposition: attachment; filename="factura-' . $invoice_id . '.pdf"' ); - header( 'Content-Length: ' . strlen( $pdf_body ) ); - header( 'Cache-Control: private' ); - - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - echo $pdf_body; - exit; + wp_send_json_success( [ + 'message' => 'La factura ha sido enviada a tu correo electrónico.', + ] ); } // ── Helpers ─────────────────────────────────────────────────────────── diff --git a/templates/myaccount-invoices.php b/templates/myaccount-invoices.php index d5b6312..c2ceb73 100644 --- a/templates/myaccount-invoices.php +++ b/templates/myaccount-invoices.php @@ -13,6 +13,10 @@ defined( 'ABSPATH' ) || exit; wp_enqueue_style( 'woodoo-frontend' ); wp_enqueue_script( 'woodoo-frontend' ); +wp_localize_script( 'woodoo-frontend', 'WooDooInvoices', [ + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'woodoo_invoice_email' ), +] ); ?>
@@ -30,10 +34,9 @@ wp_enqueue_script( 'woodoo-frontend' ); Nº Factura Fecha Vencimiento - Total - Saldo pendiente + Importe Estado - PDF + Envío @@ -43,11 +46,6 @@ wp_enqueue_script( 'woodoo-frontend' ); $pay_state = $inv['payment_state'] ?? 'not_paid'; $badge_class = WooDoo_Invoices::payment_state_class( $pay_state ); $badge_label = WooDoo_Invoices::payment_state_label( $pay_state ); - $pdf_url = add_query_arg( [ - 'action' => 'woodoo_invoice_pdf', - 'invoice_id' => $inv['id'], - 'nonce' => wp_create_nonce( 'woodoo_invoice_pdf' ), - ], admin_url( 'admin-ajax.php' ) ); ?> @@ -78,22 +76,18 @@ wp_enqueue_script( 'woodoo-frontend' ); - - - - - - ↓ PDF - + +