feat: initial WooDoo plugin – WooCommerce & Odoo 19 integration
- Odoo JSON-RPC client (no Composer, uses wp_remote_post) - Admin settings page under WooCommerce with connection test - Customer linking: search Odoo partners from WP user profile - My Account: Odoo Invoices tab with PDF proxy download - My Account: Book a Meeting tab (slot calculator + calendar.event) - WC order → Odoo sale.order auto-sync on processing status - Products matched by SKU; partner auto-created from billing info - Uninstall cleanup (options, user meta, order meta, DB table) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
26
assets/css/woodoo-admin.css
Normal file
26
assets/css/woodoo-admin.css
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/* WooDoo Admin Styles */
|
||||||
|
.woodoo-settings .nav-tab-wrapper { margin-bottom: 0; }
|
||||||
|
.woodoo-settings .woodoo-tab { padding: 20px 0; }
|
||||||
|
.woodoo-info-box {
|
||||||
|
background: #fff8e1;
|
||||||
|
border: 1px solid #ffe082;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: .875rem;
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
.woodoo-info-box ul { margin: .5rem 0 0 1.2rem; }
|
||||||
|
.woodoo-info-box li { margin-bottom: 4px; }
|
||||||
|
.woodoo-customer-linker { margin-bottom: 1.5rem; }
|
||||||
|
.woodoo-customer-linker label { display: block; margin-bottom: 6px; font-weight: 600; }
|
||||||
|
#woo-customer-results { margin-top: 10px; }
|
||||||
|
.woodoo-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.woodoo-badge--green { background: #d1fae5; color: #065f46; }
|
||||||
|
.woodoo-badge--red { background: #fee2e2; color: #991b1b; }
|
||||||
189
assets/css/woodoo.css
Normal file
189
assets/css/woodoo.css
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/* =========================================================
|
||||||
|
WooDoo Frontend Styles
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
/* ── Shared ──────────────────────────────────────────────── */
|
||||||
|
.woodoo-notice {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
.woodoo-notice.woodoo-success { background: #ecfdf5; border: 1px solid #6ee7b7; color: #065f46; }
|
||||||
|
.woodoo-notice.woodoo-error { background: #fef2f2; border: 1px solid #fca5a5; color: #991b1b; }
|
||||||
|
.woodoo-empty { color: #6b7280; font-style: italic; }
|
||||||
|
.woodoo-section { margin-bottom: 2rem; }
|
||||||
|
.woodoo-section h3 { font-size: 1.15rem; margin-bottom: .5rem; }
|
||||||
|
.woodoo-desc { color: #6b7280; margin-bottom: 1rem; font-size: .9rem; }
|
||||||
|
|
||||||
|
/* ── Buttons ─────────────────────────────────────────────── */
|
||||||
|
.woodoo-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: .875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity .15s;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.woodoo-btn:hover { opacity: .85; text-decoration: none; }
|
||||||
|
.woodoo-btn--primary { background: #2271b1; color: #fff; }
|
||||||
|
.woodoo-btn--sm { padding: 4px 10px; font-size: .8rem; }
|
||||||
|
.woodoo-btn--outline { background: transparent; border: 1px solid #d1d5db; color: #374151; }
|
||||||
|
.woodoo-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Badges ──────────────────────────────────────────────── */
|
||||||
|
.woodoo-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.woodoo-badge--green { background: #d1fae5; color: #065f46; }
|
||||||
|
.woodoo-badge--red { background: #fee2e2; color: #991b1b; }
|
||||||
|
.woodoo-badge--orange { background: #fed7aa; color: #92400e; }
|
||||||
|
.woodoo-badge--blue { background: #dbeafe; color: #1e40af; }
|
||||||
|
.woodoo-badge--grey { background: #f3f4f6; color: #6b7280; }
|
||||||
|
|
||||||
|
/* ── Table ───────────────────────────────────────────────── */
|
||||||
|
.woodoo-table-wrap { overflow-x: auto; }
|
||||||
|
.woodoo-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
.woodoo-table th,
|
||||||
|
.woodoo-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.woodoo-table th { background: #f9fafb; font-weight: 600; }
|
||||||
|
.woodoo-table tr:last-child td { border-bottom: none; }
|
||||||
|
.woodoo-table .woodoo-amount { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.woodoo-table .woodoo-inv-number { font-weight: 600; }
|
||||||
|
.woodoo-overdue { color: #dc2626; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Pagination ──────────────────────────────────────────── */
|
||||||
|
.woodoo-pagination {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.woodoo-pagination a,
|
||||||
|
.woodoo-pagination .woodoo-page-current {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
font-size: .875rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.woodoo-pagination .woodoo-page-current {
|
||||||
|
background: #2271b1;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2271b1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Calendar / Booking ──────────────────────────────────── */
|
||||||
|
.woodoo-booking-form .woodoo-field {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.woodoo-booking-form label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
.woodoo-booking-form input[type="date"],
|
||||||
|
.woodoo-booking-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
.woodoo-booking-form textarea { max-width: 480px; resize: vertical; }
|
||||||
|
|
||||||
|
.woodoo-slots-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.woodoo-slot {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 2px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: .875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
transition: border-color .15s, background .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.woodoo-slot:hover { border-color: #2271b1; background: #eff6ff; }
|
||||||
|
.woodoo-slot.selected{ border-color: #2271b1; background: #dbeafe; font-weight: 600; }
|
||||||
|
|
||||||
|
.woodoo-selected-slot-display {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meeting Cards ───────────────────────────────────────── */
|
||||||
|
.woodoo-meetings-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.woodoo-meeting-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.woodoo-meeting-card__date {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 44px;
|
||||||
|
background: #2271b1;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.woodoo-meeting-card__day { font-size: 1.4rem; font-weight: 700; line-height: 1; }
|
||||||
|
.woodoo-meeting-card__month { font-size: .7rem; text-transform: uppercase; }
|
||||||
|
.woodoo-meeting-card__info { flex: 1; display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.woodoo-meeting-card__time { font-size: .85rem; color: #6b7280; }
|
||||||
|
.woodoo-meeting-card__video { font-size: .85rem; font-weight: 600; }
|
||||||
|
.woodoo-meeting-card__loc { font-size: .85rem; color: #6b7280; }
|
||||||
|
|
||||||
|
/* ── Admin styles (woodoo-admin.css shares this file via enqueueing) ── */
|
||||||
|
.woodoo-settings .woodoo-tab { padding: 16px 0; }
|
||||||
|
.woodoo-info-box {
|
||||||
|
background: #fff8e1;
|
||||||
|
border: 1px solid #ffe082;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
.woodoo-info-box ul { margin: .5rem 0 0 1.2rem; }
|
||||||
|
.woodoo-info-box li { margin-bottom: 4px; }
|
||||||
|
.woodoo-order-meta { border-top: 1px solid #e5e7eb; padding-top: 10px; }
|
||||||
130
assets/js/woodoo-admin.js
Normal file
130
assets/js/woodoo-admin.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/* WooDoo Admin JS */
|
||||||
|
jQuery( function ( $ ) {
|
||||||
|
|
||||||
|
const cfg = window.WooDooAdmin || {};
|
||||||
|
|
||||||
|
// ── Tab navigation ──────────────────────────────────────────────────
|
||||||
|
$( '.woodoo-settings .nav-tab' ).on( 'click', function ( e ) {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = $( this ).attr( 'href' );
|
||||||
|
|
||||||
|
$( '.woodoo-settings .nav-tab' ).removeClass( 'nav-tab-active' );
|
||||||
|
$( this ).addClass( 'nav-tab-active' );
|
||||||
|
|
||||||
|
$( '.woodoo-tab' ).hide();
|
||||||
|
$( target ).show();
|
||||||
|
} );
|
||||||
|
|
||||||
|
// ── Test Connection ──────────────────────────────────────────────────
|
||||||
|
$( '#woodoo-test-connection' ).on( 'click', function () {
|
||||||
|
const $btn = $( this );
|
||||||
|
const $result = $( '#woodoo-test-result' );
|
||||||
|
|
||||||
|
$btn.prop( 'disabled', true ).text( cfg.i18n.testing );
|
||||||
|
$result.html( '' );
|
||||||
|
|
||||||
|
$.post( cfg.ajax_url, {
|
||||||
|
action : 'woodoo_test_connection',
|
||||||
|
nonce : cfg.nonce,
|
||||||
|
} )
|
||||||
|
.done( function ( res ) {
|
||||||
|
if ( res.success ) {
|
||||||
|
const d = res.data;
|
||||||
|
const cls = d.success ? 'success' : 'error';
|
||||||
|
$result.html(
|
||||||
|
'<span class="woodoo-badge woodoo-badge--' + ( d.success ? 'green' : 'red' ) + '">' +
|
||||||
|
escHtml( d.message ) + '</span>'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$result.html( '<span style="color:red;">' + escHtml( res.data || cfg.i18n.error ) + '</span>' );
|
||||||
|
}
|
||||||
|
} )
|
||||||
|
.fail( function () {
|
||||||
|
$result.html( '<span style="color:red;">' + escHtml( cfg.i18n.error ) + '</span>' );
|
||||||
|
} )
|
||||||
|
.always( function () {
|
||||||
|
$btn.prop( 'disabled', false ).text( 'Test Connection' );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// ── Partner search on user profile ──────────────────────────────────
|
||||||
|
$( '#woodoo-partner-search-btn' ).on( 'click', function () {
|
||||||
|
const $input = $( '#woodoo-partner-search-input' );
|
||||||
|
const $results = $( '#woodoo-partner-search-results' );
|
||||||
|
const query = $input.val().trim();
|
||||||
|
const userId = $input.data( 'user-id' );
|
||||||
|
|
||||||
|
if ( ! query ) return;
|
||||||
|
|
||||||
|
$results.html( '<em>' + escHtml( cfg.i18n.searching ) + '</em>' );
|
||||||
|
|
||||||
|
$.post( cfg.ajax_url, {
|
||||||
|
action : 'woodoo_search_partners',
|
||||||
|
nonce : cfg.nonce,
|
||||||
|
query : query,
|
||||||
|
} )
|
||||||
|
.done( function ( res ) {
|
||||||
|
if ( ! res.success || ! res.data.length ) {
|
||||||
|
$results.html( '<em>No partners found.</em>' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<ul style="margin:0;padding:0;list-style:none;">';
|
||||||
|
res.data.forEach( function ( p ) {
|
||||||
|
html +=
|
||||||
|
'<li style="padding:4px 0;border-bottom:1px solid #eee;">' +
|
||||||
|
'<strong>' + escHtml( p.name ) + '</strong>' +
|
||||||
|
( p.email ? ' <' + escHtml( p.email ) + '>' : '' ) +
|
||||||
|
' <button type="button" class="button button-small woodoo-pick-partner"' +
|
||||||
|
' data-id="' + parseInt( p.id, 10 ) + '"' +
|
||||||
|
' data-name="' + escAttr( p.name ) + '"' +
|
||||||
|
' data-user-id="' + parseInt( userId, 10 ) + '">' +
|
||||||
|
'Select</button>' +
|
||||||
|
'</li>';
|
||||||
|
} );
|
||||||
|
html += '</ul>';
|
||||||
|
$results.html( html );
|
||||||
|
} )
|
||||||
|
.fail( function () {
|
||||||
|
$results.html( '<em>' + escHtml( cfg.i18n.error ) + '</em>' );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// ── Pick partner from search results ────────────────────────────────
|
||||||
|
$( document ).on( 'click', '.woodoo-pick-partner', function () {
|
||||||
|
const $btn = $( this );
|
||||||
|
const partnerId = $btn.data( 'id' );
|
||||||
|
const name = $btn.data( 'name' );
|
||||||
|
const userId = $btn.data( 'user-id' );
|
||||||
|
|
||||||
|
$( '#woodoo_odoo_partner_id' ).val( partnerId );
|
||||||
|
$( '#woodoo-partner-name-display' ).text( name ).show();
|
||||||
|
$( '#woodoo-partner-search-results' ).html(
|
||||||
|
'<span style="color:green;">' + escHtml( cfg.i18n.link_done ) + ' → ' + escHtml( name ) + ' (#' + partnerId + ')</span>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persist immediately if we have the AJAX handler
|
||||||
|
if ( userId ) {
|
||||||
|
$.post( cfg.ajax_url, {
|
||||||
|
action : 'woodoo_link_customer',
|
||||||
|
nonce : cfg.nonce,
|
||||||
|
user_id : userId,
|
||||||
|
partner_id : partnerId,
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
|
||||||
|
// ── Utility ──────────────────────────────────────────────────────────
|
||||||
|
function escHtml( str ) {
|
||||||
|
return String( str )
|
||||||
|
.replace( /&/g, '&' )
|
||||||
|
.replace( /</g, '<' )
|
||||||
|
.replace( />/g, '>' )
|
||||||
|
.replace( /"/g, '"' );
|
||||||
|
}
|
||||||
|
|
||||||
|
function escAttr( str ) {
|
||||||
|
return escHtml( str ).replace( /'/g, ''' );
|
||||||
|
}
|
||||||
|
|
||||||
|
} );
|
||||||
248
assets/js/woodoo-frontend.js
Normal file
248
assets/js/woodoo-frontend.js
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/* WooDoo Frontend JS – Calendar Booking */
|
||||||
|
( function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const cfg = window.WooDooCalendar;
|
||||||
|
if ( ! cfg ) return; // not on calendar page
|
||||||
|
|
||||||
|
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' );
|
||||||
|
|
||||||
|
let selectedStart = null;
|
||||||
|
let selectedEnd = null;
|
||||||
|
|
||||||
|
// ── 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';
|
||||||
|
confirmBlock.style.display = 'none';
|
||||||
|
successMsg.style.display = 'none';
|
||||||
|
errorMsg.style.display = 'none';
|
||||||
|
selectedStart = selectedEnd = null;
|
||||||
|
bookBtn.disabled = true;
|
||||||
|
|
||||||
|
post( 'woodoo_get_slots', { date } )
|
||||||
|
.then( res => {
|
||||||
|
if ( ! res.success ) {
|
||||||
|
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.textContent = formatTime( slot.start ) + ' – ' + formatTime( slot.end );
|
||||||
|
btn.dataset.start = slot.start;
|
||||||
|
btn.dataset.end = slot.end;
|
||||||
|
btn.addEventListener( 'click', onSlotClick );
|
||||||
|
slotsGrid.appendChild( btn );
|
||||||
|
} );
|
||||||
|
} )
|
||||||
|
.catch( () => {
|
||||||
|
slotsGrid.innerHTML = '<em>' + esc( i18n.error ) + '</em>';
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Slot selection ───────────────────────────────────────────────────
|
||||||
|
function onSlotClick( e ) {
|
||||||
|
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, {
|
||||||
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||||
|
} ) + ' ' + formatTime( selectedStart ) + ' – ' + formatTime( selectedEnd );
|
||||||
|
|
||||||
|
confirmBlock.style.display = 'block';
|
||||||
|
bookBtn.disabled = false;
|
||||||
|
successMsg.style.display = 'none';
|
||||||
|
errorMsg.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Confirm booking ──────────────────────────────────────────────────
|
||||||
|
if ( bookBtn ) {
|
||||||
|
bookBtn.addEventListener( 'click', function () {
|
||||||
|
if ( ! selectedStart || ! selectedEnd ) return;
|
||||||
|
|
||||||
|
bookBtn.disabled = true;
|
||||||
|
bookBtn.textContent = i18n.booking;
|
||||||
|
errorMsg.style.display = 'none';
|
||||||
|
|
||||||
|
post( 'woodoo_book_slot', {
|
||||||
|
start : selectedStart,
|
||||||
|
end : selectedEnd,
|
||||||
|
notes : notesArea ? notesArea.value : '',
|
||||||
|
} )
|
||||||
|
.then( res => {
|
||||||
|
if ( res.success ) {
|
||||||
|
successMsg.textContent = res.data.message;
|
||||||
|
successMsg.style.display = 'block';
|
||||||
|
confirmBlock.style.display = 'none';
|
||||||
|
slotsWrap.style.display = 'none';
|
||||||
|
dateInput.value = '';
|
||||||
|
selectedStart = selectedEnd = null;
|
||||||
|
|
||||||
|
// Re-fetch meetings after booking
|
||||||
|
refreshMeetings();
|
||||||
|
} else {
|
||||||
|
showError( res.data || i18n.error );
|
||||||
|
bookBtn.disabled = false;
|
||||||
|
bookBtn.textContent = i18n.book_btn;
|
||||||
|
}
|
||||||
|
} )
|
||||||
|
.catch( () => {
|
||||||
|
showError( i18n.error );
|
||||||
|
bookBtn.disabled = false;
|
||||||
|
bookBtn.textContent = i18n.book_btn;
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cancel meeting ───────────────────────────────────────────────────
|
||||||
|
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.textContent = i18n.cancelling;
|
||||||
|
|
||||||
|
post( 'woodoo_cancel_meeting', { event_id: eventId } )
|
||||||
|
.then( res => {
|
||||||
|
if ( res.success ) {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
} )
|
||||||
|
.catch( () => {
|
||||||
|
this.disabled = false;
|
||||||
|
this.textContent = 'Cancel';
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// ── Refresh meetings list ────────────────────────────────────────────
|
||||||
|
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>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="woodoo-meetings-grid">';
|
||||||
|
meetings.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>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="woodoo-meeting-card__info">' +
|
||||||
|
'<strong>' + esc( ev.name ) + '</strong>' +
|
||||||
|
'<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>'
|
||||||
|
: '' ) +
|
||||||
|
'</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>' +
|
||||||
|
'</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() );
|
||||||
|
}
|
||||||
|
|
||||||
|
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 esc( str ) {
|
||||||
|
return String( str )
|
||||||
|
.replace( /&/g, '&' )
|
||||||
|
.replace( /</g, '<' )
|
||||||
|
.replace( />/g, '>' )
|
||||||
|
.replace( /"/g, '"' );
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError( msg ) {
|
||||||
|
errorMsg.textContent = msg;
|
||||||
|
errorMsg.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
} )();
|
||||||
402
includes/class-woodoo-admin.php
Normal file
402
includes/class-woodoo-admin.php
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Admin: settings page, customer linking, connection test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
class WooDoo_Admin {
|
||||||
|
|
||||||
|
public static function init(): void {
|
||||||
|
add_action( 'admin_menu', [ __CLASS__, 'add_menu' ] );
|
||||||
|
add_action( 'admin_init', [ __CLASS__, 'register_settings' ] );
|
||||||
|
add_action( 'admin_enqueue_scripts', [ __CLASS__, 'enqueue_admin_assets' ] );
|
||||||
|
|
||||||
|
// User profile: add Odoo Partner ID field
|
||||||
|
add_action( 'show_user_profile', [ __CLASS__, 'user_profile_fields' ] );
|
||||||
|
add_action( 'edit_user_profile', [ __CLASS__, 'user_profile_fields' ] );
|
||||||
|
add_action( 'personal_options_update',[ __CLASS__, 'save_user_profile_fields' ] );
|
||||||
|
add_action( 'edit_user_profile_update',[ __CLASS__, 'save_user_profile_fields' ] );
|
||||||
|
|
||||||
|
// AJAX handlers (admin only)
|
||||||
|
add_action( 'wp_ajax_woodoo_test_connection', [ __CLASS__, 'ajax_test_connection' ] );
|
||||||
|
add_action( 'wp_ajax_woodoo_search_partners', [ __CLASS__, 'ajax_search_partners' ] );
|
||||||
|
add_action( 'wp_ajax_woodoo_link_customer', [ __CLASS__, 'ajax_link_customer' ] );
|
||||||
|
|
||||||
|
// WC customer list column
|
||||||
|
add_filter( 'manage_users_columns', [ __CLASS__, 'add_users_column' ] );
|
||||||
|
add_filter( 'manage_users_custom_column', [ __CLASS__, 'render_users_column' ], 10, 3 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Menu & Settings ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function add_menu(): void {
|
||||||
|
add_submenu_page(
|
||||||
|
'woocommerce',
|
||||||
|
__( 'WooDoo – Odoo Integration', 'woodoo' ),
|
||||||
|
__( 'Odoo Integration', 'woodoo' ),
|
||||||
|
'manage_woocommerce',
|
||||||
|
'woodoo-settings',
|
||||||
|
[ __CLASS__, 'render_settings_page' ]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register_settings(): void {
|
||||||
|
$fields = [
|
||||||
|
'woodoo_odoo_url' => __( 'Odoo URL (e.g. https://odoo.example.com)', 'woodoo' ),
|
||||||
|
'woodoo_odoo_db' => __( 'Database Name', 'woodoo' ),
|
||||||
|
'woodoo_odoo_username' => __( 'Username / Login', 'woodoo' ),
|
||||||
|
'woodoo_odoo_api_key' => __( 'API Key', 'woodoo' ),
|
||||||
|
];
|
||||||
|
foreach ( $fields as $key => $label ) {
|
||||||
|
register_setting( 'woodoo_settings', $key, [ 'sanitize_callback' => 'sanitize_text_field' ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meeting settings
|
||||||
|
$meeting_fields = [
|
||||||
|
'woodoo_meeting_duration' => 30, // minutes
|
||||||
|
'woodoo_available_days' => [ 1, 2, 3, 4, 5 ], // Mon-Fri
|
||||||
|
'woodoo_available_from' => '09:00',
|
||||||
|
'woodoo_available_to' => '17:00',
|
||||||
|
'woodoo_meeting_title_prefix'=> 'Meeting via WooDoo',
|
||||||
|
];
|
||||||
|
foreach ( $meeting_fields as $key => $default ) {
|
||||||
|
register_setting( 'woodoo_settings', $key );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order sync toggle
|
||||||
|
register_setting( 'woodoo_settings', 'woodoo_sync_orders', [ 'sanitize_callback' => 'rest_sanitize_boolean' ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function enqueue_admin_assets( string $hook ): void {
|
||||||
|
if ( strpos( $hook, 'woodoo-settings' ) === false
|
||||||
|
&& $hook !== 'user-edit.php'
|
||||||
|
&& $hook !== 'profile.php' ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wp_enqueue_style(
|
||||||
|
'woodoo-admin',
|
||||||
|
WOODOO_URL . 'assets/css/woodoo-admin.css',
|
||||||
|
[],
|
||||||
|
WOODOO_VERSION
|
||||||
|
);
|
||||||
|
wp_enqueue_script(
|
||||||
|
'woodoo-admin',
|
||||||
|
WOODOO_URL . 'assets/js/woodoo-admin.js',
|
||||||
|
[ 'jquery' ],
|
||||||
|
WOODOO_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
wp_localize_script( 'woodoo-admin', 'WooDooAdmin', [
|
||||||
|
'nonce' => wp_create_nonce( 'woodoo_admin' ),
|
||||||
|
'ajax_url' => admin_url( 'admin-ajax.php' ),
|
||||||
|
'i18n' => [
|
||||||
|
'testing' => __( 'Testing…', 'woodoo' ),
|
||||||
|
'searching' => __( 'Searching…', 'woodoo' ),
|
||||||
|
'link_done' => __( 'Linked!', 'woodoo' ),
|
||||||
|
'error' => __( 'Error. Check console.', 'woodoo' ),
|
||||||
|
],
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings Page HTML ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function render_settings_page(): void {
|
||||||
|
if ( ! current_user_can( 'manage_woocommerce' ) ) {
|
||||||
|
wp_die( esc_html__( 'Access denied.', 'woodoo' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$days_map = [
|
||||||
|
1 => __( 'Monday', 'woodoo' ),
|
||||||
|
2 => __( 'Tuesday', 'woodoo' ),
|
||||||
|
3 => __( 'Wednesday', 'woodoo' ),
|
||||||
|
4 => __( 'Thursday', 'woodoo' ),
|
||||||
|
5 => __( 'Friday', 'woodoo' ),
|
||||||
|
6 => __( 'Saturday', 'woodoo' ),
|
||||||
|
7 => __( 'Sunday', 'woodoo' ),
|
||||||
|
];
|
||||||
|
$saved_days = (array) get_option( 'woodoo_available_days', [ 1, 2, 3, 4, 5 ] );
|
||||||
|
?>
|
||||||
|
<div class="wrap woodoo-settings">
|
||||||
|
<h1><?php esc_html_e( 'WooDoo – Odoo Integration', 'woodoo' ); ?></h1>
|
||||||
|
|
||||||
|
<nav class="nav-tab-wrapper">
|
||||||
|
<a href="#tab-connection" class="nav-tab nav-tab-active"><?php esc_html_e( 'Connection', 'woodoo' ); ?></a>
|
||||||
|
<a href="#tab-meetings" class="nav-tab"><?php esc_html_e( 'Meetings', 'woodoo' ); ?></a>
|
||||||
|
<a href="#tab-orders" class="nav-tab"><?php esc_html_e( 'Order Sync', 'woodoo' ); ?></a>
|
||||||
|
<a href="#tab-customers" class="nav-tab"><?php esc_html_e( 'Customers', 'woodoo' ); ?></a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<form method="post" action="options.php">
|
||||||
|
<?php settings_fields( 'woodoo_settings' ); ?>
|
||||||
|
|
||||||
|
<!-- Connection Tab -->
|
||||||
|
<div id="tab-connection" class="woodoo-tab active">
|
||||||
|
<h2><?php esc_html_e( 'Odoo Connection', 'woodoo' ); ?></h2>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Odoo URL', 'woodoo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<input type="url" name="woodoo_odoo_url" value="<?php echo esc_attr( get_option( 'woodoo_odoo_url' ) ); ?>" class="regular-text" placeholder="https://odoo.yourcompany.com">
|
||||||
|
<p class="description"><?php esc_html_e( 'Include https://, no trailing slash.', 'woodoo' ); ?></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Database Name', 'woodoo' ); ?></th>
|
||||||
|
<td><input type="text" name="woodoo_odoo_db" value="<?php echo esc_attr( get_option( 'woodoo_odoo_db' ) ); ?>" class="regular-text"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Username', 'woodoo' ); ?></th>
|
||||||
|
<td><input type="text" name="woodoo_odoo_username" value="<?php echo esc_attr( get_option( 'woodoo_odoo_username' ) ); ?>" class="regular-text" autocomplete="off"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'API Key', 'woodoo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<input type="password" name="woodoo_odoo_api_key" value="<?php echo esc_attr( get_option( 'woodoo_odoo_api_key' ) ); ?>" class="regular-text" autocomplete="off">
|
||||||
|
<p class="description"><?php esc_html_e( 'Generate in Odoo: Settings → Users → Your User → Account Security → New API Key.', 'woodoo' ); ?></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<button type="button" id="woodoo-test-connection" class="button button-secondary">
|
||||||
|
<?php esc_html_e( 'Test Connection', 'woodoo' ); ?>
|
||||||
|
</button>
|
||||||
|
<span id="woodoo-test-result" style="margin-left:12px;"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meetings Tab -->
|
||||||
|
<div id="tab-meetings" class="woodoo-tab" style="display:none;">
|
||||||
|
<h2><?php esc_html_e( 'Meeting / Booking Settings', 'woodoo' ); ?></h2>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Slot Duration (minutes)', 'woodoo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<input type="number" name="woodoo_meeting_duration" value="<?php echo esc_attr( get_option( 'woodoo_meeting_duration', 30 ) ); ?>" min="15" max="240" step="15" class="small-text">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Available Days', 'woodoo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<?php foreach ( $days_map as $num => $label ) : ?>
|
||||||
|
<label style="margin-right:12px;">
|
||||||
|
<input type="checkbox" name="woodoo_available_days[]" value="<?php echo esc_attr( $num ); ?>"
|
||||||
|
<?php checked( in_array( $num, $saved_days, true ) ); ?>>
|
||||||
|
<?php echo esc_html( $label ); ?>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Available From', 'woodoo' ); ?></th>
|
||||||
|
<td><input type="time" name="woodoo_available_from" value="<?php echo esc_attr( get_option( 'woodoo_available_from', '09:00' ) ); ?>"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Available To', 'woodoo' ); ?></th>
|
||||||
|
<td><input type="time" name="woodoo_available_to" value="<?php echo esc_attr( get_option( 'woodoo_available_to', '17:00' ) ); ?>"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Meeting Title Prefix', 'woodoo' ); ?></th>
|
||||||
|
<td><input type="text" name="woodoo_meeting_title_prefix" value="<?php echo esc_attr( get_option( 'woodoo_meeting_title_prefix', 'Meeting via WooDoo' ) ); ?>" class="regular-text"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Order Sync Tab -->
|
||||||
|
<div id="tab-orders" class="woodoo-tab" style="display:none;">
|
||||||
|
<h2><?php esc_html_e( 'Order → Odoo Sales Order Sync', 'woodoo' ); ?></h2>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Enable Order Sync', 'woodoo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="woodoo_sync_orders" value="1" <?php checked( get_option( 'woodoo_sync_orders', 1 ) ); ?>>
|
||||||
|
<?php esc_html_e( 'Automatically create a Sales Order in Odoo when a WooCommerce order is placed (status: processing).', 'woodoo' ); ?>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div class="woodoo-info-box">
|
||||||
|
<strong><?php esc_html_e( 'How it works:', 'woodoo' ); ?></strong>
|
||||||
|
<ul>
|
||||||
|
<li><?php esc_html_e( 'When a WooCommerce order reaches "Processing" status, WooDoo finds or creates the customer in Odoo.', 'woodoo' ); ?></li>
|
||||||
|
<li><?php esc_html_e( 'Products are matched by SKU (default_code). Unmatched items are added as generic lines.', 'woodoo' ); ?></li>
|
||||||
|
<li><?php esc_html_e( 'The Odoo Sales Order is left in "Quotation" (draft) state, ready for invoicing.', 'woodoo' ); ?></li>
|
||||||
|
<li><?php esc_html_e( 'The Odoo SO reference is saved as order meta and visible in the WC order screen.', 'woodoo' ); ?></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Customer Linking Tab -->
|
||||||
|
<div id="tab-customers" class="woodoo-tab" style="display:none;">
|
||||||
|
<h2><?php esc_html_e( 'Customer Linking', 'woodoo' ); ?></h2>
|
||||||
|
<p><?php esc_html_e( 'Link WooCommerce customers to their Odoo partner record. You can also edit this from each user\'s profile page.', 'woodoo' ); ?></p>
|
||||||
|
|
||||||
|
<div class="woodoo-customer-linker">
|
||||||
|
<label><?php esc_html_e( 'Search WooCommerce Customer:', 'woodoo' ); ?></label>
|
||||||
|
<input type="text" id="woo-customer-search" class="regular-text" placeholder="<?php esc_attr_e( 'Customer name or email…', 'woodoo' ); ?>">
|
||||||
|
<div id="woo-customer-results"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php submit_button(); ?>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User Profile Fields ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function user_profile_fields( WP_User $user ): void {
|
||||||
|
if ( ! current_user_can( 'edit_users' ) ) return;
|
||||||
|
|
||||||
|
$partner_id = get_user_meta( $user->ID, 'woodoo_odoo_partner_id', true );
|
||||||
|
$partner_name = get_user_meta( $user->ID, 'woodoo_odoo_partner_name', true );
|
||||||
|
?>
|
||||||
|
<h2><?php esc_html_e( 'Odoo Integration (WooDoo)', 'woodoo' ); ?></h2>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Odoo Partner ID', 'woodoo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<input type="number" id="woodoo_odoo_partner_id" name="woodoo_odoo_partner_id"
|
||||||
|
value="<?php echo esc_attr( $partner_id ); ?>"
|
||||||
|
class="small-text" min="1">
|
||||||
|
<?php if ( $partner_name ) : ?>
|
||||||
|
<span id="woodoo-partner-name-display" style="margin-left:8px;color:#666;">
|
||||||
|
<?php echo esc_html( $partner_name ); ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'Leave blank to auto-match by email on next sync. Or type an ID and the name will resolve automatically.', 'woodoo' ); ?>
|
||||||
|
</p>
|
||||||
|
<input type="hidden" name="woodoo_nonce" value="<?php echo esc_attr( wp_create_nonce( 'woodoo_user_profile_' . $user->ID ) ); ?>">
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong><?php esc_html_e( 'Search Odoo partners:', 'woodoo' ); ?></strong><br>
|
||||||
|
<input type="text" id="woodoo-partner-search-input" class="regular-text"
|
||||||
|
placeholder="<?php esc_attr_e( 'Name or email…', 'woodoo' ); ?>"
|
||||||
|
data-user-id="<?php echo esc_attr( $user->ID ); ?>">
|
||||||
|
<button type="button" id="woodoo-partner-search-btn" class="button button-secondary" style="margin-left:4px;">
|
||||||
|
<?php esc_html_e( 'Search', 'woodoo' ); ?>
|
||||||
|
</button>
|
||||||
|
<div id="woodoo-partner-search-results" style="margin-top:8px;"></div>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php
|
||||||
|
$odoo_so_count = get_user_meta( $user->ID, 'woodoo_so_count', true );
|
||||||
|
if ( $odoo_so_count ) : ?>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Odoo Sales Orders', 'woodoo' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<span><?php echo esc_html( $odoo_so_count ); ?> <?php esc_html_e( 'orders synced to Odoo', 'woodoo' ); ?></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</table>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function save_user_profile_fields( int $user_id ): void {
|
||||||
|
if ( ! isset( $_POST['woodoo_nonce'] )
|
||||||
|
|| ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['woodoo_nonce'] ) ), 'woodoo_user_profile_' . $user_id )
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! current_user_can( 'edit_user', $user_id ) ) return;
|
||||||
|
|
||||||
|
$partner_id = isset( $_POST['woodoo_odoo_partner_id'] )
|
||||||
|
? absint( $_POST['woodoo_odoo_partner_id'] )
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if ( $partner_id > 0 ) {
|
||||||
|
update_user_meta( $user_id, 'woodoo_odoo_partner_id', $partner_id );
|
||||||
|
|
||||||
|
// Resolve name from Odoo
|
||||||
|
$api = woodoo_api();
|
||||||
|
if ( $api ) {
|
||||||
|
$partners = $api->read( 'res.partner', [ $partner_id ], [ 'name', 'email' ] );
|
||||||
|
if ( ! empty( $partners[0]['name'] ) ) {
|
||||||
|
update_user_meta( $user_id, 'woodoo_odoo_partner_name', sanitize_text_field( $partners[0]['name'] ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete_user_meta( $user_id, 'woodoo_odoo_partner_id' );
|
||||||
|
delete_user_meta( $user_id, 'woodoo_odoo_partner_name' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Users List Column ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function add_users_column( array $columns ): array {
|
||||||
|
$columns['woodoo_odoo'] = __( 'Odoo Partner', 'woodoo' );
|
||||||
|
return $columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function render_users_column( string $value, string $column, int $user_id ): string {
|
||||||
|
if ( $column !== 'woodoo_odoo' ) return $value;
|
||||||
|
$partner_id = get_user_meta( $user_id, 'woodoo_odoo_partner_id', true );
|
||||||
|
$partner_name = get_user_meta( $user_id, 'woodoo_odoo_partner_name', true );
|
||||||
|
if ( $partner_id ) {
|
||||||
|
return '<span style="color:#2271b1;">' . esc_html( $partner_name ?: "#$partner_id" ) . '</span>';
|
||||||
|
}
|
||||||
|
return '<span style="color:#999;">—</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AJAX Handlers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function ajax_test_connection(): void {
|
||||||
|
check_ajax_referer( 'woodoo_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_woocommerce' ) ) wp_send_json_error( 'Forbidden', 403 );
|
||||||
|
|
||||||
|
$api = woodoo_api();
|
||||||
|
if ( ! $api ) {
|
||||||
|
wp_send_json_error( 'Credentials not saved yet. Save settings first.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success( $api->test_connection() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ajax_search_partners(): void {
|
||||||
|
check_ajax_referer( 'woodoo_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'edit_users' ) ) wp_send_json_error( 'Forbidden', 403 );
|
||||||
|
|
||||||
|
$q = sanitize_text_field( wp_unslash( $_POST['query'] ?? '' ) );
|
||||||
|
$api = woodoo_api();
|
||||||
|
if ( ! $api || empty( $q ) ) wp_send_json_success( [] );
|
||||||
|
|
||||||
|
$results = $api->search_read(
|
||||||
|
'res.partner',
|
||||||
|
[ '|', [ 'name', 'ilike', $q ], [ 'email', 'ilike', $q ] ],
|
||||||
|
[ 'id', 'name', 'email', 'is_company' ],
|
||||||
|
10
|
||||||
|
);
|
||||||
|
|
||||||
|
wp_send_json_success( $results );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ajax_link_customer(): void {
|
||||||
|
check_ajax_referer( 'woodoo_admin', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'edit_users' ) ) wp_send_json_error( 'Forbidden', 403 );
|
||||||
|
|
||||||
|
$user_id = absint( $_POST['user_id'] ?? 0 );
|
||||||
|
$partner_id = absint( $_POST['partner_id'] ?? 0 );
|
||||||
|
|
||||||
|
if ( ! $user_id || ! $partner_id ) wp_send_json_error( 'Invalid IDs' );
|
||||||
|
|
||||||
|
$api = woodoo_api();
|
||||||
|
if ( ! $api ) wp_send_json_error( 'API not configured' );
|
||||||
|
|
||||||
|
$partners = $api->read( 'res.partner', [ $partner_id ], [ 'name', 'email' ] );
|
||||||
|
if ( empty( $partners ) ) wp_send_json_error( 'Partner not found in Odoo' );
|
||||||
|
|
||||||
|
update_user_meta( $user_id, 'woodoo_odoo_partner_id', $partner_id );
|
||||||
|
update_user_meta( $user_id, 'woodoo_odoo_partner_name', sanitize_text_field( $partners[0]['name'] ) );
|
||||||
|
|
||||||
|
wp_send_json_success( [
|
||||||
|
'partner_id' => $partner_id,
|
||||||
|
'partner_name' => $partners[0]['name'],
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
}
|
||||||
259
includes/class-woodoo-api.php
Normal file
259
includes/class-woodoo-api.php
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Odoo JSON-RPC API client.
|
||||||
|
*
|
||||||
|
* Communicates with Odoo 19 via the /jsonrpc endpoint using the
|
||||||
|
* "common" (authenticate) and "object" (execute_kw) services.
|
||||||
|
* Uses WordPress's built-in HTTP API – no Composer required.
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
class WooDoo_API {
|
||||||
|
|
||||||
|
private string $url;
|
||||||
|
private string $db;
|
||||||
|
private string $username;
|
||||||
|
private string $api_key;
|
||||||
|
private ?int $uid = null;
|
||||||
|
|
||||||
|
/** Cache results for one minute to reduce round-trips */
|
||||||
|
private const CACHE_TTL = 60;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
string $url,
|
||||||
|
string $db,
|
||||||
|
string $username,
|
||||||
|
string $api_key
|
||||||
|
) {
|
||||||
|
$this->url = $url;
|
||||||
|
$this->db = $db;
|
||||||
|
$this->username = $username;
|
||||||
|
$this->api_key = $api_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Low-level JSON-RPC ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST to /jsonrpc.
|
||||||
|
*
|
||||||
|
* @param string $service "common" | "object"
|
||||||
|
* @param string $method e.g. "authenticate" | "execute_kw"
|
||||||
|
* @param array $args positional arguments
|
||||||
|
* @return mixed decoded result or WP_Error
|
||||||
|
*/
|
||||||
|
public function jsonrpc( string $service, string $method, array $args ): mixed {
|
||||||
|
$body = wp_json_encode( [
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'method' => 'call',
|
||||||
|
'id' => wp_rand( 1, 999999999 ),
|
||||||
|
'params' => [
|
||||||
|
'service' => $service,
|
||||||
|
'method' => $method,
|
||||||
|
'args' => $args,
|
||||||
|
],
|
||||||
|
] );
|
||||||
|
|
||||||
|
$response = wp_remote_post(
|
||||||
|
$this->url . '/jsonrpc',
|
||||||
|
[
|
||||||
|
'headers' => [ 'Content-Type' => 'application/json' ],
|
||||||
|
'body' => $body,
|
||||||
|
'timeout' => 30,
|
||||||
|
'sslverify' => apply_filters( 'woodoo_ssl_verify', true ),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||||
|
|
||||||
|
if ( isset( $data['error'] ) ) {
|
||||||
|
$msg = $data['error']['data']['message']
|
||||||
|
?? $data['error']['message']
|
||||||
|
?? 'Unknown Odoo error';
|
||||||
|
return new WP_Error( 'odoo_error', $msg, $data['error'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data['result'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Authentication ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate and cache the uid for this request lifecycle.
|
||||||
|
*/
|
||||||
|
public function authenticate(): ?int {
|
||||||
|
if ( $this->uid ) return $this->uid;
|
||||||
|
|
||||||
|
$result = $this->jsonrpc( 'common', 'authenticate', [
|
||||||
|
$this->db,
|
||||||
|
$this->username,
|
||||||
|
$this->api_key,
|
||||||
|
[],
|
||||||
|
] );
|
||||||
|
|
||||||
|
if ( is_wp_error( $result ) || ! is_int( $result ) || $result <= 0 ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->uid = $result;
|
||||||
|
return $this->uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ORM execute_kw wrapper ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call any ORM method via execute_kw.
|
||||||
|
*
|
||||||
|
* @param string $model e.g. 'res.partner'
|
||||||
|
* @param string $method e.g. 'search_read'
|
||||||
|
* @param array $args positional args (list of lists usually)
|
||||||
|
* @param array $kwargs keyword args (fields, limit, offset, etc.)
|
||||||
|
*/
|
||||||
|
public function execute_kw( string $model, string $method, array $args = [], array $kwargs = [] ): mixed {
|
||||||
|
$uid = $this->authenticate();
|
||||||
|
if ( ! $uid ) {
|
||||||
|
return new WP_Error( 'woodoo_auth', 'Could not authenticate with Odoo.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->jsonrpc( 'object', 'execute_kw', [
|
||||||
|
$this->db,
|
||||||
|
$uid,
|
||||||
|
$this->api_key,
|
||||||
|
$model,
|
||||||
|
$method,
|
||||||
|
$args,
|
||||||
|
$kwargs,
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Convenience helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function search_read(
|
||||||
|
string $model,
|
||||||
|
array $domain = [],
|
||||||
|
array $fields = [],
|
||||||
|
int $limit = 0,
|
||||||
|
int $offset = 0,
|
||||||
|
string $order = ''
|
||||||
|
): array {
|
||||||
|
$kwargs = [ 'fields' => $fields ];
|
||||||
|
if ( $limit > 0 ) $kwargs['limit'] = $limit;
|
||||||
|
if ( $offset > 0 ) $kwargs['offset'] = $offset;
|
||||||
|
if ( $order !== '' ) $kwargs['order'] = $order;
|
||||||
|
|
||||||
|
$result = $this->execute_kw( $model, 'search_read', [ $domain ], $kwargs );
|
||||||
|
return is_array( $result ) ? $result : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(
|
||||||
|
string $model,
|
||||||
|
array $domain = [],
|
||||||
|
int $limit = 0,
|
||||||
|
int $offset = 0,
|
||||||
|
string $order = ''
|
||||||
|
): array {
|
||||||
|
$kwargs = [];
|
||||||
|
if ( $limit > 0 ) $kwargs['limit'] = $limit;
|
||||||
|
if ( $offset > 0 ) $kwargs['offset'] = $offset;
|
||||||
|
if ( $order !== '' ) $kwargs['order'] = $order;
|
||||||
|
|
||||||
|
$result = $this->execute_kw( $model, 'search', [ $domain ], $kwargs );
|
||||||
|
return is_array( $result ) ? $result : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function read( string $model, array $ids, array $fields = [] ): array {
|
||||||
|
$kwargs = $fields ? [ 'fields' => $fields ] : [];
|
||||||
|
$result = $this->execute_kw( $model, 'read', [ $ids ], $kwargs );
|
||||||
|
return is_array( $result ) ? $result : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create( string $model, array $values ): ?int {
|
||||||
|
$result = $this->execute_kw( $model, 'create', [ $values ] );
|
||||||
|
return is_int( $result ) ? $result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function write( string $model, array $ids, array $values ): bool {
|
||||||
|
$result = $this->execute_kw( $model, 'write', [ $ids, $values ] );
|
||||||
|
return $result === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search_count( string $model, array $domain = [] ): int {
|
||||||
|
$result = $this->execute_kw( $model, 'search_count', [ $domain ] );
|
||||||
|
return is_int( $result ) ? $result : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unlink( string $model, array $ids ): bool {
|
||||||
|
$result = $this->execute_kw( $model, 'unlink', [ $ids ] );
|
||||||
|
return $result === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Partner helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a partner by email or create one.
|
||||||
|
* Returns the Odoo partner ID.
|
||||||
|
*/
|
||||||
|
public function find_or_create_partner( string $email, string $name, array $extra = [] ): ?int {
|
||||||
|
$found = $this->search( 'res.partner', [ [ 'email', '=', $email ] ], 1 );
|
||||||
|
if ( ! empty( $found ) ) {
|
||||||
|
return (int) $found[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->create( 'res.partner', array_merge( [
|
||||||
|
'name' => $name,
|
||||||
|
'email' => $email,
|
||||||
|
], $extra ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Product helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a product.product by its SKU (default_code).
|
||||||
|
* Returns product ID or null if not found.
|
||||||
|
*/
|
||||||
|
public function find_product_by_sku( string $sku ): ?int {
|
||||||
|
if ( empty( $sku ) ) return null;
|
||||||
|
$found = $this->search( 'product.product', [ [ 'default_code', '=', $sku ] ], 1 );
|
||||||
|
return ! empty( $found ) ? (int) $found[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Diagnostics ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connectivity and credentials.
|
||||||
|
* Returns ['success' => bool, 'message' => string, 'version' => string|null]
|
||||||
|
*/
|
||||||
|
public function test_connection(): array {
|
||||||
|
// Version check (no auth needed)
|
||||||
|
$version = $this->jsonrpc( 'common', 'version', [] );
|
||||||
|
if ( is_wp_error( $version ) ) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Cannot reach Odoo: ' . $version->get_error_message(),
|
||||||
|
'version' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ver_str = $version['server_version'] ?? 'unknown';
|
||||||
|
|
||||||
|
// Try authenticate
|
||||||
|
$uid = $this->authenticate();
|
||||||
|
if ( ! $uid ) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => "Reached Odoo {$ver_str} but authentication failed. Check username / API key.",
|
||||||
|
'version' => $ver_str,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => "Connected to Odoo {$ver_str} as UID {$uid}.",
|
||||||
|
'version' => $ver_str,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
318
includes/class-woodoo-calendar.php
Normal file
318
includes/class-woodoo-calendar.php
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* My Account – Book a Meeting tab.
|
||||||
|
* Reads booked slots from Odoo calendar.event and creates new bookings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
class WooDoo_Calendar {
|
||||||
|
|
||||||
|
const ENDPOINT = 'book-meeting';
|
||||||
|
|
||||||
|
public static function init(): void {
|
||||||
|
add_filter( 'woocommerce_account_menu_items', [ __CLASS__, 'add_menu_item' ], 30 );
|
||||||
|
add_action( 'woocommerce_account_' . self::ENDPOINT . '_endpoint', [ __CLASS__, 'render' ] );
|
||||||
|
add_filter( 'woocommerce_get_query_vars', [ __CLASS__, 'add_query_var' ] );
|
||||||
|
|
||||||
|
add_action( 'wp_ajax_woodoo_get_slots', [ __CLASS__, 'ajax_get_slots' ] );
|
||||||
|
add_action( 'wp_ajax_woodoo_book_slot', [ __CLASS__, 'ajax_book_slot' ] );
|
||||||
|
add_action( 'wp_ajax_woodoo_get_meetings',[ __CLASS__, 'ajax_get_meetings' ] );
|
||||||
|
add_action( 'wp_ajax_woodoo_cancel_meeting', [ __CLASS__, 'ajax_cancel_meeting' ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function add_query_var( array $vars ): array {
|
||||||
|
$vars[ self::ENDPOINT ] = self::ENDPOINT;
|
||||||
|
return $vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function add_menu_item( array $items ): array {
|
||||||
|
$items[ self::ENDPOINT ] = __( 'Book a Meeting', 'woodoo' );
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function render(): void {
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true );
|
||||||
|
$user = get_userdata( $user_id );
|
||||||
|
|
||||||
|
$api = woodoo_api();
|
||||||
|
if ( ! $api ) {
|
||||||
|
echo '<p class="woodoo-notice woodoo-error">' .
|
||||||
|
esc_html__( 'Booking is not available right now. Please contact support.', 'woodoo' ) .
|
||||||
|
'</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upcoming bookings for this user
|
||||||
|
$upcoming = [];
|
||||||
|
if ( $partner_id ) {
|
||||||
|
$now = gmdate( 'Y-m-d H:i:s' );
|
||||||
|
$upcoming = $api->search_read(
|
||||||
|
'calendar.event',
|
||||||
|
[
|
||||||
|
[ 'partner_ids', 'in', [ $partner_id ] ],
|
||||||
|
[ 'start', '>=', $now ],
|
||||||
|
[ 'active', '=', true ],
|
||||||
|
],
|
||||||
|
[ 'id', 'name', 'start', 'stop', 'location', 'description', 'videocall_location' ],
|
||||||
|
20,
|
||||||
|
0,
|
||||||
|
'start asc'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
include WOODOO_DIR . 'templates/myaccount-calendar.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Slot Calculation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns available booking slots for a given date (Y-m-d).
|
||||||
|
* Filters out already-booked times from Odoo calendar.
|
||||||
|
*
|
||||||
|
* @return array List of ['start' => 'Y-m-d H:i', 'end' => 'Y-m-d H:i']
|
||||||
|
*/
|
||||||
|
public static function get_available_slots( string $date ): array {
|
||||||
|
$available_days = (array) get_option( 'woodoo_available_days', [ 1, 2, 3, 4, 5 ] );
|
||||||
|
$from = get_option( 'woodoo_available_from', '09:00' );
|
||||||
|
$to = get_option( 'woodoo_available_to', '17:00' );
|
||||||
|
$duration = (int) get_option( 'woodoo_meeting_duration', 30 );
|
||||||
|
|
||||||
|
$ts = strtotime( $date );
|
||||||
|
$day_of_w = (int) gmdate( 'N', $ts ); // 1=Mon … 7=Sun
|
||||||
|
|
||||||
|
if ( ! in_array( $day_of_w, array_map( 'intval', $available_days ), true ) ) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build all slots for the day
|
||||||
|
$slots = [];
|
||||||
|
$from_ts = strtotime( $date . ' ' . $from . ':00' );
|
||||||
|
$to_ts = strtotime( $date . ' ' . $to . ':00' );
|
||||||
|
$step = $duration * 60;
|
||||||
|
|
||||||
|
for ( $start = $from_ts; $start + $step <= $to_ts; $start += $step ) {
|
||||||
|
$slots[] = [
|
||||||
|
'start' => gmdate( 'Y-m-d H:i', $start ),
|
||||||
|
'end' => gmdate( 'Y-m-d H:i', $start + $step ),
|
||||||
|
'start_ts' => $start,
|
||||||
|
'end_ts' => $start + $step,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch existing events from Odoo for this day
|
||||||
|
$api = woodoo_api();
|
||||||
|
if ( ! $api ) return $slots;
|
||||||
|
|
||||||
|
$day_start = $date . ' 00:00:00';
|
||||||
|
$day_end = $date . ' 23:59:59';
|
||||||
|
|
||||||
|
$cache_key = 'woodoo_booked_' . $date;
|
||||||
|
$booked = get_transient( $cache_key );
|
||||||
|
|
||||||
|
if ( false === $booked ) {
|
||||||
|
$booked = $api->search_read(
|
||||||
|
'calendar.event',
|
||||||
|
[
|
||||||
|
[ 'start', '>=', $day_start ],
|
||||||
|
[ 'start', '<=', $day_end ],
|
||||||
|
[ 'active', '=', true ],
|
||||||
|
],
|
||||||
|
[ 'id', 'start', 'stop' ],
|
||||||
|
100
|
||||||
|
);
|
||||||
|
set_transient( $cache_key, $booked, 120 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove slots that overlap with existing events
|
||||||
|
$available = array_filter( $slots, function ( $slot ) use ( $booked ) {
|
||||||
|
foreach ( $booked as $event ) {
|
||||||
|
$e_start = strtotime( $event['start'] );
|
||||||
|
$e_end = strtotime( $event['stop'] );
|
||||||
|
// Overlap: slot_start < e_end AND slot_end > e_start
|
||||||
|
if ( $slot['start_ts'] < $e_end && $slot['end_ts'] > $e_start ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} );
|
||||||
|
|
||||||
|
// Strip internal timestamps before returning
|
||||||
|
return array_values( array_map( fn($s) => [
|
||||||
|
'start' => $s['start'],
|
||||||
|
'end' => $s['end'],
|
||||||
|
], $available ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AJAX: Get Slots ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function ajax_get_slots(): void {
|
||||||
|
check_ajax_referer( 'woodoo_calendar', 'nonce' );
|
||||||
|
if ( ! is_user_logged_in() ) wp_send_json_error( 'Not logged in', 401 );
|
||||||
|
|
||||||
|
$date = sanitize_text_field( wp_unslash( $_POST['date'] ?? '' ) );
|
||||||
|
if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
|
||||||
|
wp_send_json_error( 'Invalid date format' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow past dates
|
||||||
|
if ( strtotime( $date ) < strtotime( 'today' ) ) {
|
||||||
|
wp_send_json_success( [] );
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success( self::get_available_slots( $date ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AJAX: Book Slot ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function ajax_book_slot(): void {
|
||||||
|
check_ajax_referer( 'woodoo_calendar', 'nonce' );
|
||||||
|
if ( ! is_user_logged_in() ) wp_send_json_error( 'Not logged in', 401 );
|
||||||
|
|
||||||
|
$start = sanitize_text_field( wp_unslash( $_POST['start'] ?? '' ) );
|
||||||
|
$end = sanitize_text_field( wp_unslash( $_POST['end'] ?? '' ) );
|
||||||
|
$notes = sanitize_textarea_field( wp_unslash( $_POST['notes'] ?? '' ) );
|
||||||
|
|
||||||
|
if ( ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $start )
|
||||||
|
|| ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $end ) ) {
|
||||||
|
wp_send_json_error( 'Invalid date/time format' );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( strtotime( $start ) < time() ) {
|
||||||
|
wp_send_json_error( __( 'Cannot book a slot in the past.', 'woodoo' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$user = get_userdata( $user_id );
|
||||||
|
$api = woodoo_api();
|
||||||
|
|
||||||
|
if ( ! $api ) wp_send_json_error( 'API not configured' );
|
||||||
|
|
||||||
|
// Find or create Odoo partner for the customer
|
||||||
|
$partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true );
|
||||||
|
if ( ! $partner_id ) {
|
||||||
|
$partner_id = $api->find_or_create_partner(
|
||||||
|
$user->user_email,
|
||||||
|
$user->display_name
|
||||||
|
);
|
||||||
|
if ( $partner_id ) {
|
||||||
|
update_user_meta( $user_id, 'woodoo_odoo_partner_id', $partner_id );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! $partner_id ) {
|
||||||
|
wp_send_json_error( __( 'Could not find your Odoo record.', 'woodoo' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the Odoo user (organizer) – the API user
|
||||||
|
$me_uid = $api->authenticate();
|
||||||
|
// Find res.users for this uid to get partner_id of organizer
|
||||||
|
$organizer_users = $api->search_read(
|
||||||
|
'res.users',
|
||||||
|
[ [ 'id', '=', $me_uid ] ],
|
||||||
|
[ 'partner_id' ],
|
||||||
|
1
|
||||||
|
);
|
||||||
|
$organizer_partner_id = $organizer_users[0]['partner_id'][0] ?? null;
|
||||||
|
|
||||||
|
$title = get_option( 'woodoo_meeting_title_prefix', 'Meeting via WooDoo' )
|
||||||
|
. ' – ' . $user->display_name;
|
||||||
|
|
||||||
|
$partner_ids = array_filter( [ $partner_id, $organizer_partner_id ] );
|
||||||
|
|
||||||
|
$event_vals = [
|
||||||
|
'name' => $title,
|
||||||
|
'start' => $start . ':00',
|
||||||
|
'stop' => $end . ':00',
|
||||||
|
'description' => $notes ?: false,
|
||||||
|
'partner_ids' => array_map( fn($id) => [ 4, $id, 0 ], $partner_ids ),
|
||||||
|
'privacy' => 'confidential',
|
||||||
|
];
|
||||||
|
|
||||||
|
$event_id = $api->create( 'calendar.event', $event_vals );
|
||||||
|
|
||||||
|
if ( ! $event_id ) {
|
||||||
|
wp_send_json_error( __( 'Could not create meeting in Odoo. Please try again.', 'woodoo' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate booked slots cache for this date
|
||||||
|
$date = substr( $start, 0, 10 );
|
||||||
|
delete_transient( 'woodoo_booked_' . $date );
|
||||||
|
|
||||||
|
wp_send_json_success( [
|
||||||
|
'event_id' => $event_id,
|
||||||
|
'start' => $start,
|
||||||
|
'end' => $end,
|
||||||
|
'message' => __( 'Meeting booked! You will receive a confirmation from Odoo.', 'woodoo' ),
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AJAX: Get User's Meetings ─────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function ajax_get_meetings(): void {
|
||||||
|
check_ajax_referer( 'woodoo_calendar', 'nonce' );
|
||||||
|
if ( ! is_user_logged_in() ) wp_send_json_error( 'Not logged in', 401 );
|
||||||
|
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true );
|
||||||
|
if ( ! $partner_id ) wp_send_json_success( [] );
|
||||||
|
|
||||||
|
$api = woodoo_api();
|
||||||
|
if ( ! $api ) wp_send_json_error( 'API not configured' );
|
||||||
|
|
||||||
|
$now = gmdate( 'Y-m-d H:i:s' );
|
||||||
|
$meetings = $api->search_read(
|
||||||
|
'calendar.event',
|
||||||
|
[
|
||||||
|
[ 'partner_ids', 'in', [ $partner_id ] ],
|
||||||
|
[ 'start', '>=', $now ],
|
||||||
|
[ 'active', '=', true ],
|
||||||
|
],
|
||||||
|
[ 'id', 'name', 'start', 'stop', 'location', 'videocall_location' ],
|
||||||
|
20,
|
||||||
|
0,
|
||||||
|
'start asc'
|
||||||
|
);
|
||||||
|
|
||||||
|
wp_send_json_success( $meetings );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AJAX: Cancel Meeting ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function ajax_cancel_meeting(): void {
|
||||||
|
check_ajax_referer( 'woodoo_calendar', 'nonce' );
|
||||||
|
if ( ! is_user_logged_in() ) wp_send_json_error( 'Not logged in', 401 );
|
||||||
|
|
||||||
|
$event_id = absint( $_POST['event_id'] ?? 0 );
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true );
|
||||||
|
|
||||||
|
if ( ! $event_id || ! $partner_id ) wp_send_json_error( 'Invalid request' );
|
||||||
|
|
||||||
|
$api = woodoo_api();
|
||||||
|
if ( ! $api ) wp_send_json_error( 'API not configured' );
|
||||||
|
|
||||||
|
// Verify the event belongs to this user
|
||||||
|
$events = $api->search(
|
||||||
|
'calendar.event',
|
||||||
|
[ [ 'id', '=', $event_id ], [ 'partner_ids', 'in', [ $partner_id ] ] ],
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( empty( $events ) ) {
|
||||||
|
wp_send_json_error( __( 'Meeting not found.', 'woodoo' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlink the customer partner from the event (don't delete the event)
|
||||||
|
$api->write(
|
||||||
|
'calendar.event',
|
||||||
|
[ $event_id ],
|
||||||
|
[ 'partner_ids' => [ [ 3, $partner_id, 0 ] ] ]
|
||||||
|
);
|
||||||
|
|
||||||
|
wp_send_json_success( [ 'message' => __( 'Meeting cancelled.', 'woodoo' ) ] );
|
||||||
|
}
|
||||||
|
}
|
||||||
219
includes/class-woodoo-invoices.php
Normal file
219
includes/class-woodoo-invoices.php
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* My Account – Invoices tab.
|
||||||
|
* Shows the customer's Odoo invoices and handles PDF proxy-download.
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
class WooDoo_Invoices {
|
||||||
|
|
||||||
|
const ENDPOINT = 'odoo-invoices';
|
||||||
|
|
||||||
|
public static function init(): void {
|
||||||
|
add_filter( 'woocommerce_account_menu_items', [ __CLASS__, 'add_menu_item' ], 20 );
|
||||||
|
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' ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function add_query_var( array $vars ): array {
|
||||||
|
$vars[ self::ENDPOINT ] = self::ENDPOINT;
|
||||||
|
return $vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function add_menu_item( array $items ): array {
|
||||||
|
$new = [];
|
||||||
|
foreach ( $items as $key => $label ) {
|
||||||
|
$new[ $key ] = $label;
|
||||||
|
if ( $key === 'orders' ) {
|
||||||
|
$new[ self::ENDPOINT ] = __( 'Odoo Invoices', 'woodoo' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $new;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function render(): void {
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true );
|
||||||
|
|
||||||
|
if ( ! $partner_id ) {
|
||||||
|
echo '<p class="woodoo-notice">' .
|
||||||
|
esc_html__( 'Your account is not yet linked to Odoo. Please contact us.', 'woodoo' ) .
|
||||||
|
'</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$api = woodoo_api();
|
||||||
|
if ( ! $api ) {
|
||||||
|
echo '<p class="woodoo-notice woodoo-error">' .
|
||||||
|
esc_html__( 'Odoo integration is not configured. Please contact support.', 'woodoo' ) .
|
||||||
|
'</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page parameter
|
||||||
|
$paged = max( 1, (int) ( $_GET['invoice_page'] ?? 1 ) ); // phpcs:ignore
|
||||||
|
$per_page = 10;
|
||||||
|
$offset = ( $paged - 1 ) * $per_page;
|
||||||
|
|
||||||
|
$domain = [
|
||||||
|
[ 'partner_id', '=', $partner_id ],
|
||||||
|
[ 'move_type', '=', 'out_invoice' ],
|
||||||
|
[ 'state', '!=', 'cancel' ],
|
||||||
|
];
|
||||||
|
|
||||||
|
$cache_key = 'woodoo_invoices_' . $partner_id . '_' . $paged;
|
||||||
|
$invoices = get_transient( $cache_key );
|
||||||
|
|
||||||
|
if ( false === $invoices ) {
|
||||||
|
$invoices = $api->search_read(
|
||||||
|
'account.move',
|
||||||
|
$domain,
|
||||||
|
[
|
||||||
|
'id', 'name', 'invoice_date', 'invoice_date_due',
|
||||||
|
'amount_total', 'amount_residual', 'payment_state',
|
||||||
|
'currency_id', 'state',
|
||||||
|
],
|
||||||
|
$per_page,
|
||||||
|
$offset,
|
||||||
|
'invoice_date desc'
|
||||||
|
);
|
||||||
|
set_transient( $cache_key, $invoices, 60 );
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = $api->search_count( 'account.move', $domain );
|
||||||
|
$num_pages = (int) ceil( $total / $per_page );
|
||||||
|
|
||||||
|
include WOODOO_DIR . 'templates/myaccount-invoices.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PDF Proxy ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download an invoice PDF from Odoo and serve it to the logged-in user.
|
||||||
|
* ?action=woodoo_invoice_pdf&invoice_id=123&nonce=...
|
||||||
|
*/
|
||||||
|
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' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! is_user_logged_in() ) {
|
||||||
|
wp_die( esc_html__( 'Please log in.', 'woodoo' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice_id = absint( $_GET['invoice_id'] ?? 0 );
|
||||||
|
if ( ! $invoice_id ) wp_die( 'Invalid invoice ID.' );
|
||||||
|
|
||||||
|
// 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' ) );
|
||||||
|
|
||||||
|
$api = woodoo_api();
|
||||||
|
if ( ! $api ) wp_die( 'API not configured.' );
|
||||||
|
|
||||||
|
$invoices = $api->search(
|
||||||
|
'account.move',
|
||||||
|
[ [ 'id', '=', $invoice_id ], [ 'partner_id', '=', $partner_id ] ],
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( empty( $invoices ) ) {
|
||||||
|
wp_die( esc_html__( 'Invoice not found.', 'woodoo' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the PDF report from Odoo
|
||||||
|
$odoo_url = rtrim( get_option( 'woodoo_odoo_url', '' ), '/' );
|
||||||
|
$pdf_url = $odoo_url . '/report/pdf/account.report_invoice/' . $invoice_id;
|
||||||
|
|
||||||
|
// Build auth cookie by first doing session authenticate
|
||||||
|
$auth_response = wp_remote_post(
|
||||||
|
$odoo_url . '/web/session/authenticate',
|
||||||
|
[
|
||||||
|
'headers' => [ 'Content-Type' => 'application/json' ],
|
||||||
|
'body' => wp_json_encode( [
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'method' => 'call',
|
||||||
|
'id' => 1,
|
||||||
|
'params' => [
|
||||||
|
'db' => get_option( 'woodoo_odoo_db' ),
|
||||||
|
'login' => get_option( 'woodoo_odoo_username' ),
|
||||||
|
'password' => get_option( 'woodoo_odoo_api_key' ),
|
||||||
|
],
|
||||||
|
] ),
|
||||||
|
'timeout' => 20,
|
||||||
|
'sslverify' => apply_filters( 'woodoo_ssl_verify', true ),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_wp_error( $auth_response ) ) {
|
||||||
|
wp_die( esc_html__( 'Could not authenticate with Odoo.', 'woodoo' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract session cookie
|
||||||
|
$raw_headers = wp_remote_retrieve_headers( $auth_response );
|
||||||
|
$session_cookie = '';
|
||||||
|
if ( isset( $raw_headers['set-cookie'] ) ) {
|
||||||
|
$cookie_header = is_array( $raw_headers['set-cookie'] )
|
||||||
|
? $raw_headers['set-cookie'][0]
|
||||||
|
: $raw_headers['set-cookie'];
|
||||||
|
preg_match( '/session_id=([^;]+)/', $cookie_header, $m );
|
||||||
|
if ( isset( $m[1] ) ) $session_cookie = 'session_id=' . $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdf_response = wp_remote_get(
|
||||||
|
$pdf_url,
|
||||||
|
[
|
||||||
|
'headers' => $session_cookie ? [ 'Cookie' => $session_cookie ] : [],
|
||||||
|
'timeout' => 60,
|
||||||
|
'sslverify' => apply_filters( 'woodoo_ssl_verify', true ),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_wp_error( $pdf_response ) ) {
|
||||||
|
wp_die( esc_html__( 'Could not retrieve invoice PDF.', 'woodoo' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdf_body = wp_remote_retrieve_body( $pdf_response );
|
||||||
|
|
||||||
|
header( 'Content-Type: application/pdf' );
|
||||||
|
header( 'Content-Disposition: attachment; filename="invoice-' . $invoice_id . '.pdf"' );
|
||||||
|
header( 'Content-Length: ' . strlen( $pdf_body ) );
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||||
|
echo $pdf_body;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function payment_state_label( string $state ): string {
|
||||||
|
$labels = [
|
||||||
|
'not_paid' => __( 'Unpaid', 'woodoo' ),
|
||||||
|
'partial' => __( 'Partial', 'woodoo' ),
|
||||||
|
'in_payment'=> __( 'In Payment', 'woodoo' ),
|
||||||
|
'paid' => __( 'Paid', 'woodoo' ),
|
||||||
|
'reversed' => __( 'Reversed', 'woodoo' ),
|
||||||
|
];
|
||||||
|
return $labels[ $state ] ?? ucfirst( $state );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function payment_state_class( string $state ): string {
|
||||||
|
return match ( $state ) {
|
||||||
|
'paid' => 'woodoo-badge--green',
|
||||||
|
'not_paid' => 'woodoo-badge--red',
|
||||||
|
'partial' => 'woodoo-badge--orange',
|
||||||
|
'in_payment'=> 'woodoo-badge--blue',
|
||||||
|
default => 'woodoo-badge--grey',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
254
includes/class-woodoo-orders.php
Normal file
254
includes/class-woodoo-orders.php
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* WooCommerce Order → Odoo Sales Order sync.
|
||||||
|
*
|
||||||
|
* When a WC order reaches "processing" status, this class:
|
||||||
|
* 1. Finds or creates the customer in Odoo (res.partner)
|
||||||
|
* 2. Creates a sale.order with all line items
|
||||||
|
* 3. Stores the Odoo SO ID in WC order meta
|
||||||
|
*
|
||||||
|
* Products are matched by SKU (default_code). Unknown SKUs become
|
||||||
|
* generic service lines so nothing is lost.
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
class WooDoo_Orders {
|
||||||
|
|
||||||
|
public static function init(): void {
|
||||||
|
if ( ! get_option( 'woodoo_sync_orders', 1 ) ) return;
|
||||||
|
|
||||||
|
// Trigger sync when order becomes "processing"
|
||||||
|
add_action( 'woocommerce_order_status_processing', [ __CLASS__, 'sync_order' ], 10, 2 );
|
||||||
|
|
||||||
|
// Also show Odoo SO reference in the WC order admin screen
|
||||||
|
add_action( 'woocommerce_admin_order_data_after_order_details', [ __CLASS__, 'show_so_meta' ] );
|
||||||
|
|
||||||
|
// Manual re-sync button (order actions)
|
||||||
|
add_filter( 'woocommerce_order_actions', [ __CLASS__, 'add_order_action' ] );
|
||||||
|
add_action( 'woocommerce_order_action_woodoo_resync', [ __CLASS__, 'manual_resync' ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Sync Entry ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function sync_order( int $order_id, WC_Order $order ): void {
|
||||||
|
// Skip if already synced
|
||||||
|
if ( $order->get_meta( '_woodoo_so_id' ) ) return;
|
||||||
|
|
||||||
|
$api = woodoo_api();
|
||||||
|
if ( ! $api ) {
|
||||||
|
$order->add_order_note( __( '[WooDoo] Odoo integration not configured – order not synced.', 'woodoo' ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$partner_id = self::resolve_partner( $api, $order );
|
||||||
|
if ( ! $partner_id ) {
|
||||||
|
$order->add_order_note( __( '[WooDoo] Could not find/create Odoo partner – order not synced.', 'woodoo' ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$so_id = self::create_sales_order( $api, $order, $partner_id );
|
||||||
|
|
||||||
|
if ( $so_id ) {
|
||||||
|
$order->update_meta_data( '_woodoo_so_id', $so_id );
|
||||||
|
$order->update_meta_data( '_woodoo_partner_id', $partner_id );
|
||||||
|
$order->save_meta_data();
|
||||||
|
|
||||||
|
$odoo_url = get_option( 'woodoo_odoo_url', '' );
|
||||||
|
$so_link = $odoo_url ? sprintf(
|
||||||
|
'<a href="%s/odoo/sales/%d" target="_blank">#%d</a>',
|
||||||
|
esc_url( $odoo_url ),
|
||||||
|
$so_id,
|
||||||
|
$so_id
|
||||||
|
) : "#$so_id";
|
||||||
|
|
||||||
|
/* translators: %s: Odoo SO link */
|
||||||
|
$order->add_order_note( sprintf( __( '[WooDoo] Sales Order created in Odoo: %s', 'woodoo' ), $so_link ) );
|
||||||
|
|
||||||
|
// If the WC customer has no partner_id set yet, save it
|
||||||
|
$wc_user_id = $order->get_customer_id();
|
||||||
|
if ( $wc_user_id && ! get_user_meta( $wc_user_id, 'woodoo_odoo_partner_id', true ) ) {
|
||||||
|
update_user_meta( $wc_user_id, 'woodoo_odoo_partner_id', $partner_id );
|
||||||
|
// Count synced orders
|
||||||
|
$count = (int) get_user_meta( $wc_user_id, 'woodoo_so_count', true );
|
||||||
|
update_user_meta( $wc_user_id, 'woodoo_so_count', $count + 1 );
|
||||||
|
} else {
|
||||||
|
$count = (int) get_user_meta( $wc_user_id, 'woodoo_so_count', true );
|
||||||
|
update_user_meta( $wc_user_id, 'woodoo_so_count', $count + 1 );
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$order->add_order_note( __( '[WooDoo] Failed to create Odoo Sales Order. Check WooDoo logs.', 'woodoo' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Partner Resolution ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static function resolve_partner( WooDoo_API $api, WC_Order $order ): ?int {
|
||||||
|
// 1. Try from WC user meta first
|
||||||
|
$wc_user_id = $order->get_customer_id();
|
||||||
|
if ( $wc_user_id ) {
|
||||||
|
$partner_id = (int) get_user_meta( $wc_user_id, 'woodoo_odoo_partner_id', true );
|
||||||
|
if ( $partner_id > 0 ) return $partner_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Search Odoo by billing email
|
||||||
|
$email = $order->get_billing_email();
|
||||||
|
if ( $email ) {
|
||||||
|
$found = $api->search( 'res.partner', [ [ 'email', '=', $email ] ], 1 );
|
||||||
|
if ( ! empty( $found ) ) return (int) $found[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create new partner from order billing info
|
||||||
|
$name = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
|
||||||
|
if ( ! $name ) $name = $order->get_billing_company() ?: 'WooCommerce Customer';
|
||||||
|
|
||||||
|
$vals = [
|
||||||
|
'name' => $name,
|
||||||
|
'email' => $email ?: false,
|
||||||
|
'phone' => $order->get_billing_phone() ?: false,
|
||||||
|
'street' => $order->get_billing_address_1() ?: false,
|
||||||
|
'street2' => $order->get_billing_address_2() ?: false,
|
||||||
|
'city' => $order->get_billing_city() ?: false,
|
||||||
|
'zip' => $order->get_billing_postcode() ?: false,
|
||||||
|
'is_company' => (bool) $order->get_billing_company(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Country
|
||||||
|
$country_code = $order->get_billing_country();
|
||||||
|
if ( $country_code ) {
|
||||||
|
$countries = $api->search_read(
|
||||||
|
'res.country',
|
||||||
|
[ [ 'code', '=', $country_code ] ],
|
||||||
|
[ 'id' ],
|
||||||
|
1
|
||||||
|
);
|
||||||
|
if ( ! empty( $countries ) ) {
|
||||||
|
$vals['country_id'] = $countries[0]['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove false values
|
||||||
|
$vals = array_filter( $vals, fn( $v ) => $v !== false );
|
||||||
|
|
||||||
|
return $api->create( 'res.partner', $vals );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sales Order Creation ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static function create_sales_order( WooDoo_API $api, WC_Order $order, int $partner_id ): ?int {
|
||||||
|
$lines = self::build_order_lines( $api, $order );
|
||||||
|
|
||||||
|
$so_vals = [
|
||||||
|
'partner_id' => $partner_id,
|
||||||
|
'client_order_ref' => 'WC-' . $order->get_order_number(),
|
||||||
|
'note' => $order->get_customer_note() ?: false,
|
||||||
|
'order_line' => $lines,
|
||||||
|
];
|
||||||
|
|
||||||
|
// If there's a WC-created date set it
|
||||||
|
$date_created = $order->get_date_created();
|
||||||
|
if ( $date_created ) {
|
||||||
|
$so_vals['date_order'] = $date_created->date( 'Y-m-d H:i:s' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove false values
|
||||||
|
$so_vals = array_filter( $so_vals, fn( $v ) => $v !== false );
|
||||||
|
|
||||||
|
return $api->create( 'sale.order', $so_vals );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Order Line Builder ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static function build_order_lines( WooDoo_API $api, WC_Order $order ): array {
|
||||||
|
$lines = [];
|
||||||
|
|
||||||
|
foreach ( $order->get_items() as $item ) {
|
||||||
|
/** @var WC_Order_Item_Product $item */
|
||||||
|
$sku = '';
|
||||||
|
$product = $item->get_product();
|
||||||
|
if ( $product ) $sku = $product->get_sku();
|
||||||
|
|
||||||
|
$odoo_product_id = $api->find_product_by_sku( $sku );
|
||||||
|
|
||||||
|
$line = [
|
||||||
|
'name' => $item->get_name(),
|
||||||
|
'product_uom_qty' => (float) $item->get_quantity(),
|
||||||
|
'price_unit' => (float) $order->get_item_subtotal( $item, false, false ),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ( $odoo_product_id ) {
|
||||||
|
$line['product_id'] = $odoo_product_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command.create: [0, 0, vals]
|
||||||
|
$lines[] = [ 0, 0, $line ];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shipping line
|
||||||
|
$shipping_total = (float) $order->get_shipping_total();
|
||||||
|
if ( $shipping_total > 0 ) {
|
||||||
|
$lines[] = [ 0, 0, [
|
||||||
|
'name' => sprintf(
|
||||||
|
__( 'Shipping: %s', 'woodoo' ),
|
||||||
|
$order->get_shipping_method()
|
||||||
|
),
|
||||||
|
'product_uom_qty' => 1,
|
||||||
|
'price_unit' => $shipping_total,
|
||||||
|
] ];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discount line (if any coupon was applied)
|
||||||
|
$discount = (float) $order->get_discount_total();
|
||||||
|
if ( $discount > 0 ) {
|
||||||
|
$lines[] = [ 0, 0, [
|
||||||
|
'name' => __( 'Discount', 'woodoo' ),
|
||||||
|
'product_uom_qty' => 1,
|
||||||
|
'price_unit' => -$discount,
|
||||||
|
] ];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin UI ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function show_so_meta( WC_Order $order ): void {
|
||||||
|
$so_id = $order->get_meta( '_woodoo_so_id' );
|
||||||
|
$partner_id = $order->get_meta( '_woodoo_partner_id' );
|
||||||
|
|
||||||
|
if ( ! $so_id && ! $partner_id ) return;
|
||||||
|
|
||||||
|
$odoo_url = get_option( 'woodoo_odoo_url', '' );
|
||||||
|
|
||||||
|
echo '<div class="woodoo-order-meta" style="margin-top:12px;">';
|
||||||
|
echo '<h4 style="margin:0 0 4px;">' . esc_html__( 'Odoo', 'woodoo' ) . '</h4>';
|
||||||
|
|
||||||
|
if ( $so_id ) {
|
||||||
|
$link = $odoo_url
|
||||||
|
? sprintf( '<a href="%s/odoo/sales/%d" target="_blank">SO #%d</a>', esc_url( $odoo_url ), (int) $so_id, (int) $so_id )
|
||||||
|
: 'SO #' . esc_html( $so_id );
|
||||||
|
echo '<p><strong>' . esc_html__( 'Sales Order:', 'woodoo' ) . '</strong> ' . wp_kses_post( $link ) . '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $partner_id ) {
|
||||||
|
$link = $odoo_url
|
||||||
|
? sprintf( '<a href="%s/odoo/contacts/%d" target="_blank">Partner #%d</a>', esc_url( $odoo_url ), (int) $partner_id, (int) $partner_id )
|
||||||
|
: 'Partner #' . esc_html( $partner_id );
|
||||||
|
echo '<p><strong>' . esc_html__( 'Partner:', 'woodoo' ) . '</strong> ' . wp_kses_post( $link ) . '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function add_order_action( array $actions ): array {
|
||||||
|
$actions['woodoo_resync'] = __( 'Re-sync to Odoo (WooDoo)', 'woodoo' );
|
||||||
|
return $actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function manual_resync( WC_Order $order ): void {
|
||||||
|
// Clear previous SO ID so sync runs fresh
|
||||||
|
$order->delete_meta_data( '_woodoo_so_id' );
|
||||||
|
$order->save_meta_data();
|
||||||
|
self::sync_order( $order->get_id(), $order );
|
||||||
|
}
|
||||||
|
}
|
||||||
146
templates/myaccount-calendar.php
Normal file
146
templates/myaccount-calendar.php
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Template: My Account – Book a Meeting
|
||||||
|
*
|
||||||
|
* Variables available:
|
||||||
|
* $upcoming array Upcoming booked meetings from Odoo
|
||||||
|
* $partner_id int Odoo partner ID (0 if unlinked)
|
||||||
|
* $user WP_User Current user
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
wp_enqueue_style( 'woodoo-frontend' );
|
||||||
|
wp_enqueue_script( 'woodoo-frontend' );
|
||||||
|
wp_localize_script( 'woodoo-frontend', 'WooDooCalendar', [
|
||||||
|
'nonce' => wp_create_nonce( 'woodoo_calendar' ),
|
||||||
|
'ajax_url' => admin_url( 'admin-ajax.php' ),
|
||||||
|
'i18n' => [
|
||||||
|
'loading' => __( 'Loading…', 'woodoo' ),
|
||||||
|
'no_slots' => __( 'No available slots for this day.', 'woodoo' ),
|
||||||
|
'select_slot' => __( 'Select a time slot', 'woodoo' ),
|
||||||
|
'booking' => __( 'Booking…', 'woodoo' ),
|
||||||
|
'book_btn' => __( 'Book this slot', 'woodoo' ),
|
||||||
|
'cancel_confirm' => __( 'Cancel this meeting?', 'woodoo' ),
|
||||||
|
'cancelling' => __( 'Cancelling…', 'woodoo' ),
|
||||||
|
'cancelled' => __( 'Meeting cancelled.', 'woodoo' ),
|
||||||
|
'error' => __( 'Something went wrong. Please try again.', 'woodoo' ),
|
||||||
|
],
|
||||||
|
] );
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="woodoo-calendar-wrap">
|
||||||
|
|
||||||
|
<!-- ── Upcoming Meetings ──────────────────────────────────────────── -->
|
||||||
|
<div class="woodoo-section">
|
||||||
|
<h3><?php esc_html_e( 'Your Upcoming Meetings', 'woodoo' ); ?></h3>
|
||||||
|
<div id="woodoo-meetings-list">
|
||||||
|
<?php if ( empty( $upcoming ) ) : ?>
|
||||||
|
<p class="woodoo-empty"><?php esc_html_e( 'No upcoming meetings scheduled.', 'woodoo' ); ?></p>
|
||||||
|
<?php else : ?>
|
||||||
|
<div class="woodoo-meetings-grid">
|
||||||
|
<?php foreach ( $upcoming as $event ) :
|
||||||
|
$start_ts = strtotime( $event['start'] );
|
||||||
|
$end_ts = strtotime( $event['stop'] );
|
||||||
|
?>
|
||||||
|
<div class="woodoo-meeting-card" data-event-id="<?php echo esc_attr( $event['id'] ); ?>">
|
||||||
|
<div class="woodoo-meeting-card__date">
|
||||||
|
<span class="woodoo-meeting-card__day"><?php echo esc_html( gmdate( 'd', $start_ts ) ); ?></span>
|
||||||
|
<span class="woodoo-meeting-card__month"><?php echo esc_html( gmdate( 'M', $start_ts ) ); ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="woodoo-meeting-card__info">
|
||||||
|
<strong><?php echo esc_html( $event['name'] ); ?></strong>
|
||||||
|
<span class="woodoo-meeting-card__time">
|
||||||
|
<?php printf(
|
||||||
|
'%s – %s',
|
||||||
|
esc_html( gmdate( 'H:i', $start_ts ) ),
|
||||||
|
esc_html( gmdate( 'H:i', $end_ts ) )
|
||||||
|
); ?>
|
||||||
|
</span>
|
||||||
|
<?php if ( ! empty( $event['videocall_location'] ) ) : ?>
|
||||||
|
<a href="<?php echo esc_url( $event['videocall_location'] ); ?>"
|
||||||
|
target="_blank" rel="noopener" class="woodoo-meeting-card__video">
|
||||||
|
<?php esc_html_e( 'Join Video Call', 'woodoo' ); ?>
|
||||||
|
</a>
|
||||||
|
<?php elseif ( ! empty( $event['location'] ) ) : ?>
|
||||||
|
<span class="woodoo-meeting-card__loc">
|
||||||
|
<?php echo esc_html( $event['location'] ); ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="woodoo-meeting-card__actions">
|
||||||
|
<button class="woodoo-cancel-meeting woodoo-btn woodoo-btn--sm woodoo-btn--outline"
|
||||||
|
data-event-id="<?php echo esc_attr( $event['id'] ); ?>">
|
||||||
|
<?php esc_html_e( 'Cancel', 'woodoo' ); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Book New Meeting ───────────────────────────────────────────── -->
|
||||||
|
<div class="woodoo-section">
|
||||||
|
<h3><?php esc_html_e( 'Book a New Meeting', 'woodoo' ); ?></h3>
|
||||||
|
<p class="woodoo-desc">
|
||||||
|
<?php printf(
|
||||||
|
esc_html__( 'Slots are %d minutes. Pick a date to see availability.', 'woodoo' ),
|
||||||
|
(int) get_option( 'woodoo_meeting_duration', 30 )
|
||||||
|
); ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="woodoo-booking-form">
|
||||||
|
|
||||||
|
<!-- Date picker -->
|
||||||
|
<div class="woodoo-field">
|
||||||
|
<label for="woodoo-booking-date">
|
||||||
|
<?php esc_html_e( 'Select Date', 'woodoo' ); ?>
|
||||||
|
</label>
|
||||||
|
<input type="date"
|
||||||
|
id="woodoo-booking-date"
|
||||||
|
name="booking_date"
|
||||||
|
min="<?php echo esc_attr( gmdate( 'Y-m-d', strtotime( '+1 day' ) ) ); ?>"
|
||||||
|
max="<?php echo esc_attr( gmdate( 'Y-m-d', strtotime( '+60 days' ) ) ); ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slot list (populated via JS) -->
|
||||||
|
<div id="woodoo-slots-wrap" style="display:none;">
|
||||||
|
<div class="woodoo-field">
|
||||||
|
<label><?php esc_html_e( 'Available Slots', 'woodoo' ); ?></label>
|
||||||
|
<div id="woodoo-slots-grid" class="woodoo-slots-grid">
|
||||||
|
<!-- Slots injected by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Booking confirmation -->
|
||||||
|
<div id="woodoo-booking-confirm" style="display:none;">
|
||||||
|
<div class="woodoo-field">
|
||||||
|
<label for="woodoo-booking-notes">
|
||||||
|
<?php esc_html_e( 'Notes (optional)', 'woodoo' ); ?>
|
||||||
|
</label>
|
||||||
|
<textarea id="woodoo-booking-notes" name="notes" rows="3"
|
||||||
|
placeholder="<?php esc_attr_e( 'What would you like to discuss?', 'woodoo' ); ?>"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="woodoo-selected-slot-display">
|
||||||
|
<strong><?php esc_html_e( 'Selected:', 'woodoo' ); ?></strong>
|
||||||
|
<span id="woodoo-selected-slot-label"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="woodoo-book-btn" class="woodoo-btn woodoo-btn--primary" disabled>
|
||||||
|
<?php esc_html_e( 'Confirm Booking', 'woodoo' ); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success message -->
|
||||||
|
<div id="woodoo-booking-success" class="woodoo-notice woodoo-success" style="display:none;"></div>
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
<div id="woodoo-booking-error" class="woodoo-notice woodoo-error" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
119
templates/myaccount-invoices.php
Normal file
119
templates/myaccount-invoices.php
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Template: My Account – Odoo Invoices
|
||||||
|
*
|
||||||
|
* Variables available:
|
||||||
|
* $invoices array List of invoice records from Odoo
|
||||||
|
* $total int Total invoice count
|
||||||
|
* $paged int Current page
|
||||||
|
* $num_pages int Total pages
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
// Enqueue frontend assets
|
||||||
|
wp_enqueue_style( 'woodoo-frontend' );
|
||||||
|
wp_enqueue_script( 'woodoo-frontend' );
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="woodoo-invoices">
|
||||||
|
<h3><?php esc_html_e( 'Your Invoices', 'woodoo' ); ?></h3>
|
||||||
|
|
||||||
|
<?php if ( empty( $invoices ) ) : ?>
|
||||||
|
<p class="woodoo-empty">
|
||||||
|
<?php esc_html_e( 'No invoices found.', 'woodoo' ); ?>
|
||||||
|
</p>
|
||||||
|
<?php else : ?>
|
||||||
|
|
||||||
|
<div class="woodoo-table-wrap">
|
||||||
|
<table class="woodoo-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Invoice #', 'woodoo' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Date', 'woodoo' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Due Date', 'woodoo' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Total', 'woodoo' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Balance Due', 'woodoo' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Status', 'woodoo' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Download', 'woodoo' ); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ( $invoices as $inv ) :
|
||||||
|
$currency = is_array( $inv['currency_id'] ) ? $inv['currency_id'][1] : '';
|
||||||
|
$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' ) );
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td class="woodoo-inv-number">
|
||||||
|
<?php echo esc_html( $inv['name'] ?: "INV-{$inv['id']}" ); ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php echo $inv['invoice_date']
|
||||||
|
? esc_html( date_i18n( get_option( 'date_format' ), strtotime( $inv['invoice_date'] ) ) )
|
||||||
|
: '—'; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php
|
||||||
|
if ( $inv['invoice_date_due'] ) {
|
||||||
|
$due_ts = strtotime( $inv['invoice_date_due'] );
|
||||||
|
$overdue = $pay_state === 'not_paid' && $due_ts < time();
|
||||||
|
$class = $overdue ? ' class="woodoo-overdue"' : '';
|
||||||
|
echo '<span' . $class . '>' . // phpcs:ignore WordPress.Security.EscapeOutput
|
||||||
|
esc_html( date_i18n( get_option( 'date_format' ), $due_ts ) ) .
|
||||||
|
'</span>';
|
||||||
|
if ( $overdue ) echo ' <span class="woodoo-badge woodoo-badge--red">' . esc_html__( 'Overdue', 'woodoo' ) . '</span>';
|
||||||
|
} else {
|
||||||
|
echo '—';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
<td class="woodoo-amount">
|
||||||
|
<?php echo esc_html( number_format_i18n( $inv['amount_total'], 2 ) . ' ' . $currency ); ?>
|
||||||
|
</td>
|
||||||
|
<td class="woodoo-amount">
|
||||||
|
<?php echo esc_html( number_format_i18n( $inv['amount_residual'], 2 ) . ' ' . $currency ); ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="woodoo-badge <?php echo esc_attr( $badge_class ); ?>">
|
||||||
|
<?php echo esc_html( $badge_label ); ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="<?php echo esc_url( $pdf_url ); ?>"
|
||||||
|
class="woodoo-btn woodoo-btn--sm"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener">
|
||||||
|
<?php esc_html_e( 'PDF', 'woodoo' ); ?>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ( $num_pages > 1 ) :
|
||||||
|
$base_url = wc_get_account_endpoint_url( WooDoo_Invoices::ENDPOINT );
|
||||||
|
?>
|
||||||
|
<nav class="woodoo-pagination">
|
||||||
|
<?php for ( $p = 1; $p <= $num_pages; $p++ ) : ?>
|
||||||
|
<?php if ( $p === $paged ) : ?>
|
||||||
|
<span class="woodoo-page-current"><?php echo esc_html( $p ); ?></span>
|
||||||
|
<?php else : ?>
|
||||||
|
<a href="<?php echo esc_url( add_query_arg( 'invoice_page', $p, $base_url ) ); ?>">
|
||||||
|
<?php echo esc_html( $p ); ?>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</nav>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
45
uninstall.php
Normal file
45
uninstall.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Runs when the plugin is deleted from the WP admin.
|
||||||
|
* Removes all options and the customer-links table.
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
|
||||||
|
|
||||||
|
// Remove plugin options
|
||||||
|
$options = [
|
||||||
|
'woodoo_odoo_url',
|
||||||
|
'woodoo_odoo_db',
|
||||||
|
'woodoo_odoo_username',
|
||||||
|
'woodoo_odoo_api_key',
|
||||||
|
'woodoo_meeting_duration',
|
||||||
|
'woodoo_available_days',
|
||||||
|
'woodoo_available_from',
|
||||||
|
'woodoo_available_to',
|
||||||
|
'woodoo_meeting_title_prefix',
|
||||||
|
'woodoo_sync_orders',
|
||||||
|
];
|
||||||
|
foreach ( $options as $opt ) {
|
||||||
|
delete_option( $opt );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove user meta
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( "DELETE FROM {$wpdb->usermeta} WHERE meta_key IN (
|
||||||
|
'woodoo_odoo_partner_id',
|
||||||
|
'woodoo_odoo_partner_name',
|
||||||
|
'woodoo_so_count'
|
||||||
|
)" );
|
||||||
|
|
||||||
|
// Remove order meta
|
||||||
|
$wpdb->query( "DELETE FROM {$wpdb->postmeta} WHERE meta_key IN (
|
||||||
|
'_woodoo_so_id',
|
||||||
|
'_woodoo_partner_id'
|
||||||
|
)" );
|
||||||
|
|
||||||
|
// Drop custom table
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}woodoo_customer_links" );
|
||||||
|
|
||||||
|
// Clear any transients
|
||||||
|
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_woodoo_%'" );
|
||||||
|
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_woodoo_%'" );
|
||||||
123
woodoo.php
Normal file
123
woodoo.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: WooDoo – WooCommerce & Odoo Integration
|
||||||
|
* Plugin URI: https://github.com/
|
||||||
|
* Description: Connects WooCommerce to a self-hosted Odoo 19 instance. Customer linking, invoices, meeting booking, and automatic sales order creation.
|
||||||
|
* Version: 1.0.0
|
||||||
|
* Requires at least: 6.0
|
||||||
|
* Requires PHP: 8.1
|
||||||
|
* Author: WooDoo
|
||||||
|
* License: GPL v2 or later
|
||||||
|
* Text Domain: woodoo
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
define( 'WOODOO_VERSION', '1.0.0' );
|
||||||
|
define( 'WOODOO_FILE', __FILE__ );
|
||||||
|
define( 'WOODOO_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
|
define( 'WOODOO_URL', plugin_dir_url( __FILE__ ) );
|
||||||
|
|
||||||
|
// ── Auto-load includes ──────────────────────────────────────────────────────
|
||||||
|
foreach ( [
|
||||||
|
'class-woodoo-api',
|
||||||
|
'class-woodoo-admin',
|
||||||
|
'class-woodoo-invoices',
|
||||||
|
'class-woodoo-calendar',
|
||||||
|
'class-woodoo-orders',
|
||||||
|
] as $file ) {
|
||||||
|
require_once WOODOO_DIR . 'includes/' . $file . '.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Frontend Assets ───────────────────────────────────────────────────────────
|
||||||
|
add_action( 'wp_enqueue_scripts', 'woodoo_register_frontend_assets' );
|
||||||
|
|
||||||
|
function woodoo_register_frontend_assets(): void {
|
||||||
|
wp_register_style(
|
||||||
|
'woodoo-frontend',
|
||||||
|
WOODOO_URL . 'assets/css/woodoo.css',
|
||||||
|
[],
|
||||||
|
WOODOO_VERSION
|
||||||
|
);
|
||||||
|
wp_register_script(
|
||||||
|
'woodoo-frontend',
|
||||||
|
WOODOO_URL . 'assets/js/woodoo-frontend.js',
|
||||||
|
[],
|
||||||
|
WOODOO_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boot ─────────────────────────────────────────────────────────────────────
|
||||||
|
add_action( 'plugins_loaded', 'woodoo_init' );
|
||||||
|
|
||||||
|
function woodoo_init(): void {
|
||||||
|
if ( ! class_exists( 'WooCommerce' ) ) {
|
||||||
|
add_action( 'admin_notices', fn() =>
|
||||||
|
print '<div class="notice notice-error"><p>' .
|
||||||
|
esc_html__( 'WooDoo requires WooCommerce to be active.', 'woodoo' ) .
|
||||||
|
'</p></div>'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WooDoo_Admin::init();
|
||||||
|
WooDoo_Invoices::init();
|
||||||
|
WooDoo_Calendar::init();
|
||||||
|
WooDoo_Orders::init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a configured WooDoo_API instance using saved options.
|
||||||
|
* Returns null if credentials are not set.
|
||||||
|
*/
|
||||||
|
function woodoo_api(): ?WooDoo_API {
|
||||||
|
static $instance = null;
|
||||||
|
if ( $instance ) return $instance;
|
||||||
|
|
||||||
|
$opts = [
|
||||||
|
'url' => get_option( 'woodoo_odoo_url', '' ),
|
||||||
|
'db' => get_option( 'woodoo_odoo_db', '' ),
|
||||||
|
'username' => get_option( 'woodoo_odoo_username', '' ),
|
||||||
|
'api_key' => get_option( 'woodoo_odoo_api_key', '' ),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ( $opts as $v ) {
|
||||||
|
if ( empty( $v ) ) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new WooDoo_API(
|
||||||
|
rtrim( $opts['url'], '/' ),
|
||||||
|
$opts['db'],
|
||||||
|
$opts['username'],
|
||||||
|
$opts['api_key']
|
||||||
|
);
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activation / Deactivation ─────────────────────────────────────────────
|
||||||
|
register_activation_hook( __FILE__, 'woodoo_activate' );
|
||||||
|
function woodoo_activate(): void {
|
||||||
|
// Create DB table for customer links (supplement to user meta)
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'woodoo_customer_links';
|
||||||
|
$charset = $wpdb->get_charset_collate();
|
||||||
|
|
||||||
|
$sql = "CREATE TABLE IF NOT EXISTS {$table} (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
wp_user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
odoo_partner_id INT UNSIGNED NOT NULL,
|
||||||
|
linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY wp_user_id (wp_user_id),
|
||||||
|
KEY odoo_partner_id (odoo_partner_id)
|
||||||
|
) {$charset};";
|
||||||
|
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
|
dbDelta( $sql );
|
||||||
|
|
||||||
|
// Register endpoints and flush so My Account tabs appear immediately
|
||||||
|
WC()->query->init_query_vars();
|
||||||
|
WC()->query->add_endpoints();
|
||||||
|
flush_rewrite_rules();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user