Changeset 3603307
- Timestamp:
- 07/10/2026 06:49:28 PM (4 months ago)
- Location:
- pfh-shop-manager
- Files:
-
- 19 added
- 1 deleted
- 7 edited
-
tags/0.4.45 (added)
-
tags/0.4.45/assets (added)
-
tags/0.4.45/assets/app-0.4.45.js (added)
-
tags/0.4.45/assets/app-icon.ico (added)
-
tags/0.4.45/assets/app-icon.png (added)
-
tags/0.4.45/assets/app.css (added)
-
tags/0.4.45/assets/app.js (added)
-
tags/0.4.45/includes (added)
-
tags/0.4.45/includes/class-pfhsm-cash-flow.php (added)
-
tags/0.4.45/includes/class-pfhsm-csv.php (added)
-
tags/0.4.45/includes/class-pfhsm-login-log.php (added)
-
tags/0.4.45/includes/class-pfhsm-plugin.php (added)
-
tags/0.4.45/includes/class-pfhsm-rest-controller.php (added)
-
tags/0.4.45/includes/class-pfhsm-session-timeout.php (added)
-
tags/0.4.45/readme.txt (added)
-
tags/0.4.45/templates (added)
-
tags/0.4.45/templates/portal.php (added)
-
tags/0.4.45/woo-shop-manager.php (added)
-
trunk/assets/app-0.4.40.js (deleted)
-
trunk/assets/app-0.4.45.js (added)
-
trunk/assets/app.css (modified) (2 diffs)
-
trunk/assets/app.js (modified) (16 diffs)
-
trunk/includes/class-pfhsm-plugin.php (modified) (1 diff)
-
trunk/includes/class-pfhsm-rest-controller.php (modified) (12 diffs)
-
trunk/readme.txt (modified) (5 diffs)
-
trunk/templates/portal.php (modified) (5 diffs)
-
trunk/woo-shop-manager.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
pfh-shop-manager/trunk/assets/app.css
r3599418 r3603307 6 6 .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%)} 7 7 .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} 9 9 .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} 10 10 @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}} … … 53 53 .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} 54 54 .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)} 55 56 #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)} 56 57 #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} 58 59 @media(max-width:900px){.wsm-settings-grid{grid-template-columns:repeat(2,minmax(160px,1fr))}} 59 60 @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%}} 61 62 @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%}} 62 63 .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 4 4 if (!window.pfhsmConfig || !document.getElementById('wsmApp')) return; 5 5 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 }; 7 7 const API_TIMEOUT_MS = 15000; 8 const REPORT_PAGE_SIZE = 20; 8 9 const $ = (selector, root = document) => root.querySelector(selector); 9 10 const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector)); … … 201 202 function showProductFailure(message) { 202 203 $('#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>`; 204 205 } 205 206 206 207 function showProductLoading() { 207 208 $('#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>'; 209 210 } 210 211 … … 225 226 } 226 227 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 227 253 function sortCogsProducts(items) { 228 254 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; 229 264 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); 233 266 }); 234 267 } … … 283 316 $('#wsmPrevProducts').disabled = state.productPage <= 1; 284 317 $('#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>'; 286 319 } catch (error) { 287 320 showProductFailure(error.message); … … 292 325 const quantity = product.manage_stock ? Number(product.stock_quantity || 0) : product.stock_status === 'instock' ? 'In stock' : 'Out of stock'; 293 326 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>`; 296 328 } 297 329 … … 390 422 } while (page <= pages); 391 423 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(); 395 426 $('#wsmExportSales').disabled = false; 396 427 if (combined.skipped) notice(`${combined.skipped} order${combined.skipped === 1 ? ' could' : 's could'} not be included in this report.`, 'error'); 397 428 } catch (error) { 429 state.sales = null; 430 state.salesPages = 1; 431 updateReportPager('Sales', 1, 1); 398 432 $('#wsmSalesSummary').innerHTML = ''; 399 433 $('#wsmSalesRows').innerHTML = `<tr><td colspan="9" class="wsm-empty">${esc(error.message)}</td></tr>`; … … 404 438 $('#wsmRunSales').textContent = 'Run report'; 405 439 } 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); 406 452 } 407 453 … … 434 480 $('#wsmCustomerCategory').disabled = true; 435 481 $('#wsmExportCustomers').disabled = true; 482 state.customerPage = 1; 483 state.customerPages = 1; 484 updateReportPager('Customers', 1, 1); 436 485 $('#wsmCustomerRows').innerHTML = '<tr><td colspan="8" class="wsm-empty">Add a customer to create their account and send a setup email.</td></tr>'; 437 486 } … … 509 558 const rows = ranked.filter(row => !search || row.name.toLowerCase().includes(search) || row.email.toLowerCase().includes(search)); 510 559 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); 511 563 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 }); 512 564 $('#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'}`; 513 565 $('#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); 515 568 } 516 569 … … 574 627 const rows = ranked.filter(row => (!search || row.name.toLowerCase().includes(search) || String(row.sku || '').toLowerCase().includes(search)) && (!category || row.categories.includes(category))); 575 628 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); 576 632 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 }); 577 633 $('#wsmBestSellerCount').textContent = `${rows.length} product${rows.length === 1 ? '' : 's'} shown from ${ranked.length} product${ranked.length === 1 ? '' : 's'} sold`; 578 634 $('#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); 580 637 } 581 638 … … 760 817 } 761 818 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 762 843 async function loadCogsProducts() { 763 844 const select = $('#wsmCogsProductSelect'); … … 768 849 state.cogs = null; 769 850 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' })}`); 771 852 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(); 775 854 } catch (error) { 776 855 select.innerHTML = '<option value="">Products could not be loaded</option>'; … … 825 904 const profitability = state.cogs.profitability || {}; 826 905 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; 831 907 const margin = netRevenue > 0 ? (profit / netRevenue) * 100 : null; 832 908 const rows = state.cogs.entries.filter(entry => entry.date || entry.net || entry.tax || entry.gross || entry.reference || entry.description).length; 833 909 $('#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)], 837 912 ['Profit', money(profit, state.cogs.currency)], 838 913 ['Margin', margin === null ? 'n/a' : `${margin.toFixed(2)}%`], 839 ['Qty sold', formatQuantity( quantity)],914 ['Qty sold', formatQuantity(profitability.quantity)], 840 915 ['Orders', Number(profitability.orders || 0)] 841 916 ].map(([label, value]) => `<div><span>${label}</span><strong>${value}</strong></div>`).join(''); … … 843 918 $('#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.`; 844 919 } 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).'; 846 921 } 847 922 } … … 1082 1157 $('#wsmSalesTo').addEventListener('change', () => $$('[data-period]').forEach(button => button.classList.remove('active'))); 1083 1158 $('#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); }); 1085 1162 $('#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(); } }); 1089 1170 $('#wsmCashFilters').addEventListener('submit', runCashFlow); 1090 1171 $$('.wsm-tabs [data-forecast-tab]').forEach(button => button.addEventListener('click', () => { … … 1099 1180 $('#wsmCogsProductSelect').addEventListener('change', event => selectCogsProduct(Number(event.currentTarget.value || 0))); 1100 1181 $('#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); 1103 1183 $('#wsmCogsReloadProducts').addEventListener('click', loadCogsProducts); 1104 1184 $('#wsmAddCogsEntry').addEventListener('click', addCogsEntry); -
pfh-shop-manager/trunk/includes/class-pfhsm-plugin.php
r3599418 r3603307 506 506 507 507 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.4 0.js', array(), PFHSM_VERSION, true );508 wp_enqueue_script( 'pfhsm-app', PFHSM_URL . 'assets/app-0.4.45.js', array(), PFHSM_VERSION, true ); 509 509 wp_localize_script( 510 510 'pfhsm-app', -
pfh-shop-manager/trunk/includes/class-pfhsm-rest-controller.php
r3599418 r3603307 133 133 134 134 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 139 135 $name = trim( (string) $order->get_formatted_billing_full_name() ); 140 136 if ( '' !== $name ) { 141 137 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; 142 173 } 143 174 … … 204 235 'regular_price' => $product->get_regular_price(), 205 236 'sale_price' => $product->get_sale_price(), 206 'current_cogs' => method_exists( $product, 'get_cogs_value' ) ? $product->get_cogs_value() : null,207 237 'short_description' => $product->get_short_description(), 208 238 'description' => $product->get_description(), … … 358 388 } while ( $page <= $pages && $page <= $max_pages ); 359 389 360 $sold_cogs_net = $cogs_net * $quantity; 361 $profit = $net_revenue - $sold_cogs_net; 390 $profit = $net_revenue - $cogs_net; 362 391 return array( 363 392 'orders' => count( $orders ), … … 366 395 'tax' => wc_format_decimal( $tax, 2 ), 367 396 '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 ), 370 398 'profit' => wc_format_decimal( $profit, 2 ), 371 399 'margin_percent' => $net_revenue > 0 ? wc_format_decimal( ( $profit / $net_revenue ) * 100, 2 ) : null, … … 375 403 } 376 404 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 426 405 public function cogs_products( WP_REST_Request $request ) { 427 406 if ( $error = $this->require_woocommerce() ) { … … 433 412 $status_filter = sanitize_key( (string) $request->get_param( 'status' ) ); 434 413 $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();438 414 439 415 $products = array(); … … 441 417 if ( $sku_id ) { 442 418 $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 ) ) { 444 420 $products[ $product->get_id() ] = $product; 445 421 } 446 422 } 447 423 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 ); 459 432 if ( '' !== $search ) { 460 433 $result = wc_get_products( … … 463 436 'status' => $statuses, 464 437 's' => $search, 465 'orderby' => ' title',466 'order' => ' ASC',438 'orderby' => 'date', 439 'order' => 'DESC', 467 440 ) 468 441 ); … … 470 443 foreach ( $result as $product ) { 471 444 if ( $product instanceof WC_Product ) { 472 if ( $with_orders && ! isset( $sold_product_ids[ $product->get_id() ] ) ) {473 continue;474 }475 445 $products[ $product->get_id() ] = $product; 476 446 } … … 481 451 $product_list, 482 452 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; 486 464 } 487 465 … … 491 469 } 492 470 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(); 494 473 } 495 474 ); … … 1603 1582 'pfhsm_view_cash_flow' => 'Sales Forecast', 1604 1583 'pfhsm_manage_cash_flow_schedule' => 'Sales Forecast schedules', 1605 'pfhsm_manage_cogs' => 'COGS',1606 1584 'pfhsm_manage_users' => 'Users and groups', 1607 1585 'pfhsm_view_help' => 'Help', -
pfh-shop-manager/trunk/readme.txt
r3599418 r3603307 5 5 Tested up to: 7.0 6 6 Requires PHP: 8.0 7 Stable tag: 0.4.4 07 Stable tag: 0.4.45 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 15 15 PFH Shop Manager provides a branded portal at /pfh-shop-manager/ for managing one WooCommerce shop. 16 16 17 Version 0.4.4 0includes:17 Version 0.4.45 includes: 18 18 19 19 * Product creation and editing, including prices, descriptions, stock and dimensions. … … 29 29 * Potential-sales reporting, CSV/PDF exports and scheduled sales forecast emails. 30 30 * 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. 34 32 * Shop Manager, Shop Assistant and Accountant user roles. 35 33 * Permission-protected login and explicit logout activity for portal users, retained for 90 days. … … 42 40 = Development and source = 43 41 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.4 0.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`.42 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.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`. 45 43 46 44 == Installation == … … 61 59 == Changelog == 62 60 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. 80 86 81 87 = 0.4.36 = -
pfh-shop-manager/trunk/templates/portal.php
r3599418 r3603307 107 107 <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> 108 108 <div class="wsm-toolbar"><label class="wsm-search"><span>⚲</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> 110 110 <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> 111 111 </section> … … 134 134 <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> 135 135 <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> 136 137 </section> 137 138 … … 141 142 <div id="wsmCustomerSummary" class="wsm-summary"></div> 142 143 <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> 143 145 </section> 144 146 … … 148 150 <div id="wsmBestSellerSummary" class="wsm-summary"></div> 149 151 <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> 150 153 </section> 151 154 … … 184 187 <section class="wsm-view" data-panel="cogs"> 185 188 <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>⚲</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> 187 190 <div id="wsmCogsEditor" class="wsm-section" hidden> 188 191 <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 3 3 * Plugin Name: PFH Shop Manager 4 4 * Description: A simplified, permission-controlled WooCommerce management portal for products, customers, shipping, sales and sales forecasting. 5 * Version: 0.4.4 05 * Version: 0.4.45 6 6 * Author: Paul Howe 7 7 * License: GPL-2.0-or-later … … 16 16 defined( 'ABSPATH' ) || exit; 17 17 18 define( 'PFHSM_VERSION', '0.4.4 0' );18 define( 'PFHSM_VERSION', '0.4.45' ); 19 19 define( 'PFHSM_EDITION', 'lite' ); 20 20 define( 'PFHSM_FILE', __FILE__ );
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)