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:
Malin
2026-04-01 17:43:39 +02:00
parent d1597731c5
commit c05433689e
4 changed files with 221 additions and 167 deletions

View File

@@ -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; }

View File

@@ -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 = '<em>' + esc( i18n.loading ) + '</em>';
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 = '<em>' + esc( i18n.error ) + '</em>';
return;
}
const slots = res.data;
if ( ! slots.length ) {
slotsGrid.innerHTML = '<em>' + esc( i18n.no_slots ) + '</em>';
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 = '<em>' + esc( i18n.error ) + '</em>';
} );
.catch( () => { slotsGrid.innerHTML = '<em>' + esc( i18n.error ) + '</em>'; } );
} );
}
// ── 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 = '<p class="woodoo-empty">' + esc( 'No upcoming meetings scheduled.' ) + '</p>';
if ( ! res.success || ! res.data.length ) {
listEl.innerHTML = '<p class="woodoo-empty">No tienes reuniones programadas.</p>';
return;
}
const meses = [ '', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic' ];
let html = '<div class="woodoo-meetings-grid">';
meetings.forEach( ev => {
res.data.forEach( ev => {
const start = new Date( ev.start.replace( ' ', 'T' ) );
const end = new Date( ev.stop.replace( ' ', 'T' ) );
html +=
'<div class="woodoo-meeting-card" data-event-id="' + ev.id + '">' +
'<div class="woodoo-meeting-card__date">' +
'<span class="woodoo-meeting-card__day">' + pad( start.getDate() ) + '</span>' +
'<span class="woodoo-meeting-card__month">' + monthShort( start ) + '</span>' +
'<span class="woodoo-meeting-card__day">' + pad( start.getDate() ) + '</span>' +
'<span class="woodoo-meeting-card__month">' + ( meses[ start.getMonth() + 1 ] || '' ) + '</span>' +
'</div>' +
'<div class="woodoo-meeting-card__info">' +
'<strong>' + esc( ev.name ) + '</strong>' +
'<span class="woodoo-meeting-card__time">' +
timeStr( start ) + ' ' + timeStr( end ) +
'</span>' +
'<span class="woodoo-meeting-card__time">' + timeStr( start ) + ' ' + timeStr( end ) + '</span>' +
( 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 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>';
} );
html += '</div>';
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, '&amp;' )
.replace( /</g, '&lt;' )
.replace( />/g, '&gt;' )
.replace( /"/g, '&quot;' );
.replace( /&/g, '&amp;' ).replace( /</g, '&lt;' )
.replace( />/g, '&gt;' ).replace( /"/g, '&quot;' );
}
function showError( msg ) {
errorMsg.textContent = msg;
function showCalError( msg ) {
errorMsg.textContent = msg;
errorMsg.style.display = 'block';
}