Plugin Directory

Changeset 3603307


Ignore:
Timestamp:
07/10/2026 06:49:28 PM (4 months ago)
Author:
pfhowe
Message:

Release 0.4.45

Location:
pfh-shop-manager
Files:
19 added
1 deleted
7 edited

Legend:

Unmodified
Added
Removed
  • pfh-shop-manager/trunk/assets/app.css

    r3599418 r3603307  
    66.wsm-section,.wsm-shipping-panel,.wsm-help{background:#fff;border:1px solid var(--wsm-border);border-radius:6px;margin-bottom:20px}.wsm-section>h2,.wsm-shipping-panel>h2{font-size:17px;margin:0;padding:18px 20px;border-bottom:1px solid var(--wsm-border)}.wsm-action-row{display:flex;justify-content:space-between;align-items:center;gap:20px;padding:20px;border-bottom:1px solid var(--wsm-border)}.wsm-action-row:last-child{border-bottom:0}.wsm-action-row p,.wsm-upload p{margin:3px 0 0;color:var(--wsm-muted)}.wsm-upload{padding:22px;display:grid;justify-items:start;gap:14px}.wsm-upload input{border:1px dashed #aebbc0;padding:22px;border-radius:6px;width:min(520px,100%)}
    77.wsm-subheading{display:flex;align-items:center;justify-content:space-between;padding:17px 20px;border-bottom:1px solid var(--wsm-border)}.wsm-subheading h2{font-size:17px;margin:0}.wsm-tabs{display:flex;gap:2px;border-bottom:1px solid var(--wsm-border);margin-bottom:18px}.wsm-tabs button{border:0;background:transparent;padding:11px 16px;color:var(--wsm-muted);font-weight:700;cursor:pointer;border-bottom:2px solid transparent}.wsm-tabs button.active{color:var(--wsm-green);border-color:var(--wsm-green)}.wsm-shipping-panel{display:none}.wsm-shipping-panel.active{display:block}.wsm-list-row{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:15px 20px;border-bottom:1px solid var(--wsm-border)}.wsm-list-row:last-child{border-bottom:0}.wsm-list-row p{margin:2px 0 0;color:var(--wsm-muted)}.wsm-settings-form{padding:20px;display:grid;gap:16px}.wsm-check{display:flex!important;grid-template-columns:none!important;align-items:center;gap:9px;font-weight:500!important}.wsm-check input{width:17px!important;height:17px!important;min-height:0!important}.wsm-settings-form .wsm-button{justify-self:start;margin-top:8px}
    8 .wsm-periods{display:flex;align-items:end;gap:9px;flex-wrap:wrap;margin-bottom:18px}.wsm-periods>button:not(.wsm-button){min-height:40px;border:1px solid #cbd4d8;background:#fff;padding:8px 12px;cursor:pointer}.wsm-periods>button.active{background:var(--wsm-green-soft);border-color:#76bfa9;color:var(--wsm-green-dark)}.wsm-periods label{display:grid;gap:3px;color:var(--wsm-muted);font-size:12px}.wsm-periods input{height:40px;border:1px solid #cbd4d8;border-radius:6px;padding:0 9px}.wsm-summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));background:#fff;border:1px solid var(--wsm-border);border-radius:6px;margin-bottom:18px;overflow:hidden}.wsm-summary div{padding:15px 18px;border-right:1px solid var(--wsm-border);border-bottom:1px solid var(--wsm-border)}.wsm-summary div:last-child{border-right:0}.wsm-summary span{display:block;color:var(--wsm-muted);font-size:12px}.wsm-summary strong{font-size:20px}#wsmCogsSummary{grid-template-columns:repeat(7,minmax(0,1fr))}#wsmCogsSummary div{padding:15px 16px}#wsmCogsSummary strong{font-size:19px}@media(max-width:1100px){#wsmCogsSummary{grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}#wsmCogsSummary strong{font-size:20px}}.wsm-help{display:grid;grid-template-columns:220px 1fr;min-height:420px}.wsm-help nav{padding:12px;border-right:1px solid var(--wsm-border);display:flex;flex-direction:column;gap:3px}.wsm-help nav button{text-align:left;border:0;background:transparent;padding:10px;border-radius:5px;cursor:pointer}.wsm-help nav button.active{background:var(--wsm-green-soft);color:var(--wsm-green-dark);font-weight:700}.wsm-help article{padding:28px;max-width:760px}.wsm-help article h2{margin-top:0;font-size:22px}.wsm-help article h3{font-size:16px;margin-top:24px}.wsm-help article li{margin-bottom:8px}.wsm-readme{white-space:pre-wrap;overflow:auto;max-height:680px;margin:0;padding:16px;border:1px solid var(--wsm-border);border-radius:6px;background:#f8faf9;color:var(--wsm-text);font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace}
     8.wsm-periods{display:flex;align-items:end;gap:9px;flex-wrap:wrap;margin-bottom:18px}.wsm-periods>button:not(.wsm-button){min-height:40px;border:1px solid #cbd4d8;background:#fff;padding:8px 12px;cursor:pointer}.wsm-periods>button.active{background:var(--wsm-green-soft);border-color:#76bfa9;color:var(--wsm-green-dark)}.wsm-periods label{display:grid;gap:3px;color:var(--wsm-muted);font-size:12px}.wsm-periods input{height:40px;border:1px solid #cbd4d8;border-radius:6px;padding:0 9px}.wsm-summary{display:grid;grid-template-columns:repeat(5,1fr);background:#fff;border:1px solid var(--wsm-border);border-radius:6px;margin-bottom:18px}.wsm-summary div{padding:15px 18px;border-right:1px solid var(--wsm-border)}.wsm-summary div:last-child{border:0}.wsm-summary span{display:block;color:var(--wsm-muted);font-size:12px}.wsm-summary strong{font-size:20px}.wsm-help{display:grid;grid-template-columns:220px 1fr;min-height:420px}.wsm-help nav{padding:12px;border-right:1px solid var(--wsm-border);display:flex;flex-direction:column;gap:3px}.wsm-help nav button{text-align:left;border:0;background:transparent;padding:10px;border-radius:5px;cursor:pointer}.wsm-help nav button.active{background:var(--wsm-green-soft);color:var(--wsm-green-dark);font-weight:700}.wsm-help article{padding:28px;max-width:760px}.wsm-help article h2{margin-top:0;font-size:22px}.wsm-help article h3{font-size:16px;margin-top:24px}.wsm-help article li{margin-bottom:8px}.wsm-readme{white-space:pre-wrap;overflow:auto;max-height:680px;margin:0;padding:16px;border:1px solid var(--wsm-border);border-radius:6px;background:#f8faf9;color:var(--wsm-text);font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace}
    99.wsm-drawer{position:fixed;z-index:60;inset:0 0 0 auto;width:min(var(--wsm-drawer),100%);background:#fff;box-shadow:-12px 0 35px rgba(15,35,42,.14);transform:translateX(105%);transition:transform .2s ease;display:flex;flex-direction:column}.wsm-drawer.open{transform:translateX(0)}.wsm-drawer-header{display:flex;justify-content:space-between;align-items:center;padding:19px 20px;border-bottom:1px solid var(--wsm-border)}.wsm-drawer-header h2{margin:0;font-size:19px}.wsm-drawer form{display:flex;flex-direction:column;min-height:0;flex:1}.wsm-drawer #wsmDrawerBody{padding:20px;overflow:auto;display:grid;gap:16px}.wsm-drawer-actions{margin-top:auto;padding:15px 20px;border-top:1px solid var(--wsm-border);display:grid;grid-template-columns:auto 1fr auto auto;gap:9px}.wsm-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.wsm-form-grid .wide{grid-column:1/-1}.wsm-field textarea{min-height:88px;resize:vertical}.wsm-image-picker{display:flex;align-items:center;gap:14px}.wsm-image-picker img{width:82px;height:82px;object-fit:cover;border-radius:6px;border:1px solid var(--wsm-border)}.wsm-scrim{position:fixed;z-index:50;inset:0;background:rgba(13,25,29,.35)}.wsm-empty{text-align:center!important;color:var(--wsm-muted);padding:36px!important}
    1010@media(max-width:900px){.wsm-sidebar{transform:translateX(-105%);transition:transform .2s ease}.wsm-app.menu-open .wsm-sidebar{transform:translateX(0)}.wsm-main{margin-left:0;padding:74px 18px 40px}.wsm-mobile-header{position:fixed;z-index:20;display:flex;align-items:center;gap:12px;inset:0 0 auto 0;height:56px;padding:0 15px;background:#fff;border-bottom:1px solid var(--wsm-border)}.wsm-mobile-header button{border:0;background:transparent;font-size:22px}.wsm-summary{grid-template-columns:repeat(2,1fr)}.wsm-summary div{border-bottom:1px solid var(--wsm-border)}.wsm-help{grid-template-columns:1fr}.wsm-help nav{border-right:0;border-bottom:1px solid var(--wsm-border);flex-direction:row;overflow:auto}.wsm-help nav button{white-space:nowrap}}
    … …  
    5353.wsm-users-panel{display:none;background:#fff;border:1px solid var(--wsm-border);border-radius:6px;overflow:hidden}.wsm-users-panel.active{display:block}.wsm-users-panel .wsm-table-wrap{border:0;border-radius:0}.wsm-system-label{display:block;color:var(--wsm-muted);font-size:11px;font-weight:500}.wsm-permission-list{display:flex;flex-wrap:wrap;gap:5px;max-width:620px}.wsm-permission-list span{display:inline-block;padding:2px 7px;border-radius:999px;background:var(--wsm-green-soft);color:var(--wsm-green-dark);font-size:11px;font-weight:650}.wsm-group-permissions{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:7px;padding:12px;border:1px solid var(--wsm-border);border-radius:6px;background:#fafbfb}
    5454.wsm-activity-toolbar{margin:0;padding:16px 20px;border-bottom:1px solid var(--wsm-border)}.wsm-activity-table{min-width:680px}.wsm-activity-event{display:inline-block;min-width:64px;padding:3px 8px;border-radius:999px;font-size:12px;font-weight:750;text-align:center}.wsm-activity-event.login{background:var(--wsm-green-soft);color:var(--wsm-green-dark)}.wsm-activity-event.logout{background:#edf1f2;color:#46545c}.wsm-activity-pagination{margin:0;padding:14px 20px;border-top:1px solid var(--wsm-border)}
     55#wsmSalesSummary{grid-template-columns:repeat(6,1fr)}
    5556#wsmCustomerCount{margin-top:5px;font-size:12px}.wsm-customer-table{min-width:1080px}.wsm-customer-table td:nth-child(4),.wsm-customer-table td:nth-child(5),.wsm-customer-table td:nth-child(6){white-space:nowrap}.wsm-customer-table a{color:var(--wsm-green);text-decoration:none}.wsm-customer-table a:hover{text-decoration:underline}.wsm-rank{display:inline-grid;place-items:center;min-width:38px;padding:4px 7px;border-radius:999px;background:var(--wsm-green-soft);color:var(--wsm-green-dark)}#wsmCustomerSummary{grid-template-columns:repeat(4,1fr)}
    5657#wsmBestSellerCount{margin-top:5px;font-size:12px}.wsm-best-seller-table{min-width:1020px}.wsm-best-seller-table td:nth-child(4),.wsm-best-seller-table td:nth-child(5),.wsm-best-seller-table td:nth-child(6),.wsm-best-seller-table td:nth-child(7){white-space:nowrap}#wsmBestSellerSummary{grid-template-columns:repeat(4,1fr)}
    57 .wsm-product-select-field{min-width:min(520px,100%)}.wsm-cogs-table{min-width:980px}.wsm-cogs-table input{width:100%;min-width:112px;border:1px solid #cbd4d8;border-radius:6px;padding:8px 9px;background:#fff}.wsm-cogs-table td:nth-child(3) input{min-width:220px}#wsmCogsSummary{grid-template-columns:repeat(7,minmax(0,1fr))}
     58.wsm-cogs-toolbar{align-items:end;flex-wrap:wrap}.wsm-cogs-toolbar .wsm-field{flex:0 0 auto}.wsm-cogs-toolbar .wsm-cogs-status-field{width:170px}.wsm-cogs-toolbar .wsm-cogs-product-search{align-self:end;width:min(280px,100%)}.wsm-cogs-toolbar .wsm-product-select-field{flex:1 1 460px;min-width:min(460px,100%)}.wsm-cogs-toolbar .wsm-button{align-self:end}.wsm-product-select-field{min-width:min(520px,100%)}.wsm-cogs-table{min-width:980px}.wsm-cogs-table input{width:100%;min-width:112px;border:1px solid #cbd4d8;border-radius:6px;padding:8px 9px;background:#fff}.wsm-cogs-table td:nth-child(3) input{min-width:220px}#wsmCogsSummary{grid-template-columns:repeat(6,1fr)}#wsmCogsSummary span{min-height:36px;line-height:1.35}#wsmCogsSummary strong{display:block}
    5859@media(max-width:900px){.wsm-settings-grid{grid-template-columns:repeat(2,minmax(160px,1fr))}}
    5960@media(max-width:600px){.wsm-pdf-modal{padding:0}.wsm-pdf-window{width:100%;height:100vh;border-radius:0}}
    60 @media(max-width:600px){.wsm-heading-actions{align-items:stretch;flex-direction:column}.wsm-cash-filters>*{flex:1 1 100%}.wsm-cash-filters input,.wsm-cash-filters select{width:100%}.wsm-settings-grid{grid-template-columns:1fr}.wsm-form-actions{align-items:stretch;flex-direction:column}.wsm-form-actions .wsm-button{width:100%}}
     61@media(max-width:600px){.wsm-cogs-toolbar .wsm-cogs-status-field,.wsm-cogs-toolbar .wsm-cogs-product-search,.wsm-cogs-toolbar .wsm-product-select-field{width:100%;min-width:0;flex:1 1 100%}.wsm-heading-actions{align-items:stretch;flex-direction:column}.wsm-cash-filters>*{flex:1 1 100%}.wsm-cash-filters input,.wsm-cash-filters select{width:100%}.wsm-settings-grid{grid-template-columns:1fr}.wsm-form-actions{align-items:stretch;flex-direction:column}.wsm-form-actions .wsm-button{width:100%}}
    6162@media(max-width:600px){.wsm-group-permissions{grid-template-columns:1fr}.wsm-users-panel .wsm-subheading{align-items:stretch;flex-direction:column;gap:12px}.wsm-users-panel .wsm-subheading .wsm-button{width:100%}}
    6263.wsm-alert.success{color:#14532d;background:#ecfdf3;border:1px solid #a7e1bb}.wsm-login-links{margin:20px 0 0;text-align:center}.wsm-login-links a{color:var(--wsm-green-dark);font-weight:650}.wsm-login-card>a.wsm-button{display:flex;align-items:center;justify-content:center;text-decoration:none}
  • pfh-shop-manager/trunk/assets/app.js

    r3599418 r3603307  
    44  if (!window.pfhsmConfig || !document.getElementById('wsmApp')) return;
    55
    6   const state = { bootstrap: null, products: [], productPage: 1, productPages: 1, categories: [], shipping: null, sales: null, customers: null, customerView: [], bestSellers: null, bestSellerView: [], cashFlow: null, cogs: null, cogsSearch: null, users: null, groups: null, loginActivity: null, loginActivityPage: 1, loginActivityPages: 1, session: null, sessionSettings: null, drawer: null };
     6  const state = { bootstrap: null, products: [], productPage: 1, productPages: 1, categories: [], shipping: null, sales: null, salesPage: 1, salesPages: 1, customers: null, customerView: [], customerPage: 1, customerPages: 1, bestSellers: null, bestSellerView: [], bestSellerPage: 1, bestSellerPages: 1, cashFlow: null, cogs: null, cogsSearch: null, users: null, groups: null, loginActivity: null, loginActivityPage: 1, loginActivityPages: 1, session: null, sessionSettings: null, drawer: null };
    77  const API_TIMEOUT_MS = 15000;
     8  const REPORT_PAGE_SIZE = 20;
    89  const $ = (selector, root = document) => root.querySelector(selector);
    910  const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
    … …  
    201202  function showProductFailure(message) {
    202203    $('#wsmProductCount').textContent = 'Products could not be loaded';
    203     $('#wsmProductRows').innerHTML = `<tr><td colspan="7" class="wsm-empty"><p>${esc(message)}</p><button class="wsm-button secondary" type="button" data-retry-products>Try again</button></td></tr>`;
     204    $('#wsmProductRows').innerHTML = `<tr><td colspan="6" class="wsm-empty"><p>${esc(message)}</p><button class="wsm-button secondary" type="button" data-retry-products>Try again</button></td></tr>`;
    204205  }
    205206
    206207  function showProductLoading() {
    207208    $('#wsmProductCount').textContent = 'Loading products...';
    208     $('#wsmProductRows').innerHTML = '<tr><td colspan="7" class="wsm-empty">Loading products...</td></tr>';
     209    $('#wsmProductRows').innerHTML = '<tr><td colspan="6" class="wsm-empty">Loading products...</td></tr>';
    209210  }
    210211
    … …  
    225226  }
    226227
     228  function reportPageCount(rows) {
     229    return Math.max(1, Math.ceil(rows.length / REPORT_PAGE_SIZE));
     230  }
     231
     232  function reportPageRows(rows, page) {
     233    const start = (page - 1) * REPORT_PAGE_SIZE;
     234    return rows.slice(start, start + REPORT_PAGE_SIZE);
     235  }
     236
     237  function updateReportPager(prefix, page, pages) {
     238    const label = $(`#wsm${prefix}Page`);
     239    const previous = $(`#wsmPrev${prefix}`);
     240    const next = $(`#wsmNext${prefix}`);
     241    if (label) label.textContent = `Page ${page} of ${pages}`;
     242    if (previous) previous.disabled = page <= 1;
     243    if (next) next.disabled = page >= pages;
     244  }
     245
     246  function cogsSkuStartDateKey(product) {
     247    const date = String(product?.sku || '').slice(0, 10);
     248    if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return '';
     249    const parsed = new Date(`${date}T00:00:00Z`);
     250    return parsed.getUTCFullYear() === Number(date.slice(0, 4)) && parsed.getUTCMonth() + 1 === Number(date.slice(5, 7)) && parsed.getUTCDate() === Number(date.slice(8, 10)) ? date : '';
     251  }
     252
    227253  function sortCogsProducts(items) {
    228254    return [...items].sort((left, right) => {
     255      const leftDate = cogsSkuStartDateKey(left);
     256      const rightDate = cogsSkuStartDateKey(right);
     257      if (!leftDate || !rightDate) {
     258        if (leftDate !== rightDate) return leftDate ? -1 : 1;
     259      }
     260      const dateCompare = leftDate.localeCompare(rightDate);
     261      if (dateCompare) return dateCompare;
     262      const skuCompare = String(left.sku || '').localeCompare(String(right.sku || ''), undefined, { numeric: true, sensitivity: 'base' });
     263      if (skuCompare) return skuCompare;
    229264      const nameCompare = String(left.name || '').localeCompare(String(right.name || ''), undefined, { numeric: true, sensitivity: 'base' });
    230       if (nameCompare) return nameCompare;
    231       const skuCompare = String(left.sku || '').localeCompare(String(right.sku || ''), undefined, { numeric: true, sensitivity: 'base' });
    232       return skuCompare || Number(left.id || 0) - Number(right.id || 0);
     265      return nameCompare || Number(left.id || 0) - Number(right.id || 0);
    233266    });
    234267  }
    … …  
    283316      $('#wsmPrevProducts').disabled = state.productPage <= 1;
    284317      $('#wsmNextProducts').disabled = state.productPage >= state.productPages;
    285       rows.innerHTML = result.items.length ? result.items.map(productRow).join('') : '<tr><td colspan="7" class="wsm-empty">No products found.</td></tr>';
     318      rows.innerHTML = result.items.length ? result.items.map(productRow).join('') : '<tr><td colspan="6" class="wsm-empty">No products found.</td></tr>';
    286319    } catch (error) {
    287320      showProductFailure(error.message);
    … …  
    292325    const quantity = product.manage_stock ? Number(product.stock_quantity || 0) : product.stock_status === 'instock' ? 'In stock' : 'Out of stock';
    293326    const stockClass = product.stock_status === 'outofstock' || quantity === 0 ? 'none' : (typeof quantity === 'number' && quantity < 10 ? 'low' : 'good');
    294     const cogs = product.current_cogs === null || product.current_cogs === undefined || product.current_cogs === '' ? 'Not set' : money(product.current_cogs);
    295     return `<tr><td><div class="wsm-product-cell"><img src="${esc(product.image)}" alt=""><div><strong>${esc(product.name)}</strong><small>${product.sale_price ? `${money(product.sale_price)} sale price` : 'Simple product'}</small></div></div></td><td>${esc(product.sku || 'Not set')}</td><td>${money(product.regular_price)}</td><td>${esc(cogs)}</td><td><span class="wsm-stock ${stockClass}">${esc(quantity)}</span></td><td><span class="wsm-status ${esc(product.status)}">${product.status === 'publish' ? 'Published' : esc(product.status)}</span></td><td><button class="wsm-link-button" data-edit-product="${product.id}">Edit</button></td></tr>`;
     327    return `<tr><td><div class="wsm-product-cell"><img src="${esc(product.image)}" alt=""><div><strong>${esc(product.name)}</strong><small>${product.sale_price ? `${money(product.sale_price)} sale price` : 'Simple product'}</small></div></div></td><td>${esc(product.sku || 'Not set')}</td><td>${money(product.regular_price)}</td><td><span class="wsm-stock ${stockClass}">${esc(quantity)}</span></td><td><span class="wsm-status ${esc(product.status)}">${product.status === 'publish' ? 'Published' : esc(product.status)}</span></td><td><button class="wsm-link-button" data-edit-product="${product.id}">Edit</button></td></tr>`;
    296328  }
    297329
    … …  
    390422      } while (page <= pages);
    391423      state.sales = combined;
    392       const s = combined.summary;
    393       $('#wsmSalesSummary').innerHTML = [['Orders', s.orders], ['Items sold', s.items], ['Gross', money(s.gross, combined.currency)], ['Tax', money(s.tax, combined.currency)], ['Net', money(s.net, combined.currency)], ['Refunds', money(s.refunds, combined.currency)]].map(([label, value]) => `<div><span>${label}</span><strong>${value}</strong></div>`).join('');
    394       $('#wsmSalesRows').innerHTML = combined.rows.length ? combined.rows.map(row => `<tr><td>${row.pdf_url ? `<a class="wsm-order-link" href="${esc(row.pdf_url)}" target="_blank" rel="noopener" data-order-pdf="${row.id}">#${row.id}</a>` : `#${row.id}`}</td><td>${esc(row.date)}</td><td>${esc(row.customer)}</td><td>${esc(row.status)}</td><td>${row.items}</td><td>${money(row.gross, combined.currency)}</td><td>${money(row.tax, combined.currency)}</td><td>${money(row.net, combined.currency)}</td><td>${money(row.refunds, combined.currency)}</td></tr>`).join('') : '<tr><td colspan="9" class="wsm-empty">No orders in this period.</td></tr>';
     424      state.salesPage = 1;
     425      renderSales();
    395426      $('#wsmExportSales').disabled = false;
    396427      if (combined.skipped) notice(`${combined.skipped} order${combined.skipped === 1 ? ' could' : 's could'} not be included in this report.`, 'error');
    397428    } catch (error) {
     429      state.sales = null;
     430      state.salesPages = 1;
     431      updateReportPager('Sales', 1, 1);
    398432      $('#wsmSalesSummary').innerHTML = '';
    399433      $('#wsmSalesRows').innerHTML = `<tr><td colspan="9" class="wsm-empty">${esc(error.message)}</td></tr>`;
    … …  
    404438      $('#wsmRunSales').textContent = 'Run report';
    405439    }
     440  }
     441
     442  function renderSales() {
     443    if (!state.sales) return;
     444    const combined = state.sales;
     445    state.salesPages = reportPageCount(combined.rows);
     446    state.salesPage = Math.min(Math.max(1, state.salesPage), state.salesPages);
     447    const s = combined.summary;
     448    $('#wsmSalesSummary').innerHTML = [['Orders', s.orders], ['Items sold', s.items], ['Gross', money(s.gross, combined.currency)], ['Tax', money(s.tax, combined.currency)], ['Net', money(s.net, combined.currency)], ['Refunds', money(s.refunds, combined.currency)]].map(([label, value]) => `<div><span>${label}</span><strong>${value}</strong></div>`).join('');
     449    const rows = reportPageRows(combined.rows, state.salesPage);
     450    $('#wsmSalesRows').innerHTML = rows.length ? rows.map(row => `<tr><td>${row.pdf_url ? `<a class="wsm-order-link" href="${esc(row.pdf_url)}" target="_blank" rel="noopener" data-order-pdf="${row.id}">#${row.id}</a>` : `#${row.id}`}</td><td>${esc(row.date)}</td><td>${esc(row.customer)}</td><td>${esc(row.status)}</td><td>${row.items}</td><td>${money(row.gross, combined.currency)}</td><td>${money(row.tax, combined.currency)}</td><td>${money(row.net, combined.currency)}</td><td>${money(row.refunds, combined.currency)}</td></tr>`).join('') : '<tr><td colspan="9" class="wsm-empty">No orders in this period.</td></tr>';
     451    updateReportPager('Sales', state.salesPage, state.salesPages);
    406452  }
    407453
    … …  
    434480    $('#wsmCustomerCategory').disabled = true;
    435481    $('#wsmExportCustomers').disabled = true;
     482    state.customerPage = 1;
     483    state.customerPages = 1;
     484    updateReportPager('Customers', 1, 1);
    436485    $('#wsmCustomerRows').innerHTML = '<tr><td colspan="8" class="wsm-empty">Add a customer to create their account and send a setup email.</td></tr>';
    437486  }
    … …  
    509558    const rows = ranked.filter(row => !search || row.name.toLowerCase().includes(search) || row.email.toLowerCase().includes(search));
    510559    state.customerView = rows;
     560    state.customerPages = reportPageCount(rows);
     561    state.customerPage = Math.min(Math.max(1, state.customerPage), state.customerPages);
     562    const pageRows = reportPageRows(rows, state.customerPage);
    511563    const totals = rows.reduce((summary, row) => ({ value: summary.value + row.total, orders: summary.orders + row.orders, items: summary.items + row.items }), { value: 0, orders: 0, items: 0 });
    512564    $('#wsmCustomerCount').textContent = category ? `${rows.length} customer${rows.length === 1 ? '' : 's'} shown for ${category}` : `${rows.length} customer${rows.length === 1 ? '' : 's'} shown from ${state.customers.rows.length} purchasing customer${state.customers.rows.length === 1 ? '' : 's'}`;
    513565    $('#wsmCustomerSummary').innerHTML = [['Customers', rows.length], [category ? 'Category value' : 'Accumulated value', money(totals.value, state.customers.currency)], [category ? 'Category orders' : 'Orders', totals.orders], [category ? 'Category items' : 'Items purchased', totals.items]].map(([label, value]) => `<div><span>${label}</span><strong>${value}</strong></div>`).join('');
    514     $('#wsmCustomerRows').innerHTML = rows.length ? rows.map(row => `<tr><td><strong class="wsm-rank">#${row.rank}</strong></td><td><strong>${esc(row.name)}</strong></td><td><a href="mailto:${esc(row.email)}">${esc(row.email)}</a></td><td>${row.orders}</td><td>${row.items}</td><td><strong>${money(row.total, state.customers.currency)}</strong></td><td><div class="wsm-permission-list">${row.categories.length ? row.categories.map(item => `<span>${esc(item)}</span>`).join('') : '<span>Uncategorised</span>'}</div></td><td>${esc(row.last_purchase)}</td></tr>`).join('') : '<tr><td colspan="8" class="wsm-empty">No purchasing customers match these filters.</td></tr>';
     566    $('#wsmCustomerRows').innerHTML = pageRows.length ? pageRows.map(row => `<tr><td><strong class="wsm-rank">#${row.rank}</strong></td><td><strong>${esc(row.name)}</strong></td><td><a href="mailto:${esc(row.email)}">${esc(row.email)}</a></td><td>${row.orders}</td><td>${row.items}</td><td><strong>${money(row.total, state.customers.currency)}</strong></td><td><div class="wsm-permission-list">${row.categories.length ? row.categories.map(item => `<span>${esc(item)}</span>`).join('') : '<span>Uncategorised</span>'}</div></td><td>${esc(row.last_purchase)}</td></tr>`).join('') : '<tr><td colspan="8" class="wsm-empty">No purchasing customers match these filters.</td></tr>';
     567    updateReportPager('Customers', state.customerPage, state.customerPages);
    515568  }
    516569
    … …  
    574627    const rows = ranked.filter(row => (!search || row.name.toLowerCase().includes(search) || String(row.sku || '').toLowerCase().includes(search)) && (!category || row.categories.includes(category)));
    575628    state.bestSellerView = rows;
     629    state.bestSellerPages = reportPageCount(rows);
     630    state.bestSellerPage = Math.min(Math.max(1, state.bestSellerPage), state.bestSellerPages);
     631    const pageRows = reportPageRows(rows, state.bestSellerPage);
    576632    const totals = rows.reduce((summary, row) => ({ value: summary.value + row.value, quantity: summary.quantity + row.quantity, orders: summary.orders + row.orders }), { value: 0, quantity: 0, orders: 0 });
    577633    $('#wsmBestSellerCount').textContent = `${rows.length} product${rows.length === 1 ? '' : 's'} shown from ${ranked.length} product${ranked.length === 1 ? '' : 's'} sold`;
    578634    $('#wsmBestSellerSummary').innerHTML = [['Products', rows.length], ['Accumulated value', money(totals.value, state.bestSellers.currency)], ['Quantity sold', formatQuantity(totals.quantity)], ['Average item value', money(totals.quantity ? totals.value / totals.quantity : 0, state.bestSellers.currency)]].map(([label, value]) => `<div><span>${label}</span><strong>${value}</strong></div>`).join('');
    579     $('#wsmBestSellerRows').innerHTML = rows.length ? rows.map(row => `<tr><td><strong class="wsm-rank">#${row.rank}</strong></td><td><strong>${esc(row.name)}</strong></td><td>${esc(row.sku || 'Not set')}</td><td><strong>${formatQuantity(row.quantity)}</strong></td><td><strong>${money(row.value, state.bestSellers.currency)}</strong></td><td>${row.orders}</td><td>${money(row.quantity ? row.value / row.quantity : 0, state.bestSellers.currency)}</td><td><div class="wsm-permission-list">${row.categories.length ? row.categories.map(item => `<span>${esc(item)}</span>`).join('') : '<span>Uncategorised</span>'}</div></td></tr>`).join('') : '<tr><td colspan="8" class="wsm-empty">No sold products match these filters.</td></tr>';
     635    $('#wsmBestSellerRows').innerHTML = pageRows.length ? pageRows.map(row => `<tr><td><strong class="wsm-rank">#${row.rank}</strong></td><td><strong>${esc(row.name)}</strong></td><td>${esc(row.sku || 'Not set')}</td><td><strong>${formatQuantity(row.quantity)}</strong></td><td><strong>${money(row.value, state.bestSellers.currency)}</strong></td><td>${row.orders}</td><td>${money(row.quantity ? row.value / row.quantity : 0, state.bestSellers.currency)}</td><td><div class="wsm-permission-list">${row.categories.length ? row.categories.map(item => `<span>${esc(item)}</span>`).join('') : '<span>Uncategorised</span>'}</div></td></tr>`).join('') : '<tr><td colspan="8" class="wsm-empty">No sold products match these filters.</td></tr>';
     636    updateReportPager('BestSellers', state.bestSellerPage, state.bestSellerPages);
    580637  }
    581638
    … …  
    760817  }
    761818
     819  function cogsProductLabel(product) {
     820    return `${esc(product.name)}${product.sku ? ` (${esc(product.sku)})` : ''}`;
     821  }
     822
     823  function filteredCogsProducts() {
     824    const items = state.cogsSearch?.items || [];
     825    const term = String($('#wsmCogsProductSearch')?.value || '').trim().toLowerCase();
     826    if (!term) return items;
     827    return items.filter(product => [product.name, product.sku].some(value => String(value || '').toLowerCase().includes(term)));
     828  }
     829
     830  function renderCogsProductOptions() {
     831    const select = $('#wsmCogsProductSelect');
     832    const items = filteredCogsProducts();
     833    const selected = String(select.value || state.cogs?.id || '');
     834    const selectedProduct = selected && !(items.some(product => String(product.id) === selected)) ? (state.cogsSearch?.items || []).find(product => String(product.id) === selected) : null;
     835    select.innerHTML = '<option value="">Choose a product...</option>' + (selectedProduct ? `<option value="${selectedProduct.id}">${cogsProductLabel(selectedProduct)}</option>` : '') + items.map(product => `<option value="${product.id}">${cogsProductLabel(product)}</option>`).join('');
     836    select.disabled = !(items.length || selectedProduct);
     837    if (selected && Array.from(select.options).some(option => option.value === selected)) select.value = selected;
     838    const total = Number(state.cogsSearch?.total || 0);
     839    const searchTerm = String($('#wsmCogsProductSearch')?.value || '').trim();
     840    $('#wsmCogsStatus').textContent = items.length ? (searchTerm ? `${items.length} matching product${items.length === 1 ? '' : 's'} from ${total}.` : `${total} product${total === 1 ? '' : 's'} available.`) : (searchTerm ? 'No products match that search.' : 'No products are available for COGS entry.');
     841  }
     842
    762843  async function loadCogsProducts() {
    763844    const select = $('#wsmCogsProductSelect');
    … …  
    768849    state.cogs = null;
    769850    try {
    770       state.cogsSearch = await api(`cogs/products?${new URLSearchParams({ limit: 500, status: $('#wsmCogsProductStatus').value || 'all', order_filter: $('#wsmCogsOrderFilter').value || 'all', search: $('#wsmCogsProductSearch').value || '' })}`);
     851      state.cogsSearch = await api(`cogs/products?${new URLSearchParams({ limit: 500, status: $('#wsmCogsProductStatus').value || 'all' })}`);
    771852      state.cogsSearch.items = sortCogsProducts(state.cogsSearch.items || []);
    772       select.innerHTML = '<option value="">Choose a product...</option>' + state.cogsSearch.items.map(product => `<option value="${product.id}">${esc(product.name)}${product.sku ? ` (${esc(product.sku)})` : ''}</option>`).join('');
    773       select.disabled = !state.cogsSearch.items.length;
    774       $('#wsmCogsStatus').textContent = state.cogsSearch.items.length ? `${state.cogsSearch.total} product${state.cogsSearch.total === 1 ? '' : 's'} available.` : 'No products match the current COGS filters.';
     853      renderCogsProductOptions();
    775854    } catch (error) {
    776855      select.innerHTML = '<option value="">Products could not be loaded</option>';
    … …  
    825904    const profitability = state.cogs.profitability || {};
    826905    const netRevenue = Number(profitability.net_revenue || 0);
    827     const grossRevenue = Number(profitability.gross_revenue || 0);
    828     const quantity = Number(profitability.quantity || 0);
    829     const soldCogsNet = totals.net * quantity;
    830     const profit = netRevenue - soldCogsNet;
     906    const profit = netRevenue - totals.net;
    831907    const margin = netRevenue > 0 ? (profit / netRevenue) * 100 : null;
    832908    const rows = state.cogs.entries.filter(entry => entry.date || entry.net || entry.tax || entry.gross || entry.reference || entry.description).length;
    833909    $('#wsmCogsSummary').innerHTML = [
    834       ['Accumulated net revenue', money(netRevenue, state.cogs.currency)],
    835       ['Accumulated gross revenue', money(grossRevenue, state.cogs.currency)],
    836       ['Total COGS (net)', money(soldCogsNet, state.cogs.currency)],
     910      ['Accumulated net revenue (before tax)', money(netRevenue, state.cogs.currency)],
     911      ['Total COGS (net)', money(totals.net, state.cogs.currency)],
    837912      ['Profit', money(profit, state.cogs.currency)],
    838913      ['Margin', margin === null ? 'n/a' : `${margin.toFixed(2)}%`],
    839       ['Qty sold', formatQuantity(quantity)],
     914      ['Qty sold', formatQuantity(profitability.quantity)],
    840915      ['Orders', Number(profitability.orders || 0)]
    841916    ].map(([label, value]) => `<div><span>${label}</span><strong>${value}</strong></div>`).join('');
    … …  
    843918      $('#wsmCogsStatus').textContent = profitability.truncated ? 'Profitability is based on the most recent order pages.' : `${profitability.skipped} order item${Number(profitability.skipped) === 1 ? '' : 's'} could not be included in profitability.`;
    844919    } else {
    845       $('#wsmCogsStatus').textContent = rows ? 'COGS entries ready. Profit uses accumulated net revenue from net sales minus unit COGS multiplied by quantity sold.' : 'Profit uses accumulated net revenue from net sales minus unit COGS multiplied by quantity sold.';
     920      $('#wsmCogsStatus').textContent = rows ? 'COGS entries ready. Profit uses accumulated net revenue from net sales minus total COGS (net).' : 'Profit uses accumulated net revenue from net sales minus total COGS (net).';
    846921    }
    847922  }
    … …  
    10821157  $('#wsmSalesTo').addEventListener('change', () => $$('[data-period]').forEach(button => button.classList.remove('active')));
    10831158  $('#wsmSalesStatus').addEventListener('change', runSales);
    1084   let customerSearchTimer; $('#wsmCustomerSearch').addEventListener('input', () => { clearTimeout(customerSearchTimer); customerSearchTimer = setTimeout(renderCustomers, 200); });
     1159  $('#wsmPrevSales').addEventListener('click', () => { if (state.salesPage > 1) { state.salesPage--; renderSales(); } });
     1160  $('#wsmNextSales').addEventListener('click', () => { if (state.salesPage < state.salesPages) { state.salesPage++; renderSales(); } });
     1161  let customerSearchTimer; $('#wsmCustomerSearch').addEventListener('input', () => { clearTimeout(customerSearchTimer); customerSearchTimer = setTimeout(() => { state.customerPage = 1; renderCustomers(); }, 200); });
    10851162  $('#wsmAddCustomer').addEventListener('click', () => editCustomer());
    1086   $('#wsmCustomerCategory').addEventListener('change', renderCustomers); $('#wsmExportCustomers').addEventListener('click', exportCustomers);
    1087   let bestSellerSearchTimer; $('#wsmBestSellerSearch').addEventListener('input', () => { clearTimeout(bestSellerSearchTimer); bestSellerSearchTimer = setTimeout(renderBestSellers, 200); });
    1088   $('#wsmBestSellerCategory').addEventListener('change', renderBestSellers); $('#wsmBestSellerRanking').addEventListener('change', renderBestSellers); $('#wsmExportBestSellers').addEventListener('click', exportBestSellers);
     1163  $('#wsmCustomerCategory').addEventListener('change', () => { state.customerPage = 1; renderCustomers(); }); $('#wsmExportCustomers').addEventListener('click', exportCustomers);
     1164  $('#wsmPrevCustomers').addEventListener('click', () => { if (state.customerPage > 1) { state.customerPage--; renderCustomers(); } });
     1165  $('#wsmNextCustomers').addEventListener('click', () => { if (state.customerPage < state.customerPages) { state.customerPage++; renderCustomers(); } });
     1166  let bestSellerSearchTimer; $('#wsmBestSellerSearch').addEventListener('input', () => { clearTimeout(bestSellerSearchTimer); bestSellerSearchTimer = setTimeout(() => { state.bestSellerPage = 1; renderBestSellers(); }, 200); });
     1167  $('#wsmBestSellerCategory').addEventListener('change', () => { state.bestSellerPage = 1; renderBestSellers(); }); $('#wsmBestSellerRanking').addEventListener('change', () => { state.bestSellerPage = 1; renderBestSellers(); }); $('#wsmExportBestSellers').addEventListener('click', exportBestSellers);
     1168  $('#wsmPrevBestSellers').addEventListener('click', () => { if (state.bestSellerPage > 1) { state.bestSellerPage--; renderBestSellers(); } });
     1169  $('#wsmNextBestSellers').addEventListener('click', () => { if (state.bestSellerPage < state.bestSellerPages) { state.bestSellerPage++; renderBestSellers(); } });
    10891170  $('#wsmCashFilters').addEventListener('submit', runCashFlow);
    10901171  $$('.wsm-tabs [data-forecast-tab]').forEach(button => button.addEventListener('click', () => {
    … …  
    10991180  $('#wsmCogsProductSelect').addEventListener('change', event => selectCogsProduct(Number(event.currentTarget.value || 0)));
    11001181  $('#wsmCogsProductStatus').addEventListener('change', loadCogsProducts);
    1101   $('#wsmCogsOrderFilter').addEventListener('change', loadCogsProducts);
    1102   $('#wsmCogsProductSearch').addEventListener('input', () => { clearTimeout(loadCogsProducts.searchTimer); loadCogsProducts.searchTimer = setTimeout(loadCogsProducts, 300); });
     1182  $('#wsmCogsProductSearch').addEventListener('input', renderCogsProductOptions);
    11031183  $('#wsmCogsReloadProducts').addEventListener('click', loadCogsProducts);
    11041184  $('#wsmAddCogsEntry').addEventListener('click', addCogsEntry);
  • pfh-shop-manager/trunk/includes/class-pfhsm-plugin.php

    r3599418 r3603307  
    506506
    507507                wp_enqueue_style( 'pfhsm-app', PFHSM_URL . 'assets/app.css', array(), PFHSM_VERSION );
    508                 wp_enqueue_script( 'pfhsm-app', PFHSM_URL . 'assets/app-0.4.40.js', array(), PFHSM_VERSION, true );
     508                wp_enqueue_script( 'pfhsm-app', PFHSM_URL . 'assets/app-0.4.45.js', array(), PFHSM_VERSION, true );
    509509                wp_localize_script(
    510510                        'pfhsm-app',
  • pfh-shop-manager/trunk/includes/class-pfhsm-rest-controller.php

    r3599418 r3603307  
    133133
    134134        private function order_customer_label( WC_Order $order ): string {
    135                 if ( ! $this->order_has_billing_address( $order ) ) {
    136                         return self::GUEST_CHECKOUT_LABEL;
    137                 }
    138 
    139135                $name = trim( (string) $order->get_formatted_billing_full_name() );
    140136                if ( '' !== $name ) {
    141137                        return $name;
     138                }
     139
     140                $name_parts = array();
     141                foreach ( array( 'get_billing_first_name', 'get_billing_last_name' ) as $method ) {
     142                        if ( method_exists( $order, $method ) ) {
     143                                $name_parts[] = trim( (string) $order->{$method}() );
     144                        }
     145                }
     146                $name = trim( implode( ' ', array_filter( $name_parts ) ) );
     147                if ( '' !== $name ) {
     148                        return $name;
     149                }
     150
     151                $name_parts = array( trim( (string) $order->get_meta( '_billing_first_name', true ) ), trim( (string) $order->get_meta( '_billing_last_name', true ) ) );
     152                $name = trim( implode( ' ', array_filter( $name_parts ) ) );
     153                if ( '' !== $name ) {
     154                        return $name;
     155                }
     156
     157                if ( method_exists( $order, 'get_formatted_shipping_full_name' ) ) {
     158                        $name = trim( (string) $order->get_formatted_shipping_full_name() );
     159                        if ( '' !== $name ) {
     160                                return $name;
     161                        }
     162                }
     163
     164                if ( method_exists( $order, 'get_customer_id' ) ) {
     165                        $user = $order->get_customer_id() ? get_userdata( $order->get_customer_id() ) : false;
     166                        if ( $user && ! empty( $user->display_name ) ) {
     167                                return (string) $user->display_name;
     168                        }
     169                }
     170
     171                if ( ! $this->order_has_billing_address( $order ) ) {
     172                        return self::GUEST_CHECKOUT_LABEL;
    142173                }
    143174
    … …  
    204235                        'regular_price'     => $product->get_regular_price(),
    205236                        'sale_price'        => $product->get_sale_price(),
    206                         'current_cogs'      => method_exists( $product, 'get_cogs_value' ) ? $product->get_cogs_value() : null,
    207237                        'short_description' => $product->get_short_description(),
    208238                        'description'       => $product->get_description(),
    … …  
    358388                } while ( $page <= $pages && $page <= $max_pages );
    359389
    360                 $sold_cogs_net = $cogs_net * $quantity;
    361                 $profit = $net_revenue - $sold_cogs_net;
     390                $profit = $net_revenue - $cogs_net;
    362391                return array(
    363392                        'orders'         => count( $orders ),
    … …  
    366395                        'tax'            => wc_format_decimal( $tax, 2 ),
    367396                        'gross_revenue'  => wc_format_decimal( $gross_revenue, 2 ),
    368                         'unit_cogs_net'  => wc_format_decimal( $cogs_net, 2 ),
    369                         'cogs_net'       => wc_format_decimal( $sold_cogs_net, 2 ),
     397                        'cogs_net'       => wc_format_decimal( $cogs_net, 2 ),
    370398                        'profit'         => wc_format_decimal( $profit, 2 ),
    371399                        'margin_percent' => $net_revenue > 0 ? wc_format_decimal( ( $profit / $net_revenue ) * 100, 2 ) : null,
    … …  
    375403        }
    376404
    377         private function cogs_sold_product_ids(): array {
    378                 $paid_statuses = function_exists( 'wc_get_is_paid_statuses' ) ? wc_get_is_paid_statuses() : array( 'processing', 'completed' );
    379                 $statuses = array_values( array_unique( array_merge( $paid_statuses, array( 'refunded' ) ) ) );
    380                 $max_pages = min( 100, max( 1, (int) apply_filters( 'pfhsm_cogs_sold_product_order_pages', 50 ) ) );
    381                 $page = 1;
    382                 $pages = 1;
    383                 $product_ids = array();
    384 
    385                 do {
    386                         try {
    387                                 $result = wc_get_orders(
    388                                         array(
    389                                                 'limit'    => 100,
    390                                                 'page'     => $page,
    391                                                 'paginate' => true,
    392                                                 'orderby'  => 'date',
    393                                                 'order'    => 'DESC',
    394                                                 'status'   => $statuses,
    395                                         )
    396                                 );
    397                         } catch ( Throwable $exception ) {
    398                                 $this->log_exception( 'cogs sold products query failed', $exception );
    399                                 break;
    400                         }
    401 
    402                         $pages = max( 1, (int) $result->max_num_pages );
    403                         foreach ( $result->orders as $order ) {
    404                                 if ( is_a( $order, 'WC_Order_Refund' ) || ( method_exists( $order, 'get_type' ) && 'shop_order_refund' === $order->get_type() ) ) {
    405                                         continue;
    406                                 }
    407 
    408                                 foreach ( $order->get_items( 'line_item' ) as $item ) {
    409                                         $product_id = absint( $item->get_product_id() );
    410                                         if ( $product_id ) {
    411                                                 $product_ids[ $product_id ] = true;
    412                                         }
    413 
    414                                         $variation_id = absint( $item->get_variation_id() );
    415                                         if ( $variation_id ) {
    416                                                 $product_ids[ $variation_id ] = true;
    417                                         }
    418                                 }
    419                         }
    420                         $page++;
    421                 } while ( $page <= $pages && $page <= $max_pages );
    422 
    423                 return array_keys( $product_ids );
    424         }
    425 
    426405        public function cogs_products( WP_REST_Request $request ) {
    427406                if ( $error = $this->require_woocommerce() ) {
    … …  
    433412                $status_filter = sanitize_key( (string) $request->get_param( 'status' ) );
    434413                $statuses = in_array( $status_filter, array( 'publish', 'draft' ), true ) ? array( $status_filter ) : array( 'publish', 'draft' );
    435                 $order_filter = sanitize_key( (string) $request->get_param( 'order_filter' ) );
    436                 $with_orders = 'with_orders' === $order_filter;
    437                 $sold_product_ids = $with_orders ? array_flip( $this->cogs_sold_product_ids() ) : array();
    438414
    439415                $products = array();
    … …  
    441417                if ( $sku_id ) {
    442418                        $product = wc_get_product( $sku_id );
    443                         if ( $product instanceof WC_Product && in_array( $product->get_status(), $statuses, true ) && ( ! $with_orders || isset( $sold_product_ids[ $product->get_id() ] ) ) ) {
     419                        if ( $product instanceof WC_Product && in_array( $product->get_status(), $statuses, true ) ) {
    444420                                $products[ $product->get_id() ] = $product;
    445421                        }
    446422                }
    447423
    448                 $product_query = array(
    449                         'limit'   => $limit,
    450                         'status'  => $statuses,
    451                         'orderby' => 'title',
    452                         'order'   => 'ASC',
    453                 );
    454                 if ( $with_orders ) {
    455                         $product_query['include'] = array_keys( $sold_product_ids );
    456                 }
    457 
    458                 $result = $sold_product_ids || ! $with_orders ? wc_get_products( $product_query ) : array();
     424                $result = wc_get_products(
     425                        array(
     426                                'limit'  => $limit,
     427                                'status' => $statuses,
     428                                'orderby' => 'date',
     429                                'order'  => 'DESC',
     430                        )
     431                );
    459432                if ( '' !== $search ) {
    460433                        $result = wc_get_products(
    … …  
    463436                                        'status'  => $statuses,
    464437                                        's'       => $search,
    465                                         'orderby' => 'title',
    466                                         'order'   => 'ASC',
     438                                        'orderby' => 'date',
     439                                        'order'   => 'DESC',
    467440                                )
    468441                        );
    … …  
    470443                foreach ( $result as $product ) {
    471444                        if ( $product instanceof WC_Product ) {
    472                                 if ( $with_orders && ! isset( $sold_product_ids[ $product->get_id() ] ) ) {
    473                                         continue;
    474                                 }
    475445                                $products[ $product->get_id() ] = $product;
    476446                        }
    … …  
    481451                        $product_list,
    482452                        function ( WC_Product $left, WC_Product $right ): int {
    483                                 $name_compare = strnatcasecmp( (string) $left->get_name(), (string) $right->get_name() );
    484                                 if ( 0 !== $name_compare ) {
    485                                         return $name_compare;
     453                                $left_date = $this->product_sku_start_date_key( $left );
     454                                $right_date = $this->product_sku_start_date_key( $right );
     455                                if ( '' === $left_date || '' === $right_date ) {
     456                                        if ( $left_date !== $right_date ) {
     457                                                return '' === $left_date ? 1 : -1;
     458                                        }
     459                                }
     460
     461                                $date_compare = strcmp( $left_date, $right_date );
     462                                if ( 0 !== $date_compare ) {
     463                                        return $date_compare;
    486464                                }
    487465
    … …  
    491469                                }
    492470
    493                                 return $left->get_id() <=> $right->get_id();
     471                                $name_compare = strnatcasecmp( (string) $left->get_name(), (string) $right->get_name() );
     472                                return 0 !== $name_compare ? $name_compare : $left->get_id() <=> $right->get_id();
    494473                        }
    495474                );
    … …  
    16031582                        'pfhsm_view_cash_flow'    => 'Sales Forecast',
    16041583                        'pfhsm_manage_cash_flow_schedule' => 'Sales Forecast schedules',
    1605                         'pfhsm_manage_cogs'       => 'COGS',
    16061584                        'pfhsm_manage_users'      => 'Users and groups',
    16071585                        'pfhsm_view_help'         => 'Help',
  • pfh-shop-manager/trunk/readme.txt

    r3599418 r3603307  
    55Tested up to: 7.0
    66Requires PHP: 8.0
    7 Stable tag: 0.4.40
     7Stable tag: 0.4.45
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    1515PFH Shop Manager provides a branded portal at /pfh-shop-manager/ for managing one WooCommerce shop.
    1616
    17 Version 0.4.40 includes:
     17Version 0.4.45 includes:
    1818
    1919* Product creation and editing, including prices, descriptions, stock and dimensions.
    … …  
    2929* Potential-sales reporting, CSV/PDF exports and scheduled sales forecast emails.
    3030* COGS entry management for WooCommerce stores with Cost of Goods Sold enabled.
    31 * COGS profitability on the COGS screen, showing accumulated net and gross revenue, sold COGS, profit and margin.
    32 * A COGS product filter for products that have orders.
    33 * Native WooCommerce COGS values on the Products list.
     31* COGS profitability on the COGS screen, showing accumulated net revenue, total COGS, profit and margin.
    3432* Shop Manager, Shop Assistant and Accountant user roles.
    3533* Permission-protected login and explicit logout activity for portal users, retained for 90 days.
    … …  
    4240= Development and source =
    4341
    44 The human-readable source used to build this plugin is included in the plugin package. The frontend source is `assets/app.js`; the versioned `assets/app-0.4.40.js` file is a release copy of the same source used for cache busting. No npm, webpack or minification step is required. Release packages are built from this repository with `scripts/build-release.ps1`.
     42The human-readable source used to build this plugin is included in the plugin package. The frontend source is `assets/app.js`; the versioned `assets/app-0.4.45.js` file is a release copy of the same source used for cache busting. No npm, webpack or minification step is required. Release packages are built from this repository with `scripts/build-release.ps1`.
    4543
    4644== Installation ==
    … …  
    6159== Changelog ==
    6260
    63 = 0.4.40 =
    64 * Sorts the COGS product dropdown alphabetically by product title instead of SKU date.
    65 * Adds COGS product search while retaining the order filter.
    66 * Adds COGS to the editable user group permissions list.
    67 * Updates the cache-busting portal script filename for the 0.4.40 release.
    68 
    69 = 0.4.39 =
    70 * Adds a Users action for sending branded PFH Shop Manager password reset emails.
    71 * Keeps password reset emails on the PFH Shop Manager reset flow instead of the default WordPress lost-password page.
    72 * Updates the cache-busting portal script filename for the 0.4.39 release.
    73 
    74 = 0.4.38 =
    75 * Adds a COGS order filter for showing products that have orders.
    76 * Shows native WooCommerce COGS values on the Products list.
    77 * Shows accumulated gross revenue in the selected-product COGS profitability summary.
    78 * Calculates total COGS as unit COGS multiplied by quantity sold.
    79 * Updates the cache-busting portal script filename for the 0.4.38 release.
     61= 0.4.45 =
     62* Adds product-style pagination to Sales, Customers and Best Sellers report tables.
     63* Uses a fresh script filename to make the 0.4.45 deployment visible.
     64
     65= 0.4.44 =
     66* Aligns the COGS status, product search, product picker and refresh controls cleanly.
     67* Uses a fresh script filename to make the 0.4.44 deployment visible.
     68
     69= 0.4.43 =
     70* Adds a product/SKU search box to the COGS product picker.
     71* Uses a fresh script filename to make the 0.4.43 deployment visible.
     72
     73= 0.4.42 =
     74* Shows the billing customer name in Sales even when a manually completed BACS order has no full billing address.
     75* Uses a fresh script filename to make the 0.4.42 deployment visible.
     76
     77= 0.4.41 =
     78* Keeps the COGS profitability summary focused on accumulated net revenue before tax.
     79* Removes accumulated gross revenue from the COGS summary tiles.
     80* Updates the cache-busting portal script filename for the 0.4.41 release.
     81
     82= 0.4.37 =
     83* Shows legacy Woo Shop Manager role assignments on the PFH Shop Manager Users screen.
     84* Maps legacy built-in staff roles to the current PFH Shop Manager groups for safe display and editing.
     85* Updates the cache-busting portal script filename for the 0.4.37 release.
    8086
    8187= 0.4.36 =
  • pfh-shop-manager/trunk/templates/portal.php

    r3599418 r3603307  
    107107                                <div class="wsm-page-heading"><div><h1>Products</h1><p id="wsmProductCount">Loading products...</p></div><button id="wsmAddProduct" class="wsm-button primary">+ Add product</button></div>
    108108                                <div class="wsm-toolbar"><label class="wsm-search"><span>&#9906;</span><input id="wsmProductSearch" type="search" placeholder="Search products..."></label><select id="wsmProductStatus" aria-label="Product status"><option value="all">All statuses</option><option value="publish">Published</option><option value="draft">Draft</option><option value="private">Private</option></select></div>
    109                                 <div class="wsm-table-wrap"><table class="wsm-table"><thead><tr><th>Product</th><th>SKU</th><th>Price</th><th>COGS</th><th>Stock</th><th>Status</th><th><span class="screen-reader-text">Actions</span></th></tr></thead><tbody id="wsmProductRows"><tr><td colspan="7" class="wsm-empty">Loading products...</td></tr></tbody></table></div>
     109                                <div class="wsm-table-wrap"><table class="wsm-table"><thead><tr><th>Product</th><th>SKU</th><th>Price</th><th>Stock</th><th>Status</th><th><span class="screen-reader-text">Actions</span></th></tr></thead><tbody id="wsmProductRows"><tr><td colspan="6" class="wsm-empty">Loading products...</td></tr></tbody></table></div>
    110110                                <div class="wsm-pagination"><button id="wsmPrevProducts" class="wsm-icon-button" aria-label="Previous page"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg></button><span id="wsmProductPage">Page 1</span><button id="wsmNextProducts" class="wsm-icon-button" aria-label="Next page"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg></button></div>
    111111                        </section>
    … …  
    134134                                <div class="wsm-periods"><button type="button" data-period="year">Current year</button><button type="button" data-period="month" class="active">Current month</button><button type="button" data-period="prior">Prior month</button><label>From<input id="wsmSalesFrom" type="date"></label><label>To<input id="wsmSalesTo" type="date"></label><label>Status<select id="wsmSalesStatus" aria-label="Order status"><option value="">All statuses</option></select></label><button id="wsmRunSales" class="wsm-button primary" type="button">Run report</button></div>
    135135                                <div id="wsmSalesSummary" class="wsm-summary"></div><div class="wsm-table-wrap"><table class="wsm-table"><thead><tr><th>Order</th><th>Date</th><th>Customer</th><th>Status</th><th>Items</th><th>Gross</th><th>Tax</th><th>Net</th><th>Refunds</th></tr></thead><tbody id="wsmSalesRows"></tbody></table></div>
     136                                <div class="wsm-pagination"><button id="wsmPrevSales" class="wsm-icon-button" aria-label="Previous page"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg></button><span id="wsmSalesPage">Page 1 of 1</span><button id="wsmNextSales" class="wsm-icon-button" aria-label="Next page"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg></button></div>
    136137                        </section>
    137138
    … …  
    141142                                <div id="wsmCustomerSummary" class="wsm-summary"></div>
    142143                                <div class="wsm-table-wrap"><table class="wsm-table wsm-customer-table"><thead><tr><th>Rank</th><th>Customer</th><th>Email</th><th>Orders</th><th>Items</th><th>Total order value</th><th>Purchase categories</th><th>Last purchase</th></tr></thead><tbody id="wsmCustomerRows"></tbody></table></div>
     144                                <div class="wsm-pagination"><button id="wsmPrevCustomers" class="wsm-icon-button" aria-label="Previous page"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg></button><span id="wsmCustomersPage">Page 1 of 1</span><button id="wsmNextCustomers" class="wsm-icon-button" aria-label="Next page"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg></button></div>
    143145                        </section>
    144146
    … …  
    148150                                <div id="wsmBestSellerSummary" class="wsm-summary"></div>
    149151                                <div class="wsm-table-wrap"><table class="wsm-table wsm-best-seller-table"><thead><tr><th>Rank</th><th>Product</th><th>SKU</th><th>Qty sold</th><th>Sales value</th><th>Orders</th><th>Average item value</th><th>Categories</th></tr></thead><tbody id="wsmBestSellerRows"></tbody></table></div>
     152                                <div class="wsm-pagination"><button id="wsmPrevBestSellers" class="wsm-icon-button" aria-label="Previous page"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg></button><span id="wsmBestSellersPage">Page 1 of 1</span><button id="wsmNextBestSellers" class="wsm-icon-button" aria-label="Next page"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg></button></div>
    150153                        </section>
    151154
    … …  
    184187                        <section class="wsm-view" data-panel="cogs">
    185188                                <div class="wsm-page-heading"><div><h1>COGS</h1><p>Enter dated Cost of Goods Sold rows for a product.</p><p id="wsmCogsStatus">Choose a product to begin.</p></div></div>
    186                                 <div class="wsm-toolbar"><label class="wsm-field">Status<select id="wsmCogsProductStatus" aria-label="Product status"><option value="all">Published and draft</option><option value="publish">Published products</option><option value="draft">Draft products</option></select></label><label class="wsm-field">Orders<select id="wsmCogsOrderFilter" aria-label="Order filter"><option value="all">All products</option><option value="with_orders">Products with orders</option></select></label><label class="wsm-field">Search<input id="wsmCogsProductSearch" type="search" placeholder="Search product title" aria-label="Search COGS products"></label><label class="wsm-field wsm-product-select-field">Product<select id="wsmCogsProductSelect" aria-label="Product"><option value="">Loading products...</option></select></label><button id="wsmCogsReloadProducts" class="wsm-button secondary" type="button">Refresh products</button></div>
     189                                <div class="wsm-toolbar wsm-cogs-toolbar"><label class="wsm-field wsm-cogs-status-field">Status<select id="wsmCogsProductStatus" aria-label="Product status"><option value="all">Published and draft</option><option value="publish">Published products</option><option value="draft">Draft products</option></select></label><label class="wsm-search wsm-cogs-product-search"><span>&#9906;</span><input id="wsmCogsProductSearch" type="search" placeholder="Search product or SKU..." aria-label="Search COGS products"></label><label class="wsm-field wsm-product-select-field">Product<select id="wsmCogsProductSelect" aria-label="Product"><option value="">Loading products...</option></select></label><button id="wsmCogsReloadProducts" class="wsm-button secondary" type="button">Refresh products</button></div>
    187190                                <div id="wsmCogsEditor" class="wsm-section" hidden>
    188191                                        <div class="wsm-subheading"><div><h2 id="wsmCogsProductName">Selected product</h2><p id="wsmCogsProductMeta"></p></div><button id="wsmAddCogsEntry" class="wsm-button secondary" type="button">+ Add cost row</button></div>
  • pfh-shop-manager/trunk/woo-shop-manager.php

    r3599418 r3603307  
    33 * Plugin Name: PFH Shop Manager
    44 * Description: A simplified, permission-controlled WooCommerce management portal for products, customers, shipping, sales and sales forecasting.
    5  * Version: 0.4.40
     5 * Version: 0.4.45
    66 * Author: Paul Howe
    77 * License: GPL-2.0-or-later
    … …  
    1616defined( 'ABSPATH' ) || exit;
    1717
    18 define( 'PFHSM_VERSION', '0.4.40' );
     18define( 'PFHSM_VERSION', '0.4.45' );
    1919define( 'PFHSM_EDITION', 'lite' );
    2020define( 'PFHSM_FILE', __FILE__ );
Note: See TracChangeset for help on using the changeset viewer.