feat: invoice email resend, smaller table, column cleanup
Email resend (replaces broken PDF download): - New AJAX action woodoo_send_invoice_email - Finds Odoo invoice email template via ir.model.data (cached 24h) with fallback search on mail.template by model - Calls mail.template.send_mail([template_id], invoice_id, force_send=True) via authenticated JSON-RPC — triggers Odoo to email the invoice - Inline success/error row appears below the invoice row, auto-hides 6s - Button shows "Enviando…" spinner state, resets on failure Table columns: - Remove "Saldo pendiente" column (was amount_residual) - Rename "Total" → "Importe" - min-width reduced to 700px now that there are 6 columns - Font size reduced to .8rem for denser display JS restructured so invoice email handler always runs (no early exit), calendar handler only runs when WooDooCalendar data is present. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -72,9 +72,9 @@
|
|||||||
}
|
}
|
||||||
.woodoo-table {
|
.woodoo-table {
|
||||||
width: 100%;
|
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;
|
border-collapse: collapse;
|
||||||
font-size: .875rem;
|
font-size: .8rem;
|
||||||
/* No table-layout:fixed — let the browser size columns to content */
|
/* No table-layout:fixed — let the browser size columns to content */
|
||||||
}
|
}
|
||||||
.woodoo-table th,
|
.woodoo-table th,
|
||||||
@@ -92,13 +92,17 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
/* Force key invoice columns to never wrap */
|
/* 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-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-date { white-space: nowrap; min-width: 90px; }
|
||||||
.woodoo-invoices-table .col-due { white-space: nowrap; min-width: 110px; }
|
.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-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-status { white-space: nowrap; min-width: 90px; }
|
.woodoo-invoices-table .col-action { min-width: 90px; text-align: center; }
|
||||||
.woodoo-invoices-table .col-download{ min-width: 60px; 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 */
|
/* Utility: never wrap content in a cell */
|
||||||
.woodoo-nowrap { white-space: nowrap; }
|
.woodoo-nowrap { white-space: nowrap; }
|
||||||
|
|||||||
@@ -1,36 +1,112 @@
|
|||||||
/* WooDoo Frontend JS – Calendar Booking */
|
/* WooDoo Frontend JS */
|
||||||
( function () {
|
( function () {
|
||||||
'use strict';
|
'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;
|
const cfg = window.WooDooCalendar;
|
||||||
if ( ! cfg ) return; // not on calendar page
|
if ( ! cfg ) return;
|
||||||
|
|
||||||
const i18n = cfg.i18n;
|
const i18n = cfg.i18n;
|
||||||
const ajaxUrl = cfg.ajax_url;
|
const ajaxUrl = cfg.ajax_url;
|
||||||
const nonce = cfg.nonce;
|
const nonce = cfg.nonce;
|
||||||
|
|
||||||
// ── Element refs ────────────────────────────────────────────────────
|
const dateInput = document.getElementById( 'woodoo-booking-date' );
|
||||||
const dateInput = document.getElementById( 'woodoo-booking-date' );
|
const slotsWrap = document.getElementById( 'woodoo-slots-wrap' );
|
||||||
const slotsWrap = document.getElementById( 'woodoo-slots-wrap' );
|
const slotsGrid = document.getElementById( 'woodoo-slots-grid' );
|
||||||
const slotsGrid = document.getElementById( 'woodoo-slots-grid' );
|
const confirmBlock = document.getElementById( 'woodoo-booking-confirm' );
|
||||||
const confirmBlock = document.getElementById( 'woodoo-booking-confirm' );
|
const bookBtn = document.getElementById( 'woodoo-book-btn' );
|
||||||
const bookBtn = document.getElementById( 'woodoo-book-btn' );
|
const notesArea = document.getElementById( 'woodoo-booking-notes' );
|
||||||
const notesArea = document.getElementById( 'woodoo-booking-notes' );
|
const slotLabel = document.getElementById( 'woodoo-selected-slot-label' );
|
||||||
const slotLabel = document.getElementById( 'woodoo-selected-slot-label' );
|
const successMsg = document.getElementById( 'woodoo-booking-success' );
|
||||||
const successMsg = document.getElementById( 'woodoo-booking-success' );
|
const errorMsg = document.getElementById( 'woodoo-booking-error' );
|
||||||
const errorMsg = document.getElementById( 'woodoo-booking-error' );
|
|
||||||
|
|
||||||
let selectedStart = null;
|
let selectedStart = null;
|
||||||
let selectedEnd = null;
|
let selectedEnd = null;
|
||||||
|
|
||||||
// ── Date change → load slots ────────────────────────────────────────
|
// ── Date change → load slots ─────────────────────────────────────────
|
||||||
if ( dateInput ) {
|
if ( dateInput ) {
|
||||||
dateInput.addEventListener( 'change', function () {
|
dateInput.addEventListener( 'change', function () {
|
||||||
const date = this.value;
|
const date = this.value;
|
||||||
if ( ! date ) return;
|
if ( ! date ) return;
|
||||||
|
|
||||||
slotsGrid.innerHTML = '<em>' + esc( i18n.loading ) + '</em>';
|
slotsGrid.innerHTML = '<em>' + esc( i18n.loading ) + '</em>';
|
||||||
slotsWrap.style.display = 'block';
|
slotsWrap.style.display = 'block';
|
||||||
confirmBlock.style.display = 'none';
|
confirmBlock.style.display = 'none';
|
||||||
successMsg.style.display = 'none';
|
successMsg.style.display = 'none';
|
||||||
errorMsg.style.display = 'none';
|
errorMsg.style.display = 'none';
|
||||||
@@ -43,19 +119,17 @@
|
|||||||
slotsGrid.innerHTML = '<em>' + esc( i18n.error ) + '</em>';
|
slotsGrid.innerHTML = '<em>' + esc( i18n.error ) + '</em>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slots = res.data;
|
const slots = res.data;
|
||||||
if ( ! slots.length ) {
|
if ( ! slots.length ) {
|
||||||
slotsGrid.innerHTML = '<em>' + esc( i18n.no_slots ) + '</em>';
|
slotsGrid.innerHTML = '<em>' + esc( i18n.no_slots ) + '</em>';
|
||||||
confirmBlock.style.display = 'none';
|
confirmBlock.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
slotsGrid.innerHTML = '';
|
slotsGrid.innerHTML = '';
|
||||||
slots.forEach( slot => {
|
slots.forEach( slot => {
|
||||||
const btn = document.createElement( 'button' );
|
const btn = document.createElement( 'button' );
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.className = 'woodoo-slot';
|
btn.className = 'woodoo-slot';
|
||||||
btn.textContent = formatTime( slot.start ) + ' – ' + formatTime( slot.end );
|
btn.textContent = formatTime( slot.start ) + ' – ' + formatTime( slot.end );
|
||||||
btn.dataset.start = slot.start;
|
btn.dataset.start = slot.start;
|
||||||
btn.dataset.end = slot.end;
|
btn.dataset.end = slot.end;
|
||||||
@@ -63,27 +137,21 @@
|
|||||||
slotsGrid.appendChild( btn );
|
slotsGrid.appendChild( btn );
|
||||||
} );
|
} );
|
||||||
} )
|
} )
|
||||||
.catch( () => {
|
.catch( () => { slotsGrid.innerHTML = '<em>' + esc( i18n.error ) + '</em>'; } );
|
||||||
slotsGrid.innerHTML = '<em>' + esc( i18n.error ) + '</em>';
|
|
||||||
} );
|
|
||||||
} );
|
} );
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Slot selection ───────────────────────────────────────────────────
|
|
||||||
function onSlotClick( e ) {
|
function onSlotClick( e ) {
|
||||||
document.querySelectorAll( '.woodoo-slot' )
|
document.querySelectorAll( '.woodoo-slot' ).forEach( b => b.classList.remove( 'selected' ) );
|
||||||
.forEach( b => b.classList.remove( 'selected' ) );
|
|
||||||
|
|
||||||
const btn = e.currentTarget;
|
const btn = e.currentTarget;
|
||||||
btn.classList.add( 'selected' );
|
btn.classList.add( 'selected' );
|
||||||
|
|
||||||
selectedStart = btn.dataset.start;
|
selectedStart = btn.dataset.start;
|
||||||
selectedEnd = btn.dataset.end;
|
selectedEnd = btn.dataset.end;
|
||||||
|
|
||||||
slotLabel.textContent =
|
slotLabel.textContent =
|
||||||
new Date( selectedStart ).toLocaleDateString( undefined, {
|
new Date( selectedStart ).toLocaleDateString( 'es-ES', {
|
||||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||||
} ) + ' ' + formatTime( selectedStart ) + ' – ' + formatTime( selectedEnd );
|
} ) + ', ' + formatTime( selectedStart ) + ' – ' + formatTime( selectedEnd );
|
||||||
|
|
||||||
confirmBlock.style.display = 'block';
|
confirmBlock.style.display = 'block';
|
||||||
bookBtn.disabled = false;
|
bookBtn.disabled = false;
|
||||||
@@ -95,8 +163,7 @@
|
|||||||
if ( bookBtn ) {
|
if ( bookBtn ) {
|
||||||
bookBtn.addEventListener( 'click', function () {
|
bookBtn.addEventListener( 'click', function () {
|
||||||
if ( ! selectedStart || ! selectedEnd ) return;
|
if ( ! selectedStart || ! selectedEnd ) return;
|
||||||
|
bookBtn.disabled = true;
|
||||||
bookBtn.disabled = true;
|
|
||||||
bookBtn.textContent = i18n.booking;
|
bookBtn.textContent = i18n.booking;
|
||||||
errorMsg.style.display = 'none';
|
errorMsg.style.display = 'none';
|
||||||
|
|
||||||
@@ -107,24 +174,22 @@
|
|||||||
} )
|
} )
|
||||||
.then( res => {
|
.then( res => {
|
||||||
if ( res.success ) {
|
if ( res.success ) {
|
||||||
successMsg.textContent = res.data.message;
|
successMsg.textContent = res.data.message;
|
||||||
successMsg.style.display = 'block';
|
successMsg.style.display = 'block';
|
||||||
confirmBlock.style.display = 'none';
|
confirmBlock.style.display = 'none';
|
||||||
slotsWrap.style.display = 'none';
|
slotsWrap.style.display = 'none';
|
||||||
dateInput.value = '';
|
dateInput.value = '';
|
||||||
selectedStart = selectedEnd = null;
|
selectedStart = selectedEnd = null;
|
||||||
|
|
||||||
// Re-fetch meetings after booking
|
|
||||||
refreshMeetings();
|
refreshMeetings();
|
||||||
} else {
|
} else {
|
||||||
showError( res.data || i18n.error );
|
showCalError( res.data || i18n.error );
|
||||||
bookBtn.disabled = false;
|
bookBtn.disabled = false;
|
||||||
bookBtn.textContent = i18n.book_btn;
|
bookBtn.textContent = i18n.book_btn;
|
||||||
}
|
}
|
||||||
} )
|
} )
|
||||||
.catch( () => {
|
.catch( () => {
|
||||||
showError( i18n.error );
|
showCalError( i18n.error );
|
||||||
bookBtn.disabled = false;
|
bookBtn.disabled = false;
|
||||||
bookBtn.textContent = i18n.book_btn;
|
bookBtn.textContent = i18n.book_btn;
|
||||||
} );
|
} );
|
||||||
} );
|
} );
|
||||||
@@ -134,28 +199,26 @@
|
|||||||
document.querySelectorAll( '.woodoo-cancel-meeting' ).forEach( btn => {
|
document.querySelectorAll( '.woodoo-cancel-meeting' ).forEach( btn => {
|
||||||
btn.addEventListener( 'click', function () {
|
btn.addEventListener( 'click', function () {
|
||||||
if ( ! confirm( i18n.cancel_confirm ) ) return;
|
if ( ! confirm( i18n.cancel_confirm ) ) return;
|
||||||
|
|
||||||
const eventId = this.dataset.eventId;
|
const eventId = this.dataset.eventId;
|
||||||
const card = this.closest( '.woodoo-meeting-card' );
|
const card = this.closest( '.woodoo-meeting-card' );
|
||||||
|
this.disabled = true;
|
||||||
this.disabled = true;
|
|
||||||
this.textContent = i18n.cancelling;
|
this.textContent = i18n.cancelling;
|
||||||
|
|
||||||
post( 'woodoo_cancel_meeting', { event_id: eventId } )
|
post( 'woodoo_cancel_meeting', { event_id: eventId } )
|
||||||
.then( res => {
|
.then( res => {
|
||||||
if ( res.success ) {
|
if ( res.success ) {
|
||||||
card.style.opacity = '0.4';
|
card.style.opacity = '0.4';
|
||||||
card.style.transition = 'opacity .3s';
|
card.style.transition = 'opacity .3s';
|
||||||
setTimeout( () => card.remove(), 350 );
|
setTimeout( () => card.remove(), 350 );
|
||||||
} else {
|
} else {
|
||||||
alert( res.data || i18n.error );
|
alert( res.data || i18n.error );
|
||||||
this.disabled = false;
|
this.disabled = false;
|
||||||
this.textContent = 'Cancel';
|
this.textContent = 'Cancelar';
|
||||||
}
|
}
|
||||||
} )
|
} )
|
||||||
.catch( () => {
|
.catch( () => {
|
||||||
this.disabled = false;
|
this.disabled = false;
|
||||||
this.textContent = 'Cancel';
|
this.textContent = 'Cancelar';
|
||||||
} );
|
} );
|
||||||
} );
|
} );
|
||||||
} );
|
} );
|
||||||
@@ -164,84 +227,59 @@
|
|||||||
function refreshMeetings() {
|
function refreshMeetings() {
|
||||||
const listEl = document.getElementById( 'woodoo-meetings-list' );
|
const listEl = document.getElementById( 'woodoo-meetings-list' );
|
||||||
if ( ! listEl ) return;
|
if ( ! listEl ) return;
|
||||||
|
|
||||||
post( 'woodoo_get_meetings', {} )
|
post( 'woodoo_get_meetings', {} )
|
||||||
.then( res => {
|
.then( res => {
|
||||||
if ( ! res.success ) return;
|
if ( ! res.success || ! res.data.length ) {
|
||||||
const meetings = res.data;
|
listEl.innerHTML = '<p class="woodoo-empty">No tienes reuniones programadas.</p>';
|
||||||
if ( ! meetings.length ) {
|
|
||||||
listEl.innerHTML = '<p class="woodoo-empty">' + esc( 'No upcoming meetings scheduled.' ) + '</p>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const meses = [ '', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
|
||||||
|
'jul', 'ago', 'sep', 'oct', 'nov', 'dic' ];
|
||||||
let html = '<div class="woodoo-meetings-grid">';
|
let html = '<div class="woodoo-meetings-grid">';
|
||||||
meetings.forEach( ev => {
|
res.data.forEach( ev => {
|
||||||
const start = new Date( ev.start.replace( ' ', 'T' ) );
|
const start = new Date( ev.start.replace( ' ', 'T' ) );
|
||||||
const end = new Date( ev.stop.replace( ' ', 'T' ) );
|
const end = new Date( ev.stop.replace( ' ', 'T' ) );
|
||||||
html +=
|
html +=
|
||||||
'<div class="woodoo-meeting-card" data-event-id="' + ev.id + '">' +
|
'<div class="woodoo-meeting-card" data-event-id="' + ev.id + '">' +
|
||||||
'<div class="woodoo-meeting-card__date">' +
|
'<div class="woodoo-meeting-card__date">' +
|
||||||
'<span class="woodoo-meeting-card__day">' + pad( start.getDate() ) + '</span>' +
|
'<span class="woodoo-meeting-card__day">' + pad( start.getDate() ) + '</span>' +
|
||||||
'<span class="woodoo-meeting-card__month">' + monthShort( start ) + '</span>' +
|
'<span class="woodoo-meeting-card__month">' + ( meses[ start.getMonth() + 1 ] || '' ) + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="woodoo-meeting-card__info">' +
|
'<div class="woodoo-meeting-card__info">' +
|
||||||
'<strong>' + esc( ev.name ) + '</strong>' +
|
'<strong>' + esc( ev.name ) + '</strong>' +
|
||||||
'<span class="woodoo-meeting-card__time">' +
|
'<span class="woodoo-meeting-card__time">' + timeStr( start ) + ' – ' + timeStr( end ) + '</span>' +
|
||||||
timeStr( start ) + ' – ' + timeStr( end ) +
|
|
||||||
'</span>' +
|
|
||||||
( ev.videocall_location
|
( ev.videocall_location
|
||||||
? '<a href="' + esc( ev.videocall_location ) + '" target="_blank" rel="noopener" class="woodoo-meeting-card__video">Join Video Call</a>'
|
? '<a href="' + esc( ev.videocall_location ) + '" target="_blank" rel="noopener" class="woodoo-meeting-card__video">Unirse a la videollamada</a>'
|
||||||
: '' ) +
|
: '' ) +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="woodoo-meeting-card__actions">' +
|
'<div class="woodoo-meeting-card__actions">' +
|
||||||
'<button class="woodoo-cancel-meeting woodoo-btn woodoo-btn--sm woodoo-btn--outline" data-event-id="' + ev.id + '">Cancel</button>' +
|
'<button class="woodoo-cancel-meeting woodoo-btn woodoo-btn--sm woodoo-btn--outline" data-event-id="' + ev.id + '">Cancelar</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
} );
|
} );
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
listEl.innerHTML = 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 ──────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
function post( action, data ) {
|
function post( action, data ) {
|
||||||
const body = new URLSearchParams( Object.assign( { action, nonce }, data ) );
|
const body = new URLSearchParams( Object.assign( { action, nonce }, data ) );
|
||||||
return fetch( ajaxUrl, { method: 'POST', body } )
|
return fetch( ajaxUrl, { method: 'POST', body } ).then( r => r.json() );
|
||||||
.then( r => r.json() );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime( datetimeStr ) {
|
function formatTime( dt ) { return dt.slice( 11, 16 ); }
|
||||||
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 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 esc( str ) {
|
function esc( str ) {
|
||||||
return String( str )
|
return String( str )
|
||||||
.replace( /&/g, '&' )
|
.replace( /&/g, '&' ).replace( /</g, '<' )
|
||||||
.replace( /</g, '<' )
|
.replace( />/g, '>' ).replace( /"/g, '"' );
|
||||||
.replace( />/g, '>' )
|
|
||||||
.replace( /"/g, '"' );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showError( msg ) {
|
function showCalError( msg ) {
|
||||||
errorMsg.textContent = msg;
|
errorMsg.textContent = msg;
|
||||||
errorMsg.style.display = 'block';
|
errorMsg.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ class WooDoo_Invoices {
|
|||||||
add_action( 'woocommerce_account_' . self::ENDPOINT . '_endpoint', [ __CLASS__, 'render' ] );
|
add_action( 'woocommerce_account_' . self::ENDPOINT . '_endpoint', [ __CLASS__, 'render' ] );
|
||||||
add_filter( 'woocommerce_get_query_vars', [ __CLASS__, 'add_query_var' ] );
|
add_filter( 'woocommerce_get_query_vars', [ __CLASS__, 'add_query_var' ] );
|
||||||
|
|
||||||
// PDF download endpoint
|
// Send invoice by email (AJAX, logged-in users only)
|
||||||
add_action( 'wp_ajax_woodoo_invoice_pdf', [ __CLASS__, 'ajax_download_pdf' ] );
|
add_action( 'wp_ajax_woodoo_send_invoice_email', [ __CLASS__, 'ajax_send_invoice_email' ] );
|
||||||
add_action( 'wp_ajax_nopriv_woodoo_invoice_pdf', [ __CLASS__, 'ajax_download_pdf' ] );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function add_query_var( array $vars ): array {
|
public static function add_query_var( array $vars ): array {
|
||||||
@@ -93,78 +92,97 @@ class WooDoo_Invoices {
|
|||||||
include WOODOO_DIR . 'templates/myaccount-invoices.php';
|
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.
|
* Ask Odoo to resend the invoice email to the customer.
|
||||||
* ?action=woodoo_invoice_pdf&invoice_id=123&nonce=...
|
* Uses mail.template.send_mail() with the standard invoice email template.
|
||||||
*/
|
*/
|
||||||
public static function ajax_download_pdf(): void {
|
public static function ajax_send_invoice_email(): void {
|
||||||
$nonce = sanitize_text_field( wp_unslash( $_GET['nonce'] ?? '' ) );
|
check_ajax_referer( 'woodoo_invoice_email', 'nonce' );
|
||||||
if ( ! wp_verify_nonce( $nonce, 'woodoo_invoice_pdf' ) ) {
|
|
||||||
wp_die( esc_html__( 'Security check failed.', 'woodoo' ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! is_user_logged_in() ) {
|
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 );
|
$invoice_id = absint( $_POST['invoice_id'] ?? 0 );
|
||||||
if ( ! $invoice_id ) wp_die( 'Invalid invoice ID.' );
|
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();
|
$user_id = get_current_user_id();
|
||||||
$partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true );
|
$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();
|
$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(
|
$invoices = $api->search(
|
||||||
'account.move',
|
'account.move',
|
||||||
[ [ 'id', '=', $invoice_id ], [ 'partner_id', '=', $partner_id ] ],
|
[ [ 'id', '=', $invoice_id ], [ 'partner_id', '=', $partner_id ] ],
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|
||||||
if ( empty( $invoices ) ) {
|
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.
|
// Find the Odoo invoice email template ID (cached 24h)
|
||||||
// Odoo's /report/pdf/ HTTP endpoint only accepts session cookies, not API keys.
|
$template_id = (int) get_transient( 'woodoo_invoice_tpl_id' );
|
||||||
// Calling ir.actions.report.render_qweb_pdf() via execute_kw works with any
|
if ( ! $template_id ) {
|
||||||
// valid authenticated user and returns the PDF content base64-encoded.
|
$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(
|
$result = $api->execute_kw(
|
||||||
'ir.actions.report',
|
'mail.template',
|
||||||
'render_qweb_pdf',
|
'send_mail',
|
||||||
[ 'account.report_invoice', [ $invoice_id ] ]
|
[ [ $template_id ], $invoice_id ],
|
||||||
|
[ 'force_send' => true ]
|
||||||
);
|
);
|
||||||
|
|
||||||
if ( is_wp_error( $result ) ) {
|
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']
|
wp_send_json_success( [
|
||||||
$b64 = is_array( $result ) ? ( $result[0] ?? '' ) : $result;
|
'message' => 'La factura ha sido enviada a tu correo electrónico.',
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ defined( 'ABSPATH' ) || exit;
|
|||||||
|
|
||||||
wp_enqueue_style( 'woodoo-frontend' );
|
wp_enqueue_style( 'woodoo-frontend' );
|
||||||
wp_enqueue_script( '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' ),
|
||||||
|
] );
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="woodoo-invoices">
|
<div class="woodoo-invoices">
|
||||||
@@ -30,10 +34,9 @@ wp_enqueue_script( 'woodoo-frontend' );
|
|||||||
<th class="col-number">Nº Factura</th>
|
<th class="col-number">Nº Factura</th>
|
||||||
<th class="col-date">Fecha</th>
|
<th class="col-date">Fecha</th>
|
||||||
<th class="col-due">Vencimiento</th>
|
<th class="col-due">Vencimiento</th>
|
||||||
<th class="col-amount">Total</th>
|
<th class="col-amount">Importe</th>
|
||||||
<th class="col-balance">Saldo pendiente</th>
|
|
||||||
<th class="col-status">Estado</th>
|
<th class="col-status">Estado</th>
|
||||||
<th class="col-download">PDF</th>
|
<th class="col-action">Envío</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -43,11 +46,6 @@ wp_enqueue_script( 'woodoo-frontend' );
|
|||||||
$pay_state = $inv['payment_state'] ?? 'not_paid';
|
$pay_state = $inv['payment_state'] ?? 'not_paid';
|
||||||
$badge_class = WooDoo_Invoices::payment_state_class( $pay_state );
|
$badge_class = WooDoo_Invoices::payment_state_class( $pay_state );
|
||||||
$badge_label = WooDoo_Invoices::payment_state_label( $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' ) );
|
|
||||||
?>
|
?>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="col-number woodoo-inv-number">
|
<td class="col-number woodoo-inv-number">
|
||||||
@@ -78,22 +76,18 @@ wp_enqueue_script( 'woodoo-frontend' );
|
|||||||
<td class="col-amount woodoo-amount woodoo-nowrap">
|
<td class="col-amount woodoo-amount woodoo-nowrap">
|
||||||
<?php echo esc_html( number_format( (float) $inv['amount_total'], 2, ',', '.' ) . ' ' . $currency ); ?>
|
<?php echo esc_html( number_format( (float) $inv['amount_total'], 2, ',', '.' ) . ' ' . $currency ); ?>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-balance woodoo-amount woodoo-nowrap">
|
|
||||||
<?php echo esc_html( number_format( (float) $inv['amount_residual'], 2, ',', '.' ) . ' ' . $currency ); ?>
|
|
||||||
</td>
|
|
||||||
<td class="col-status woodoo-nowrap">
|
<td class="col-status woodoo-nowrap">
|
||||||
<span class="woodoo-badge <?php echo esc_attr( $badge_class ); ?>">
|
<span class="woodoo-badge <?php echo esc_attr( $badge_class ); ?>">
|
||||||
<?php echo esc_html( $badge_label ); ?>
|
<?php echo esc_html( $badge_label ); ?>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-download">
|
<td class="col-action">
|
||||||
<a href="<?php echo esc_url( $pdf_url ); ?>"
|
<button type="button"
|
||||||
class="woodoo-btn woodoo-btn--sm"
|
class="woodoo-btn woodoo-btn--sm woodoo-send-invoice"
|
||||||
target="_blank"
|
data-id="<?php echo esc_attr( $inv['id'] ); ?>"
|
||||||
rel="noopener"
|
title="Reenviar factura por correo electrónico">
|
||||||
title="Descargar factura en PDF">
|
✉ Reenviar
|
||||||
↓ PDF
|
</button>
|
||||||
</a>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user