EastPrnt Clothing
Forgot password?
— OR —
🛒 Place an Order as a Customer

No login needed — fill out the form, we'll handle the rest.

Default credentials (first-time setup)
admin@eastprnt.com / admin123 (Admin)
designer@eastprnt.com / staff123 (Designer)
printer@eastprnt.com / staff123 (Printer)
press@eeastprnt.com / staff123 (Heat Press)
sewing@eastprnt.com/ staff123 (Sewing)
qa@eastprnt.com/ staff123 (QA / Trimming)
⚠ You'll be required to change password on first login
⚡ Quick Login (Test Mode — skips password)
⚠ Disable in Database Settings before going to production.
EastPrnt Clothing
A

🔔 Notifications

🔔
No notifications yet
`); win.document.close(); } function renderQALineupTable(o) { if (!o.players?.length) return `
No players in this order.
`; return `
${['#','Player','Jersey #','Size','Fabric','Collar','Closure','Add-ons'] .map(h=>``).join('')} ${o.players.map((p, i) => { const sz = (p.upperSize && p.shortsSize && p.upperSize !== p.shortsSize) ? `${p.upperSize}/${p.shortsSize}` : (p.size || p.upperSize || '—'); const addons = [p.pocket?'Pocket':'', p.slit?'Slit':''].filter(Boolean).join(', ') || '—'; const bg = i%2===1 ? 'background:var(--gray-50);' : ''; return ``; }).join('')}
${h}
${i+1} ${p.name||'—'} ${p.number||'—'} ${sz} ${p.fabricType||'—'} ${p.collar||'—'} ${p.closure||'—'} ${addons}
`; } function renderPlayers() { const productOptions = state.products.filter(p => p.perPlayer); const html = currentForm.players.map((p, i) => { const prod = getProduct(p.productId); const isSet = isSetProduct(prod); // Size fields with optional size chart button (for both set and non-set) const sizeFieldContent = isSet ? `
${prod && prod.sizeChartImage ? `` : ''}
` : `
${prod && prod.sizeChartImage ? `` : ''}
`; const needsCustom = isSet ? (p.upperSize === 'Custom' || p.shortsSize === 'Custom') : (p.size === 'Custom'); // Collar / Closure / Add-on fields derived from the selected product's config const collarOpts = getCollarOptions(prod); const closureOpts = getClosureOptions(prod); const addonOpts = getAddonOptions(prod); const collarField = collarOpts.length ? `
` : ''; const closureField = closureOpts.length ? `
` : ''; const addonFields = addonOpts.map(a => `
`).join(''); const extraRow = (collarField || closureField || addonFields) ? `
${collarField}${closureField}${addonFields}
` : ''; const headerLabel = p.name.trim() ? p.name : `Player ${i+1}`; return `
${i+1} ${headerLabel}
${sizeFieldContent}
${extraRow} ${needsCustom ? `
` : ''}
`;}).join(''); document.getElementById('playersList').innerHTML = html; const _pcEl = document.getElementById('playerCount'); if (_pcEl) _pcEl.textContent = currentForm.players.length; updateFabricChoiceSection(); recalc(); } function renderAddons() { const html = PRESET_ADDONS.map(a => { const sel = currentForm.addons.find(x => x.id === a.id); return `
${a.name} ${fmt(a.price)} per jersey
`; }).join(''); // custom addons const custom = currentForm.addons.filter(a => a.custom).map((a, i) => `
${a.name} (custom) ${fmt(a.price)}
`).join(''); document.getElementById('addonsList').innerHTML = html + custom; } function toggleAddon(id, checked) { const preset = PRESET_ADDONS.find(a => a.id === id); if (checked) { if (!currentForm.addons.find(a => a.id === id)) currentForm.addons.push({ ...preset }); } else { currentForm.addons = currentForm.addons.filter(a => a.id !== id); } recalc(); } function addCustomAddon() { const name = document.getElementById('customAddonName').value.trim(); const price = parseFloat(document.getElementById('customAddonPrice').value) || 0; if (!name) return toast('Enter an add-on name', 'error'); currentForm.addons.push({ id: 'custom-' + Date.now(), name, price, custom: true }); document.getElementById('customAddonName').value = ''; document.getElementById('customAddonPrice').value = ''; renderAddons(); } function removeCustomAddon(i) { const customs = currentForm.addons.filter(a => a.custom); const target = customs[i]; currentForm.addons = currentForm.addons.filter(a => a !== target); renderAddons(); } // ============================================================ // FABRIC CHOICE — Admin New Order // ============================================================ let _fabricModalFabrics = null; function getAvailableFabricsForForm() { if (!currentForm.players.length) return null; const counts = {}; currentForm.players.forEach(p => { if (p.productId) counts[p.productId] = (counts[p.productId] || 0) + 1; }); const topId = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0]; if (!topId) return null; const prod = getProduct(topId); const cfg = prod?.customizations?.fabrics; if (!cfg?.enabled) return null; const prices = prod.customizations.pricingRules?.fabricPrices || {}; // If selected is already an array (new format), use it exactly — even if empty (means admin // deliberately unchecked all options). Only fall back to defaultOptions for old-format products // where selected was a string (a single default choice, not the available-options filter). const list = Array.isArray(cfg.selected) ? cfg.selected : (cfg.selected ? [cfg.selected] : (cfg.defaultOptions || [])); const result = list.map(name => ({ name, price: prices[name] || 0 })); // Include custom fabrics — price stored directly on each item (cfg.customList || []).filter(c => c?.name).forEach(c => result.push({ name: c.name, price: c.price || 0 })); return result.length ? result : null; } // Returns available collar options for a product (handles old + new data format) function getCollarOptions(prod) { if (!prod) return []; const cfg = prod?.customizations?.collar; if (!cfg?.enabled) return []; const prices = prod.customizations?.pricingRules?.collarPrices || {}; // New format (after saveProductConfig): selected is an ARRAY — the admin-checked subset. // Respect empty [] meaning "admin unchecked everything". Do NOT fall back to defaults. // Old format (DEFAULT_PRODUCTS): selected is a STRING default choice; cfg.options has the full list. // When selected is not an array, show cfg.options (all available options). const names = Array.isArray(cfg.selected) ? cfg.selected : (cfg.options || cfg.defaultOptions || []); const result = names.map(name => ({ name, price: prices[name] || 0 })); (cfg.customList || []).filter(c => c?.name).forEach(c => result.push({ name: c.name, price: c.price || 0 })); return result; } // Returns available closure options for a product function getClosureOptions(prod) { if (!prod) return []; const cfg = prod?.customizations?.closure; if (!cfg?.enabled) return []; const prices = prod.customizations?.pricingRules?.closurePrices || {}; // Same logic as getCollarOptions — array = new format (respects empty), non-array = old format. const names = Array.isArray(cfg.selected) ? cfg.selected : (cfg.options || cfg.defaultOptions || []); const result = names.map(name => ({ name, price: prices[name] || 0 })); (cfg.customList || []).filter(c => c?.name).forEach(c => result.push({ name: c.name, price: c.price || 0 })); return result; } // Returns enabled add-on options (pocket / slit) for a product function getAddonOptions(prod) { if (!prod) return []; const cfg = prod?.customizations?.addons; if (!cfg) return []; const result = []; if (cfg.pocket?.enabled) result.push({ key: 'pocket', label: 'With Pocket', price: cfg.pocket.price || 0 }); if (cfg.slit?.enabled) result.push({ key: 'slit', label: 'With Slit', price: cfg.slit.price || 0 }); return result; } function updateFabricChoiceSection() { const card = document.getElementById('fabricChoiceCard'); if (!card) return; const fabrics = getAvailableFabricsForForm(); if (!fabrics || !fabrics.length) { card.style.display = 'none'; currentForm.fabricAll = { type: null, price: 0 }; currentForm.players.forEach(p => { p.fabricType = null; p.fabricPrice = 0; }); return; } card.style.display = ''; renderFabricChoiceContent(fabrics); } function renderFabricChoiceContent(fabrics) { const content = document.getElementById('fabricChoiceContent'); if (!content) return; const mode = currentForm.fabricApplyMode || 'all'; const selAll = currentForm.fabricAll?.type; const feedback = getFabricFeedbackAdmin(); const modeBtn = (val, label) => { const active = mode === val; return ``; }; const fabricRadios = fabrics.map(f => ` `).join(''); const perPlayerRows = currentForm.players.map((p, i) => `
${p.name || 'Player ' + (i + 1)} #${p.number || '—'} ${p.fabricType ? p.fabricType + (p.fabricPrice > 0 ? ' (+' + fmt(p.fabricPrice) + ')' : ' (included)') : '—'}
`).join(''); content.innerHTML = `

Choose fabric for jerseys. Prices shown are additional costs per player on top of the base product price.

${modeBtn('all', '🔘 Apply to All Players')} ${modeBtn('per-player', '🧑‍🤝‍🧑 Apply per Player')}
${mode === 'all' ? `
${fabricRadios}
${feedback ? `
✓ ${feedback}
` : ''} ` : `
${perPlayerRows || '

Add players above first.

'}
${feedback ? `
✓ ${feedback}
` : ''} `} `; } function getFabricFeedbackAdmin() { if (currentForm.fabricApplyMode === 'all') { return currentForm.fabricAll?.type ? `Fabric "${currentForm.fabricAll.type}" applied to all ${currentForm.players.length} player${currentForm.players.length !== 1 ? 's' : ''}` : ''; } const applied = currentForm.players.filter(p => p.fabricType).length; if (!applied) return ''; return applied === currentForm.players.length ? `Fabric applied to all ${applied} players` : `Fabric applied to ${applied} of ${currentForm.players.length} players`; } function adminSetFabricMode(mode) { currentForm.fabricApplyMode = mode; if (mode === 'all') { currentForm.players.forEach(p => { p.fabricType = null; p.fabricPrice = 0; }); } else { currentForm.fabricAll = { type: null, price: 0 }; } const fabrics = getAvailableFabricsForForm(); if (fabrics) renderFabricChoiceContent(fabrics); recalc(); } function adminSelectFabricAll(name, price) { currentForm.fabricAll = { type: name, price }; currentForm.players.forEach(p => { p.fabricType = name; p.fabricPrice = price; }); const fabrics = getAvailableFabricsForForm(); if (fabrics) renderFabricChoiceContent(fabrics); recalc(); } function adminOpenFabricModal() { _fabricModalFabrics = getAvailableFabricsForForm(); if (!_fabricModalFabrics) return; const existing = document.getElementById('fabricPerPlayerModal'); if (existing) existing.remove(); const playerRows = currentForm.players.map((p, i) => { const radios = _fabricModalFabrics.map(f => ` `).join(''); return `
${p.name || 'Player ' + (i + 1)} #${p.number || '—'}
${radios}
`; }).join(''); const modal = document.createElement('div'); modal.id = 'fabricPerPlayerModal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:5000;display:flex;align-items:center;justify-content:center;padding:20px;'; modal.innerHTML = `

🧵 Select Fabric per Player

Choose a fabric for each player individually.

${playerRows}
`; document.body.appendChild(modal); } function adminUpdatePlayerFabricLive(idx, name, price) { currentForm.players[idx].fabricType = name; currentForm.players[idx].fabricPrice = price; } function adminSaveFabricPerPlayer() { const fabrics = _fabricModalFabrics; const applied = currentForm.players.filter(p => p.fabricType).length; if (applied === 0) { toast('Select at least one player\'s fabric', 'error'); return; } document.getElementById('fabricPerPlayerModal')?.remove(); if (fabrics) renderFabricChoiceContent(fabrics); recalc(); toast(`Fabric applied to ${applied} player${applied !== 1 ? 's' : ''}`, 'success'); } // Per-player collar/closure/addon update handlers function updatePlayerCollar(idx, name) { const p = currentForm.players[idx]; if (!name) { p.collar = null; p.collarPrice = 0; recalc(); return; } const prod = getProduct(p.productId); const prices = prod?.customizations?.pricingRules?.collarPrices || {}; const custom = (prod?.customizations?.collar?.customList || []).find(c => c.name === name); p.collar = name; p.collarPrice = custom ? (custom.price || 0) : (prices[name] || 0); recalc(); } function updatePlayerClosure(idx, name) { const p = currentForm.players[idx]; if (!name) { p.closure = null; p.closurePrice = 0; recalc(); return; } const prod = getProduct(p.productId); const prices = prod?.customizations?.pricingRules?.closurePrices || {}; const custom = (prod?.customizations?.closure?.customList || []).find(c => c.name === name); p.closure = name; p.closurePrice = custom ? (custom.price || 0) : (prices[name] || 0); recalc(); } function updatePlayerAddon(idx, key, price, checked) { currentForm.players[idx][key] = checked; currentForm.players[idx][key + 'Price'] = checked ? price : 0; recalc(); } function calculateOrderTotals(form) { const numPlayers = form.players.length; const playersTotal = form.players.reduce((s, pl) => { const prod = getProduct(pl.productId); return s + (prod ? prod.price : 0); }, 0); const fabricTotal = form.players.reduce((s, pl) => s + (pl.fabricPrice || 0), 0); const collarTotal = form.players.reduce((s, pl) => s + (pl.collarPrice || 0), 0); const closureTotal = form.players.reduce((s, pl) => s + (pl.closurePrice || 0), 0); const pocketTotal = form.players.reduce((s, pl) => s + (pl.pocket ? (pl.pocketPrice || 0) : 0), 0); const slitTotal = form.players.reduce((s, pl) => s + (pl.slit ? (pl.slitPrice || 0) : 0), 0); const extrasTotal = (form.extras || []).reduce((s, e) => { const prod = getProduct(e.productId); return s + (prod ? prod.price * e.qty : 0); }, 0); const addonTotal = form.addons.reduce((s, a) => s + (a.custom ? a.price : a.price * numPlayers), 0); return { playersTotal, fabricTotal, collarTotal, closureTotal, pocketTotal, slitTotal, extrasTotal, addonTotal, total: playersTotal + fabricTotal + collarTotal + closureTotal + pocketTotal + slitTotal + extrasTotal + addonTotal }; } function recalc() { if (!document.getElementById('f_total')) return; const t = calculateOrderTotals(currentForm); const dp = parseFloat(document.getElementById('f_downpayment').value) || 0; const balance = Math.max(0, t.total - dp); let status = 'Unpaid'; if (dp >= t.total && t.total > 0) status = 'Fully Paid'; else if (dp > 0) status = 'Partially Paid'; document.getElementById('f_total').value = fmt(t.total); document.getElementById('f_balance').value = fmt(balance); document.getElementById('f_status').value = status; } async function submitOrder() { // Remove any previous validation errors document.querySelectorAll('.field-error').forEach(el => el.classList.remove('field-error')); const markError = (id) => { const el = document.getElementById(id); if (el) el.classList.add('field-error'); }; const teamName = document.getElementById('f_teamName').value.trim(); const contactPerson = document.getElementById('f_contactPerson').value.trim(); const email = document.getElementById('f_email').value.trim(); const phone = document.getElementById('f_phone').value.trim(); const orderSource = document.getElementById('f_orderSource').value; const facebookUrl = (document.getElementById('f_facebookUrl')?.value || '').trim(); const customerNotes = (document.getElementById('f_customerNotes')?.value || '').trim(); // Validate required fields const missingFields = []; if (!teamName) { missingFields.push('Team Name'); markError('f_teamName'); } if (!contactPerson) { missingFields.push('Contact Person'); markError('f_contactPerson'); } if (!email) { missingFields.push('Email'); markError('f_email'); } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { missingFields.push('valid Email'); markError('f_email'); } if (!phone) { missingFields.push('Phone'); markError('f_phone'); } if (!facebookUrl) { missingFields.push('Facebook Profile Link'); markError('f_facebookUrl'); } else if (!/facebook\.com|fb\.com|m\.me/i.test(facebookUrl)) { missingFields.push('valid Facebook URL'); markError('f_facebookUrl'); } if (missingFields.length) { return toast('Please complete: ' + missingFields.join(', '), 'error'); } if (currentForm.players.length === 0 && currentForm.extras.length === 0) { return toast('Add at least one player or additional item', 'error'); } // Per-player required field validation (name + number with per-field error marks) let playerValid = true; currentForm.players.forEach((p, i) => { if (!p.name.trim()) { const el = document.getElementById(`pname_${i}`); if (el) el.classList.add('field-error'); playerValid = false; } if (!p.number.trim()) { const el = document.getElementById(`pnum_${i}`); if (el) el.classList.add('field-error'); playerValid = false; } }); if (!playerValid) { const noName = currentForm.players.filter(p => !p.name.trim()).length; const noNum = currentForm.players.filter(p => !p.number.trim()).length; const parts = []; if (noName) parts.push(`${noName} missing name`); if (noNum) parts.push(`${noNum} missing jersey #`); toast(`Fix player info — ${parts.join(', ')}`, 'error'); const firstErr = document.querySelector('.player-card .field-error'); if (firstErr) firstErr.closest('.player-card')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; } // Remaining per-player validations (product, sizes, custom measurements) for (const p of currentForm.players) { if (!p.productId) return toast('Select a product for every player', 'error'); const prod = getProduct(p.productId); if (isSetProduct(prod)) { if (!p.upperSize || !p.shortsSize) return toast('Set products need both Upper and Shorts sizes', 'error'); if ((p.upperSize === 'Custom' || p.shortsSize === 'Custom') && !p.customMeasurements) { return toast(`Player "${p.name}": enter custom measurements for the Custom size`, 'error'); } } else if (p.size === 'Custom' && !p.customMeasurements) { return toast(`Player "${p.name}": enter custom measurements for the Custom size`, 'error'); } } // Fabric validation const availFabrics = getAvailableFabricsForForm(); if (availFabrics && availFabrics.length > 0) { if (currentForm.fabricApplyMode === 'all' && !currentForm.fabricAll?.type) { return toast('Please select a fabric for all players', 'error'); } if (currentForm.fabricApplyMode === 'per-player') { const noFabric = currentForm.players.filter(p => !p.fabricType); if (noFabric.length > 0) { return toast(`Select fabric for all players — ${noFabric.length} player${noFabric.length !== 1 ? 's' : ''} without fabric`, 'error'); } } } // ── Payment mode & proof validation ────────────────────────────────────── const modeOfPayment = document.getElementById('f_paymentMode').value; if (!modeOfPayment) { markError('f_paymentMode'); toast('Please select a mode of payment', 'error'); document.getElementById('f_paymentMode').scrollIntoView({ behavior: 'smooth', block: 'center' }); return; } const isOnlinePayment = ['InstaPay','GCash','Maya','Bank Transfer'].includes(modeOfPayment); const isLayoutFirst = modeOfPayment === 'Layout First Pay Later'; const paymentNote = document.getElementById('f_paymentNote').value.trim(); const proofFileEl = document.getElementById('f_proofOfPayment'); if (isLayoutFirst && !paymentNote) { markError('f_paymentNote'); toast('Please add a note for Layout First Pay Later orders', 'error'); document.getElementById('f_paymentNote').scrollIntoView({ behavior: 'smooth', block: 'center' }); return; } if (isOnlinePayment && !proofFileEl.files[0]) { document.getElementById('proofUploadTrigger').style.borderColor = 'var(--danger)'; document.getElementById('proofUploadTrigger').style.background = '#fef2f2'; toast('Please upload proof of payment', 'error'); document.getElementById('proofUploadRow').scrollIntoView({ behavior: 'smooth', block: 'center' }); return; } // ───────────────────────────────────────────────────────────────────────── // Process images with compression const designRefs = []; const refsFiles = document.getElementById('f_designRefs').files; for (let i = 0; i < Math.min(refsFiles.length, 5); i++) { if (refsFiles[i].size > 15 * 1024 * 1024) return toast(`Reference "${refsFiles[i].name}" too large (max 15 MB raw)`, 'error'); try { designRefs.push(await compressImage(refsFiles[i], 1024, 0.78)); } catch (e) { console.warn('Skipped a reference image:', e); } } let logoUpload = null; const logoFile = document.getElementById('f_logoUpload').files[0]; if (logoFile) { if (logoFile.size > 15 * 1024 * 1024) return toast('Logo file too large (max 15 MB raw)', 'error'); try { logoUpload = await compressImage(logoFile, 1024, 0.9); } catch (e) { console.warn('Logo upload skipped:', e); } } const designSpec = { description: document.getElementById('f_designDescription').value.trim(), preferredColors: document.getElementById('f_preferredColors').value.trim(), fontStyle: document.getElementById('f_fontStyle').value, instructions: document.getElementById('f_designInstructions').value.trim(), referenceImages: designRefs, logoImage: logoUpload }; const totals = calculateOrderTotals(currentForm); const total = totals.total; const dp = parseFloat(document.getElementById('f_downpayment').value) || 0; // proof of payment let proof = null; const file = document.getElementById('f_proofOfPayment').files[0]; if (file) { if (file.size > 5 * 1024 * 1024) return toast('Proof of payment too large (max 5 MB)', 'error'); proof = file.type.startsWith('image/') ? await compressImage(file, 1600, 0.85) : await fileToBase64(file); } // Determine if payment is automatically approved (no proof = cash) const isAutoApproved = !proof; let approvedAmount = 0; let paymentsArray = []; if (dp > 0) { const paymentStatus = isAutoApproved ? 'Approved' : 'Pending Approval'; paymentsArray = [{ id: uid('PAY'), amount: dp, method: document.getElementById('f_paymentMode').value, proof, status: paymentStatus, date: new Date().toISOString() }]; if (isAutoApproved) approvedAmount = dp; } // Compute balance and payment status based on approvedAmount only const balance = Math.max(0, total - approvedAmount); let paymentStatus = 'Unpaid'; if (approvedAmount >= total && total > 0) paymentStatus = 'Fully Paid'; else if (approvedAmount > 0) paymentStatus = 'Partially Paid'; const order = { id: uid('ORD'), teamName, contactPerson, email, contactNumber: phone, fabricApplyMode: currentForm.fabricApplyMode || 'all', fabricAll: currentForm.fabricAll || null, players: JSON.parse(JSON.stringify(currentForm.players)), extras: JSON.parse(JSON.stringify(currentForm.extras || [])), addons: JSON.parse(JSON.stringify(currentForm.addons)), paymentMode: modeOfPayment, paymentNote, paidAmount: approvedAmount, totalAmount: total, balance, paymentStatus, payments: paymentsArray, fulfillment: 'Pickup', deliveryAddress: null, rider: null, deliveryStatus: null, eta: document.getElementById('f_eta').value, currentStage: 'Jersey Design', // Client Intake auto‑completes; work starts at Design stages: STAGES.map((s, i) => ({ name: s, status: s === 'Client Intake' ? 'In Progress' : 'On Queue', assignee: STAGE_ROLES[s], startedAt: s === 'Client Intake' ? new Date().toISOString() : null, completedAt: null, notes: '', internalNote: s === 'Client Intake' ? 'Awaiting secretary review' : '', qaApprovals: s === 'Jersey Line-up (QA)' ? [] : undefined })), designSpec, orderSource, facebookProfileUrl: facebookUrl, customerNotes, designUploads: [], timeline: [{ time: new Date().toISOString(), text: `Order created by ${currentUser.name} via ${orderSource}`, user: currentUser.name }], createdAt: new Date().toISOString(), branchId: document.getElementById('f_branch').value || (state.branches[0] && state.branches[0].id), submittedVia: 'internal', validationStatus: 'Pending Review' }; touch(order); state.orders.push(order); logActivity('order_created', { orderId: order.id, branchId: order.branchId, description: `Order created for ${order.teamName} (${order.players.length} players, ${fmt(order.totalAmount)})` }); saveState(); notify(order, 'order_created', `Order ${order.id} created — tracking link sent to ${email} and ${phone}`); SysNotif.create('order_created', `${order.teamName} — ${order.players.length} items, ${fmt(order.totalAmount)}`, order.id); SupabaseSync.pushOrder(order).catch(() => {}); showOrderSuccess(order); navigate('orders'); } // ================================================================= // IMAGE COMPRESSION — resizes uploaded images so they fit in localStorage. // Original 5MB photo → ~80-200KB JPEG. Critical for avoiding quota errors. // ================================================================= async function compressImage(file, maxDim = 1280, quality = 0.82) { if (!file) return null; // If it's not an image (e.g. PDF), just read as-is via base64 with size check. if (!file.type.startsWith('image/')) { if (file.size > 1024 * 1024) throw new Error('Non-image files limited to 1MB. Compress your file first or convert it to an image.'); return fileToBase64(file); } return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); img.onload = () => { try { let { width: w, height: h } = img; if (w > maxDim || h > maxDim) { const ratio = w > h ? maxDim / w : maxDim / h; w = Math.round(w * ratio); h = Math.round(h * ratio); } const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); // White background for transparent PNGs (prevents black backgrounds on JPEG) ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); ctx.drawImage(img, 0, 0, w, h); // PNG → JPEG conversion for ~10x smaller file resolve(canvas.toDataURL('image/jpeg', quality)); } catch (err) { reject(err); } }; img.onerror = () => reject(new Error('Could not load image')); img.src = e.target.result; }; reader.onerror = () => reject(new Error('Could not read file')); reader.readAsDataURL(file); }); } async function uploadSizeChartToSupabase(file, productId) { if (!SupabaseSync.isEnabled() || !SupabaseSync.ready) return null; try { const compressed = await compressImage(file, 1024, 0.85); const blob = await (await fetch(compressed)).blob(); const timestamp = Date.now(); const path = `size-charts/${productId}_${timestamp}.jpg`; const { error } = await SupabaseSync.client.storage .from('designs') .upload(path, blob, { upsert: true, contentType: 'image/jpeg' }); if (error) throw error; const { data: publicUrl } = SupabaseSync.client.storage .from('designs') .getPublicUrl(path); return publicUrl.publicUrl; } catch (err) { console.error('Supabase upload failed:', err); return null; } } function fileToBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); }); } // Format bytes for human-readable display function formatBytes(b) { if (b < 1024) return b + ' B'; if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB'; return (b/1024/1024).toFixed(1) + ' MB'; } // ================================================================= // SIZE CHART MODAL — opens from both customer and internal order forms. // Pulls measurements from state.sizeChart (admin-editable). // ================================================================= function openSizeChartModal() { const chart = state.sizeChart || DEFAULT_SIZE_CHART; document.getElementById('modalTitle').textContent = '📏 Size Chart'; document.getElementById('modalBody').innerHTML = `

All measurements in inches. Pick the size where your chest measurement falls within the listed range.

${chart.map(r => ` `).join('')}
SizeChestLengthShoulderSleeveShorts WaistShorts Length
${r.size} ${r.chest || '—'} ${r.length || '—'} ${r.shoulder || '—'} ${r.sleeve || '—'} ${r.shortsWaist || '—'} ${r.shortsLength || '—'}
📏 Need a custom size? Select "Custom" in the size dropdown for the player who needs custom measurements. A free-text field will appear where you can enter exact dimensions (chest, length, shoulder, etc.) in inches.
`; document.getElementById('modalFooter').innerHTML = ``; document.getElementById('modal').classList.add('active'); } // ================================================================= // SIZE CHART ADMIN PAGE — admin can add/edit/remove sizes & measurements // ================================================================= function renderSizeChart() { const chart = state.sizeChart || DEFAULT_SIZE_CHART; return `
How this works: All measurements are in inches. The "Size" column matches the dropdown options in the order forms. Add a "Custom" entry if you want a special offering, but the system already supports per-player custom measurements via the Custom size option.
${chart.map((r, i) => ` `).join('')}
Size ChestLengthShoulderSleeve Shorts WaistShorts Length
`; } function updateSizeChartField(idx, field, value) { if (!state.sizeChart) state.sizeChart = JSON.parse(JSON.stringify(DEFAULT_SIZE_CHART)); state.sizeChart[idx][field] = value; saveState(); } function addSizeRow() { if (!state.sizeChart) state.sizeChart = []; state.sizeChart.push({ size: 'New', chest: '', length: '', shoulder: '', sleeve: '', shortsWaist: '', shortsLength: '' }); saveState(); navigate('sizechart'); } async function deleteSizeRow(idx) { if (!confirm('Remove this size? (Will be deleted from DATABASE too.)')) return; const row = state.sizeChart[idx]; if (SupabaseSync.isEnabled() && row?.size) { const r = await SupabaseSync.deleteRow('size_chart', row.size, 'size'); if (!r.ok && r.error) return toast('❌ Supabase delete failed: ' + r.error, 'error'); } state.sizeChart.splice(idx, 1); saveState(); navigate('sizechart'); toast('🗑️ Size deleted successfully', 'success'); } function resetSizeChart() { if (!confirm('Reset the size chart to default values? Custom entries will be lost.')) return; state.sizeChart = JSON.parse(JSON.stringify(DEFAULT_SIZE_CHART)); saveState(); navigate('sizechart'); toast('Size chart reset to default', 'success'); } // ================================================================= // KANBAN // ================================================================= function renderKanban() { const allowedStages = currentUser.role === 'Admin' ? STAGES : STAGES.filter(s => STAGE_ROLES[s] === currentUser.role); const cols = STAGES.map(stage => { const orders = scopedOrders().filter(o => o.currentStage === stage); const canMove = allowedStages.includes(stage); const cards = orders.map(o => { const stageObj = o.stages.find(s => s.name === stage) || { status: 'On Queue', internalNote: '' }; const statusBadge = `${stageObj.status}`; // Special-case the Client Intake column to flag public submissions awaiting review or stuck on payment let publicFlag = ''; if (stage === 'Client Intake' && o.submittedVia === 'public') { if (o.validationStatus === 'Pending Review') { publicFlag = `📥 Awaiting Review`; } else if (o.validationStatus === 'Payment Pending') { publicFlag = `⏳ Payment Pending`; } } // NEEDS REVISION — red blinking card if QA has rejected one of the player jerseys const revisionClass = o.needsRevision ? 'needs-revision' : ''; const revisionFlag = o.needsRevision ? `🔴 NEEDS REVISION` : ''; return `
${o.teamName} ${publicFlag} ${revisionFlag}
${o.id.slice(0,12)}… ${o.players.length} pcs
${paymentBadge(o)} ${fmtDate(o.eta)}
${statusBadge} ${stageObj.status === 'Pending' && stageObj.internalNote ? `⚠ ${stageObj.internalNote.length > 24 ? stageObj.internalNote.slice(0,24)+'…' : stageObj.internalNote}` : ''}
${canMove && stage !== 'Client Intake' ? `
` : ''}
`;}).join(''); return `
${stage} ${orders.length}
${cards || '
No orders
'}
`; }).join(''); return ` ${(currentUser.role === 'Admin' || currentUser.role === 'Super User') ? `
` : ''}
${cols}
`; } // Set the status of a specific stage. Handles all 4 states with their behaviors: // - On Queue: just sets the status // - Pending: prompts for an internal-only note (delay reason) // - In Progress: marks startedAt // - Completed: marks completedAt, AUTO-ADVANCES to the next stage (set to "On Queue") function setStageStatus(orderId, stageName, newStatus, internalNote) { const o = state.orders.find(x => x.id === orderId); if (!o) return toast('Order not found', 'error'); const stage = o.stages.find(s => s.name === stageName); if (!stage) return toast('Stage not found', 'error'); // Permission check (Admin can change anything; non-admin must own the stage) if (currentUser.role !== 'Admin' && STAGE_ROLES[stageName] !== currentUser.role) { return toast(`Only ${STAGE_ROLES[stageName]} or Admin can change "${stageName}"`, 'error'); } // Cannot change Client Intake post-creation if (stageName === 'Client Intake') { return toast('Client Intake is finalized at order creation', 'error'); } // Business rule: Cannot mark "Ready for Release" as Completed unless fully paid OR COD if (stageName === 'Ready for Release' && newStatus === 'Completed') { const isCOD = o.fulfillment === 'Delivery'; if (!isCOD && o.balance > 0) { return toast('Cannot complete: order must be fully paid (or COD)', 'error'); } } // Sequence rule (non-admin): cannot move a downstream stage's status while upstream isn't Completed if (currentUser.role !== 'Admin' && newStatus !== 'On Queue') { const idx = STAGES.indexOf(stageName); for (let i = 1; i < idx; i++) { // start at 1, skip Client Intake const upstream = o.stages.find(s => s.name === STAGES[i]); if (upstream && upstream.status !== 'Completed') { return toast(`Previous stage "${STAGES[i]}" must be completed first (or ask Admin to override)`, 'error'); } } } const oldStatus = stage.status; stage.status = newStatus; if (newStatus === 'In Progress' && !stage.startedAt) stage.startedAt = new Date().toISOString(); if (newStatus === 'Completed') { stage.completedAt = new Date().toISOString(); if (!stage.startedAt) stage.startedAt = stage.completedAt; } if (newStatus === 'Pending') { stage.internalNote = internalNote || stage.internalNote || ''; } // Auto-advance: when a stage is Completed, move currentStage to the NEXT stage and set it to On Queue let advanced = false; if (newStatus === 'Completed') { const idx = STAGES.indexOf(stageName); if (idx < STAGES.length - 1) { const nextName = STAGES[idx + 1]; const next = o.stages.find(s => s.name === nextName); if (next && next.status === 'On Queue' || (next && !next.startedAt)) { // Leave next stage at On Queue (default); it will be set to In Progress by the next operator } o.currentStage = nextName; advanced = true; } else { o.currentStage = 'Ready for Release'; } } else { // For non-completed status changes, set currentStage to this stage if it's "active" if (newStatus === 'In Progress' || newStatus === 'Pending') { o.currentStage = stageName; } } o.timeline.push({ time: new Date().toISOString(), text: `Stage "${stageName}" status: ${oldStatus} → ${newStatus}${internalNote ? ' — ' + internalNote : ''}`, user: currentUser.name }); touch(o); logActivity('stage_status_changed', { orderId: o.id, branchId: o.branchId, details: { stage: stageName, from: oldStatus, to: newStatus, internalNote: internalNote || null }, description: `Stage "${stageName}" → ${newStatus}${advanced ? ' (auto-advanced to ' + o.currentStage + ')' : ''}` }); saveState(); // CRITICAL: persist stage change to Supabase (UPDATE, not INSERT) so it survives reload SupabaseSync.updateStage(o.id, stageName, newStatus, { startedAt: stage.startedAt, completedAt: stage.completedAt, notes: stage.notes, internalNote: stage.internalNote, qaApprovals: stage.qaApprovals, currentStage: o.currentStage, needsRevision: o.needsRevision, validationStatus: o.validationStatus }).catch(()=>{}); if (o.timeline && o.timeline.length) SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]).catch(()=>{}); if (newStatus === 'Completed') { notify(o, 'stage_update', `Order ${o.id}: "${stageName}" completed`); if (advanced && o.currentStage !== 'Ready for Release') { const nextDept = SysNotif._STAGE_DEPT[o.currentStage] || 'production'; SysNotif.create('stage_advanced', `${o.teamName} moved to ${o.currentStage}`, o.id, nextDept); } } if (o.currentStage === 'Ready for Release' && advanced) { SysNotif.create('order_complete', `${o.teamName} is ready for ${o.fulfillment === 'Delivery' ? 'delivery' : 'pickup'}`, o.id, 'secretary'); notify(o, 'order_complete', `Order ${o.id} is ready for ${o.fulfillment === 'Delivery' ? 'delivery' : 'pickup'}`); } navigate(currentPage); toast(`Stage "${stageName}" → ${newStatus}`, 'success'); } // Convenience wrapper used by older code paths and the kanban "Advance" button. function advanceStage(orderId) { const o = state.orders.find(x => x.id === orderId); if (!o) return; setStageStatus(orderId, o.currentStage, 'Completed'); } // Prompt for a Pending reason then set status function setStagePending(orderId, stageName) { const reason = prompt( `Why is "${stageName}" pending?\n\n` + `This note is INTERNAL ONLY — clients will NOT see it on the tracking page.` ); if (reason === null) return; // user cancelled setStageStatus(orderId, stageName, 'Pending', reason); } // Used by kanban dropdowns - handles the Pending case (needs prompt) function onKanbanStatusChange(orderId, stageName, newStatus, selectEl) { if (newStatus === 'Pending') { const reason = prompt(`Why is "${stageName}" pending?\n\nThis note is INTERNAL ONLY — clients will NOT see it.`); if (reason === null) { // Restore previous selection const o = state.orders.find(x => x.id === orderId); const stage = o && o.stages.find(s => s.name === stageName); if (stage && selectEl) selectEl.value = stage.status; return; } setStageStatus(orderId, stageName, 'Pending', reason); } else { setStageStatus(orderId, stageName, newStatus); } } // ================================================================= // ORDER DETAIL MODAL // ================================================================= function openOrderDetail(orderId) { const o = state.orders.find(x => x.id === orderId); if (!o) return; const basePath = window.location.pathname.replace(/\/[^/]*$/, '/'); const trackingUrl = window.location.origin + basePath + 'track.html?id=' + o.id; document.getElementById('modalTitle').textContent = `Order ${o.id} — ${o.teamName}`; document.getElementById('modalBody').innerHTML = `
${o.submittedVia === 'public' ? `
📥 Submitted via public form — Validation status: ${o.validationStatus || 'Pending Review'} ${o.validationStatus !== 'Validated' && (currentUser.role === 'Admin' || currentUser.role === 'Secretary') ? `
` : ''}
` : ''}
Branch: ${getBranch(o.branchId)?.name || '—'}
Created: ${fmtDateTime(o.createdAt)}
Contact: ${o.contactPerson}
Phone: ${o.contactNumber}
Email: ${o.email}
ETA: ${fmtDate(o.eta)}
Total: ${fmt(o.totalAmount)}
Paid: ${fmt(o.paidAmount)}
Balance: ${fmt(o.balance)}
Payment: ${paymentBadge(o)} (${o.paymentMode})
Add-ons: ${o.addons.length ? o.addons.map(a => a.name).join(', ') : 'None'}
Order Source: ${o.orderSource || '—'}
Facebook Profile: ${o.facebookProfileUrl ? `${o.facebookProfileUrl}` : '—'}
Public Tracking Link:
🔧 Repair Orders
+ Log Repair
Loading…
`; document.getElementById('modalFooter').innerHTML = ` ${currentUser.role === 'Admin' ? ` ` : ''} `; document.getElementById('modal').classList.add('active'); loadSummaryRepairs(o.id); } async function loadSummaryRepairs(orderId) { const wrap = document.getElementById('summary-repairs-wrap'); if (!wrap) return; if (!SupabaseSync.isEnabled()) { wrap.innerHTML = 'Connect Supabase to see repair orders.'; return; } const repairs = await fetchRepairsFromSupabase(); const linked = repairs.filter(r => r.original_order_id === orderId); if (!linked.length) { wrap.innerHTML = 'No repair orders linked to this order.'; return; } const pc = p => p==='Urgent'?'#fee2e2':p==='High'?'#fef3c7':'#dbeafe'; const pt = p => p==='Urgent'?'#991b1b':p==='High'?'#92400e':'#1e40af'; const sc = s => (s==='Resolved'||s==='Closed')?'#dcfce7':s==='In Progress'?'#fef3c7':'#dbeafe'; const st = s => (s==='Resolved'||s==='Closed')?'#166534':s==='In Progress'?'#92400e':'#1e40af'; wrap.innerHTML = `
${linked.map(r => ` `).join('')}
Repair IDCategoryReason PriorityStageStatusEst. Cost
${r.id} ${r.repair_category || '—'} ${r.repair_reason || '—'} ${r.priority || 'Normal'} ${r.current_stage || '—'} ${r.status || 'Open'} ${r.estimated_cost > 0 ? '₱' + Number(r.estimated_cost).toLocaleString('en-PH', { minimumFractionDigits: 2 }) : '₱0.00'} View
`; } // Edit an existing order. Most fields editable; production timestamps are protected. function openEditOrderModal(orderId) { const o = state.orders.find(x => x.id === orderId); if (!o) return; closeModal(); setTimeout(() => { document.getElementById('modalTitle').textContent = `Edit Order ${o.id}`; document.getElementById('modalBody').innerHTML = `
Note: Players, products, and add-ons can't be edited from this screen — that would require recalculating the entire order. To change those, delete and recreate the order, or contact us for a full edit feature.
`; document.getElementById('modalFooter').innerHTML = ` `; document.getElementById('modal').classList.add('active'); }, 100); } function saveEditedOrder(orderId) { const o = state.orders.find(x => x.id === orderId); if (!o) return; const before = { teamName: o.teamName, contactPerson: o.contactPerson, email: o.email, contactNumber: o.contactNumber, branchId: o.branchId, eta: o.eta, fulfillment: o.fulfillment, deliveryAddress: o.deliveryAddress }; o.teamName = document.getElementById('ed_teamName').value.trim(); o.contactPerson = document.getElementById('ed_contact').value.trim(); o.email = document.getElementById('ed_email').value.trim(); o.contactNumber = document.getElementById('ed_phone').value.trim(); o.branchId = document.getElementById('ed_branch').value; o.eta = document.getElementById('ed_eta').value; o.fulfillment = document.getElementById('ed_fulfillment').value; o.deliveryAddress = o.fulfillment === 'Delivery' ? document.getElementById('ed_address').value.trim() : null; if (o.fulfillment === 'Delivery' && !o.deliveryStatus) o.deliveryStatus = 'Pending Assignment'; // Build a diff for the activity log const changes = {}; Object.keys(before).forEach(k => { if (before[k] !== o[k]) changes[k] = { from: before[k], to: o[k] }; }); o.timeline.push({ time: new Date().toISOString(), text: `Order edited by ${currentUser.name} — fields changed: ${Object.keys(changes).join(', ') || 'none'}`, user: currentUser.name }); touch(o); logActivity('order_edited', { orderId: o.id, branchId: o.branchId, details: changes, description: `Edited fields: ${Object.keys(changes).join(', ') || 'none'}` }); saveState(); SupabaseSync.syncOrderUpdates(o).catch(()=>{}); // Also append the latest timeline event (INSERT-only) if (o.timeline && o.timeline.length) SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]).catch(()=>{}); closeModal(); toast('Order updated', 'success'); setTimeout(() => openOrderDetail(o.id), 200); } function switchTab(ev, tabId) { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-pane').forEach(p => p.style.display = 'none'); ev.target.classList.add('active'); document.getElementById(tabId).style.display = ''; } function renderProgressTracker(o, clientView = false) { return `
${o.stages.map((s, i) => { // Client view: collapse "On Queue"/"Pending" to gray; "In Progress" stays yellow; "Completed" stays green const status = clientView ? (CLIENT_STATUS_MAP[s.status] || s.status) : s.status; const cls = status === 'Completed' ? 'completed' : status === 'In Progress' ? 'in-progress' : ''; return `
${status === 'Completed' ? '✓' : i+1}
${s.name}
${s.completedAt ? fmtDate(s.completedAt) : (s.startedAt ? 'Started ' + fmtDate(s.startedAt) : '')}
`;}).join('')}
`; } function renderInvoice(o) { const numPlayers = o.players.length; // Group players by product for cleaner invoice const productGroups = {}; o.players.forEach(p => { const key = p.productId || 'unknown'; if (!productGroups[key]) productGroups[key] = []; productGroups[key].push(p); }); const productRows = Object.entries(productGroups).map(([pid, players]) => { const prod = getProduct(pid); if (!prod) return `(deleted product)${players.length}——`; return ` ${prod.name}
${players.map(p => `#${p.number} ${p.name} (${p.size})`).join(', ')}
${players.length} ${fmt(prod.price)} ${fmt(prod.price * players.length)} `; }).join(''); const extraRows = (o.extras || []).map(e => { const prod = getProduct(e.productId); if (!prod) return ''; return `${prod.name}${e.qty}${fmt(prod.price)}${fmt(prod.price * e.qty)}`; }).join(''); return `
EZ Jersey
EastPrnt Clothing
${getBranch(o.branchId)?.name || '—'}${getBranch(o.branchId)?.address ? ' — ' + getBranch(o.branchId).address : ''}
INVOICE
${o.id}
${fmtDate(o.createdAt)}
Bill To
${o.teamName}
${o.contactPerson}
${o.email}
${o.contactNumber}
Payment Status
${paymentBadge(o)}
Method
${o.paymentMode}
${productRows} ${extraRows} ${o.addons.map(a => ` `).join('')}
DescriptionQtyUnit PriceTotal
${a.name}${a.custom?'':' (per jersey)'} ${a.custom ? 1 : numPlayers} ${fmt(a.price)} ${fmt(a.custom ? a.price : a.price * numPlayers)}
Subtotal: ${fmt(o.totalAmount)}
Paid: ${fmt(o.paidAmount)}
Balance Due: ${fmt(o.balance)}
`; } // ================================================================= // OFFICIAL RECEIPT — Secretary / Admin / Super User can attach an OR // image on request from the customer. Customers can download from // their tracking page once uploaded. // ================================================================= function renderOfficialReceiptSection(o) { const canManage = ['Admin', 'Super User', 'Secretary'].includes(currentUser.role); const or = o.officialReceipt; let html = '
'; html += '
🧾 Official Receipt (OR)
'; if (or && or.url) { html += `
Official Receipt
OR Attached
Uploaded ${fmtDate(or.uploadedAt)} by ${or.uploadedBy}
⬇ Download OR ${canManage ? `` : ''}
`; } else if (!canManage) { html += '
No Official Receipt attached yet.
'; } if (canManage) { html += `
`; } html += '
'; return html; } async function uploadOfficialReceipt(orderId) { const o = state.orders.find(x => x.id === orderId); if (!o) return; const file = document.getElementById('orFile').files[0]; if (!file) return toast('Choose an image file first', 'error'); if (!file.type.startsWith('image/') && file.type !== 'application/pdf') return toast('File must be an image or PDF', 'error'); if (file.size > 15 * 1024 * 1024) return toast('File too large (max 15 MB)', 'error'); const btn = document.querySelector('button[onclick*="uploadOfficialReceipt"]'); const originalLabel = btn ? btn.textContent : ''; if (btn) { btn.disabled = true; btn.textContent = '⏳ Uploading…'; } try { let url; if (SupabaseSync.isEnabled()) { url = await SupabaseSync.uploadImageToStorage(file, 'receipts', orderId); } else { url = await compressImage(file, 1280, 0.85); } o.officialReceipt = { url, uploadedAt: new Date().toISOString(), uploadedBy: currentUser.name }; o.timeline.push({ time: new Date().toISOString(), text: `Official Receipt attached by ${currentUser.name}`, user: currentUser.name }); touch(o); logActivity('or_attached', { orderId: o.id, branchId: o.branchId }); saveState(); SupabaseSync.syncOrderUpdates(o).catch(() => {}); if (o.timeline.length) SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length - 1]).catch(() => {}); toast('✓ Official Receipt attached', 'success'); refreshOrderDetailTab(o, 'tab-invoice'); } catch (e) { console.error(e); toast('Upload failed: ' + (e.message || e), 'error'); } finally { if (btn) { btn.disabled = false; btn.textContent = originalLabel; } } } function removeOfficialReceipt(orderId) { const o = state.orders.find(x => x.id === orderId); if (!o) return; if (!confirm('Remove the attached Official Receipt?')) return; o.officialReceipt = null; o.timeline.push({ time: new Date().toISOString(), text: `Official Receipt removed by ${currentUser.name}`, user: currentUser.name }); touch(o); saveState(); SupabaseSync.syncOrderUpdates(o).catch(() => {}); toast('Official Receipt removed', 'info'); refreshOrderDetailTab(o, 'tab-invoice'); } // ================================================================= // DESIGN UPLOAD + CLIENT APPROVAL // ================================================================= function renderDesignsTab(o) { if (!o.designs) o.designs = []; const canUpload = currentUser.role === 'Admin' || currentUser.role === 'Designer'; const hasAwaitingApproval = o.designs.some(d => d.status === 'Awaiting Approval' || d.status === 'Changes Requested'); return `
How design approval works:
${o.designs.length > 0 && hasAwaitingApproval ? `
📨 Designs ready for customer review
Send the customer a notification so they know to visit their tracking link and approve.
` : ''} ${canUpload ? `
Upload New Design
` : ''}
${o.designs.length === 0 ? '
No designs uploaded yet
' : o.designs.map((d, i) => `
${d.label}
${d.label || 'Design ' + (i+1)}
Uploaded ${fmtDate(d.uploadedAt)} by ${d.uploadedBy}
${d.status}
${d.feedback ? `
"${d.feedback}"
` : ''} ${currentUser.role === 'Admin' ? ` ` : ''}
`).join('')}
`; } // ================================================================= // DESIGN BRIEF TAB — read-only view of what the customer asked for. // Helps the designer get it right on the first try. // ================================================================= function renderDesignSpecTab(o) { const spec = o.designSpec || {}; const refs = spec.referenceImages || []; return `
📋 Customer's design brief — everything the customer specified at order intake. The designer uses this to build the mockup.
Order Source: ${o.orderSource || 'Direct / Walk-in'}
Team Name: ${o.teamName}
Design Description:
${spec.description || '— not provided —'}
Preferred Colors: ${spec.preferredColors || '—'}
Font Style: ${spec.fontStyle || '—'}
Special Instructions:
${spec.instructions || '— none —'}
${spec.logoImage ? `
🏆 Uploaded Team Logo
` : ''} ${refs.length ? `
🖼️ Reference Images (${refs.length})
${refs.map((src,i) => ``).join('')}
` : ''} `; } // ================================================================= // QA LINE-UP TAB — Designer uploads per-player jersey designs here. // QA reviews each one: ✓ Approve or ✕ Reject (with required comment). // Reject sends the order back to Designer with "NEEDS REVISION" status. // ================================================================= function renderQATab(o) { if (!o.compositeDesign && o.designSpec?.composite) { o.compositeDesign = o.designSpec.composite; } const isDesigner = currentUser.role === 'Designer' || currentUser.role === 'Admin'; const isQA = currentUser.role === 'Design QA' || currentUser.role === 'Admin'; // Normal upload section (only in Jersey Design AND not rejected) let uploadSection = ''; if (isDesigner && o.currentStage === 'Jersey Design' && (!o.compositeDesign || o.compositeDesign.status !== 'Rejected')) { uploadSection = `
📤 Upload Composite Jersey Design

Upload a single high‑resolution image showing all players' jerseys.

${o.compositeDesign ? `
✓ Composite design uploaded. ${o.compositeDesign.status !== 'Approved' ? `` : ''}
Composite Preview
` : ''}
`; } // Rejected state: show reason and revision upload section (only in Jersey Design after rejection) let revisionUploadSection = ''; if (isDesigner && o.currentStage === 'Jersey Design' && o.compositeDesign && o.compositeDesign.status === 'Rejected') { revisionUploadSection = `
⚠️ Design Rejected

Reason for rejection: ${o.compositeDesign.feedback || 'No comment provided'}

🔄 Upload Revised Composite Design

Please upload a revised version addressing the feedback above.

${o.compositeDesign.image ? `
Current Rejected Design
` : ''}
`; } // QA review section (visible if composite design exists, regardless of stage) let qaSection = ''; if (o.compositeDesign) { const status = o.compositeDesign.status || 'Pending Review'; const statusBadge = status === 'Approved' ? 'badge-success' : (status === 'Rejected' ? 'badge-danger' : 'badge-gray'); qaSection = `
📷 Composite Design Image
Composite Design
Uploaded ${fmtDate(o.compositeDesign.uploadedAt)} by ${o.compositeDesign.uploadedBy}
${status} ${o.compositeDesign.feedback ? `` : ''}
${isQA && o.currentStage === 'Jersey Line-up (QA)' && status !== 'Approved' ? `
` : ''} ${isQA && o.currentStage === 'Jersey Line-up (QA)' && status === 'Approved' ? `
✓ Composite design approved!
` : ''}
`; } else { qaSection = `
No composite design uploaded yet.
`; } const lineupSection = `
📋 Jersey QA Lineup (${o.players?.length || 0} players)
${renderQALineupTable(o)}
`; return `
🔍 QA workflow:
  1. Designer uploads a single composite image showing all jerseys.
  2. Designer clicks Mark as Completed → Send to QA.
  3. QA reviews the image: ✓ Approve or ✕ Reject (with comment).
  4. If rejected, order returns to Designer with "NEEDS REVISION" status and rejection reason shown.
  5. Designer uploads a revised composite and the order automatically goes to QA.
  6. Once approved, QA clicks Mark QA Complete → Send to Print.
${lineupSection} ${uploadSection} ${revisionUploadSection} ${qaSection} `; } async function uploadPlayerJersey(orderId) { const o = state.orders.find(x => x.id === orderId); if (!o) return; const playerIdx = parseInt(document.getElementById('qaPlayerSelect').value); const file = document.getElementById('qaJerseyFile').files[0]; if (!file) return toast('Choose a jersey image first', 'error'); if (!file.type.startsWith('image/')) return toast('File must be an image', 'error'); if (file.size > 15 * 1024 * 1024) return toast('Image too large (max 15 MB raw)', 'error'); const btn = document.querySelector('button[onclick*="uploadPlayerJersey"]'); const originalLabel = btn ? btn.textContent : ''; if (btn) { btn.disabled = true; btn.textContent = '⏳ Uploading…'; } try { let imageUrl; if (SupabaseSync.isEnabled()) { imageUrl = await SupabaseSync.uploadImageToStorage(file, 'jerseys', orderId); } else { imageUrl = await compressImage(file, 1280, 0.82); } if (!o.designUploads) o.designUploads = []; const existing = o.designUploads.findIndex(u => u.playerIdx === playerIdx); const uploadId = uid('JDS'); const entry = { id: uploadId, playerIdx, image: imageUrl, uploadedAt: new Date().toISOString(), uploadedBy: currentUser.name }; if (existing >= 0) { const oldId = o.designUploads[existing].id; if (SupabaseSync.isEnabled()) await SupabaseSync.deleteDesignUpload(oldId).catch(()=>{}); o.designUploads[existing] = entry; } else { o.designUploads.push(entry); } const player = o.players[playerIdx]; o.timeline.push({ time: new Date().toISOString(), text: `Player jersey uploaded for ${player.name} (#${player.number}) by ${currentUser.name}`, user: currentUser.name }); touch(o); logActivity('jersey_uploaded', { orderId: o.id, branchId: o.branchId, description: `Jersey design uploaded for ${player.name}` }); saveState(); if (SupabaseSync.isEnabled()) { await SupabaseSync.pushDesignUpload(o.id, entry); await SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]); } toast('✅ Images uploaded successfully', 'success'); refreshOrderDetailTab(o, 'tab-qa'); } catch (e) { console.error(e); toast('Upload failed: ' + (e.message || e), 'error'); } finally { if (btn) { btn.disabled = false; btn.textContent = originalLabel; } } } // Refreshes the order detail modal's body in-place + repaints the kanban behind. function refreshOrderDetailTab(o, focusTabId) { // Re-render the kanban/dashboard/orders page behind the modal if (['kanban','dashboard','orders'].includes(currentPage)) { const main = document.getElementById('mainContent'); if (main) { try { if (currentPage === 'kanban') main.innerHTML = renderKanban(); else if (currentPage === 'dashboard') main.innerHTML = renderDashboard(); else if (currentPage === 'orders') main.innerHTML = renderOrders(); } catch (e) {} } } // Re-open the order detail modal, then switch to the tab that was active openOrderDetail(o.id); if (focusTabId) { setTimeout(() => { const tabs = document.querySelectorAll(`.modal .tab`); tabs.forEach(t => { const onclick = t.getAttribute('onclick') || ''; if (onclick.includes(focusTabId)) t.click(); }); }, 50); } } function sendToQA(orderId) { const o = state.orders.find(x => x.id === orderId); if (!o) return toast('Order not found', 'error'); if (!o.compositeDesign) return toast('Upload composite design first', 'error'); const designStage = o.stages.find(s => s.name === 'Jersey Design'); if (designStage) { designStage.status = 'Completed'; designStage.completedAt = new Date().toISOString(); } let qaStage = o.stages.find(s => s.name === 'Jersey Line-up (QA)'); if (!qaStage) { qaStage = { name: 'Jersey Line-up (QA)', status: 'In Progress', assignee: 'QA / Trimming', startedAt: new Date().toISOString(), completedAt: null, notes: '', internalNote: '', qaApprovals: [] }; const idx = o.stages.findIndex(s => s.name === 'Jersey Design'); o.stages.splice(idx + 1, 0, qaStage); } else { qaStage.status = 'In Progress'; qaStage.startedAt = new Date().toISOString(); qaStage.completedAt = null; qaStage.internalNote = ''; } o.currentStage = 'Jersey Line-up (QA)'; o.needsRevision = false; o.timeline.push({ time: new Date().toISOString(), text: `Composite design sent to QA for review`, user: currentUser.name }); touch(o); logActivity('sent_to_qa', { orderId: o.id, branchId: o.branchId }); saveState(); SupabaseSync.syncOrderUpdates(o).catch(()=>{}); if (o.timeline && o.timeline.length) { SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]).catch(()=>{}); } notify(o, 'qa_pending', `Order ${o.id} ready for QA review`); SysNotif.create('qa_ready', `${o.teamName} composite design ready for QA review`, o.id); toast('✓ Order moved to QA for review', 'success'); refreshOrderDetailTab(o, 'tab-qa'); } function qaApproveComposite(orderId, approve) { const o = state.orders.find(x => x.id === orderId); if (!o || !o.compositeDesign) return; const qaStage = o.stages.find(s => s.name === 'Jersey Line-up (QA)'); if (approve) { o.compositeDesign.status = 'Approved'; o.compositeDesign.approvedAt = new Date().toISOString(); o.compositeDesign.approvedBy = currentUser.name; if (o.compositeDesign.feedback) delete o.compositeDesign.feedback; // CRITICAL: Sync the status into design_spec for the tracking page if (!o.designSpec) o.designSpec = {}; o.designSpec.composite = { ...o.compositeDesign }; o.timeline.push({ time: new Date().toISOString(), text: `QA approved the composite design`, user: currentUser.name }); SysNotif.create('qa_passed', `Composite design approved for ${o.teamName} — ready for production`, o.id); logActivity('qa_approved_composite', { orderId: o.id, branchId: o.branchId }); } else { const comment = prompt('Why are you rejecting this composite design? (Required)'); if (!comment || !comment.trim()) return toast('Rejection comment is required', 'error'); o.compositeDesign.status = 'Rejected'; o.compositeDesign.feedback = comment.trim(); o.compositeDesign.rejectedAt = new Date().toISOString(); o.compositeDesign.rejectedBy = currentUser.name; if (!o.designSpec) o.designSpec = {}; o.designSpec.composite = { ...o.compositeDesign }; if (qaStage) { qaStage.status = 'Pending'; qaStage.internalNote = `Rejected: ${comment}`; } const designStage = o.stages.find(s => s.name === 'Jersey Design'); if (designStage) { designStage.status = 'In Progress'; designStage.internalNote = 'NEEDS REVISION – design rejected by QA'; } o.currentStage = 'Jersey Design'; o.needsRevision = true; o.timeline.push({ time: new Date().toISOString(), text: `QA rejected composite design: "${comment}" – order returned to Designer`, user: currentUser.name }); SysNotif.create('qa_flagged', `Composite design rejected for ${o.teamName} — needs revision: ${comment}`, o.id); logActivity('qa_rejected_composite', { orderId: o.id, branchId: o.branchId, details: { reason: comment } }); notify(o, 'qa_rejected', `Order ${o.id} – composite design rejected. Please revise.`); } touch(o); saveState(); // Explicitly update the orders table in Supabase with the new design_spec if (SupabaseSync.isEnabled() && SupabaseSync.client) { SupabaseSync.client.from('orders').update({ design_spec: o.designSpec }).eq('id', o.id).then(({ error }) => { if (error) console.error('Failed to update design_spec in Supabase:', error); else console.log('✅ design_spec updated in Supabase'); }).catch(err => console.error(err)); SupabaseSync.syncOrderUpdates(o).catch(()=>{}); if (o.timeline && o.timeline.length) { SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]).catch(()=>{}); } } refreshOrderDetailTab(o, 'tab-qa'); if (currentPage === 'kanban') navigate('kanban'); } function qaApproveJersey(orderId, uploadId, approve) { const o = state.orders.find(x => x.id === orderId); if (!o) return; const qaStage = o.stages.find(s => s.name === 'Jersey Line-up (QA)'); if (!qaStage) return; if (!qaStage.qaApprovals) qaStage.qaApprovals = []; const upload = o.designUploads.find(u => u.id === uploadId); if (!upload) return; const player = o.players[upload.playerIdx]; if (approve) { // Remove any existing entry for this upload (e.g., previous rejection) qaStage.qaApprovals = qaStage.qaApprovals.filter(a => a.uploadId !== uploadId); qaStage.qaApprovals.push({ uploadId, status: 'Approved', approvedAt: new Date().toISOString(), approvedBy: currentUser.name }); o.timeline.push({ time: new Date().toISOString(), text: `QA ✓ approved jersey for ${player.name} (#${player.number})`, user: currentUser.name }); logActivity('qa_approved_jersey', { orderId: o.id, branchId: o.branchId, description: `QA approved: ${player.name}` }); // Recalculate needsRevision based on any remaining rejections const anyRejected = qaStage.qaApprovals.some(a => a.status === 'Rejected'); if (!anyRejected) { o.needsRevision = false; const designStage = o.stages.find(s => s.name === 'Jersey Design'); if (designStage) designStage.internalNote = ''; if (qaStage.status === 'Pending') qaStage.status = 'In Progress'; } else { o.needsRevision = true; // still have rejections } } else { // Rejection handling (unchanged) const comment = prompt(`Why are you rejecting this jersey for ${player.name} (#${player.number})?\n\nA comment is required so the designer knows what to fix.`); if (!comment || !comment.trim()) return toast('Rejection comment is required', 'error'); qaStage.qaApprovals = qaStage.qaApprovals.filter(a => a.uploadId !== uploadId); qaStage.qaApprovals.push({ uploadId, status: 'Rejected', rejectedAt: new Date().toISOString(), rejectedBy: currentUser.name, comment: comment.trim() }); qaStage.status = 'Pending'; qaStage.internalNote = `Rejection: ${player.name} — ${comment.trim()}`; const designStage = o.stages.find(s => s.name === 'Jersey Design'); if (designStage) { designStage.status = 'In Progress'; designStage.internalNote = 'NEEDS REVISION — see QA tab for details'; } o.currentStage = 'Jersey Design'; o.needsRevision = true; o.timeline.push({ time: new Date().toISOString(), text: `QA ✕ REJECTED jersey for ${player.name}: "${comment.trim()}" — order returned to Designer`, user: currentUser.name }); logActivity('qa_rejected_jersey', { orderId: o.id, branchId: o.branchId, details: { player: player.name, reason: comment.trim() }, description: `QA rejected ${player.name}: ${comment.trim()}` }); notify(o, 'qa_rejected', `Order ${o.id} returned to designer — QA rejected ${player.name}'s jersey`); } touch(o); saveState(); SupabaseSync.syncOrderUpdates(o).catch(() => {}); if (o.timeline && o.timeline.length) { SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length - 1]).catch(() => {}); } // Refresh the modal and the kanban view refreshOrderDetailTab(o, 'tab-qa'); if (currentPage === 'kanban') { navigate('kanban'); // force re‑render of the kanban board } } async function reuploadPlayerJersey(orderId, uploadId) { const o = state.orders.find(x => x.id === orderId); if (!o) return; const fileEl = document.getElementById('reupload-' + uploadId); const file = fileEl?.files[0]; if (!file) return toast('Choose a revised image first', 'error'); if (file.size > 15 * 1024 * 1024) return toast('Image too large (max 15 MB raw)', 'error'); const image = await compressImage(file, 1280, 0.82); const upload = o.designUploads.find(u => u.id === uploadId); if (!upload) return; upload.image = image; upload.uploadedAt = new Date().toISOString(); upload.uploadedBy = currentUser.name; // Clear the rejection — back to Pending Review const qaStage = o.stages.find(s => s.name === 'Jersey Line-up (QA)'); if (qaStage) qaStage.qaApprovals = qaStage.qaApprovals.filter(a => a.uploadId !== uploadId); const player = o.players[upload.playerIdx]; o.timeline.push({ time: new Date().toISOString(), text: `Designer re-uploaded revised jersey for ${player.name} (#${player.number})`, user: currentUser.name }); // Clear NEEDS REVISION flag if no more rejections const stillRejected = (qaStage?.qaApprovals || []).some(a => a.status === 'Rejected'); if (!stillRejected) { o.needsRevision = false; const designStage = o.stages.find(s => s.name === 'Jersey Design'); if (designStage) designStage.internalNote = ''; if (qaStage) { qaStage.status = 'In Progress'; qaStage.internalNote = ''; } o.currentStage = 'Jersey Line-up (QA)'; } touch(o); logActivity('jersey_reuploaded', { orderId: o.id, branchId: o.branchId, description: `Re-uploaded: ${player.name}` }); saveState(); SupabaseSync.syncOrderUpdates(o).catch(()=>{}); // Also append the latest timeline event (INSERT-only) if (o.timeline && o.timeline.length) SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]).catch(()=>{}); toast('✓ Revised jersey uploaded — back to QA review', 'success'); refreshOrderDetailTab(o, 'tab-qa'); } function completeQA(orderId) { const o = state.orders.find(x => x.id === orderId); if (!o) return; if (!o.compositeDesign || o.compositeDesign.status !== 'Approved') { return toast('Composite design must be approved first', 'error'); } const qaStage = o.stages.find(s => s.name === 'Jersey Line-up (QA)'); if (qaStage) { qaStage.status = 'Completed'; qaStage.completedAt = new Date().toISOString(); } o.currentStage = 'Print'; o.needsRevision = false; const printStage = o.stages.find(s => s.name === 'Print'); if (printStage) printStage.status = 'On Queue'; o.timeline.push({ time: new Date().toISOString(), text: `QA Line-up completed – composite design approved. Sent to Print.`, user: currentUser.name }); touch(o); logActivity('qa_completed', { orderId: o.id, branchId: o.branchId }); saveState(); SupabaseSync.syncOrderUpdates(o).catch(()=>{}); SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]).catch(()=>{}); notify(o, 'qa_complete', `Order ${o.id} cleared QA – sent to Print`); toast('✓ QA complete – order sent to Print', 'success'); refreshOrderDetailTab(o, 'tab-stages'); } async function uploadDesign(orderId) { const o = state.orders.find(x => x.id === orderId); if (!o) return; const label = document.getElementById('designLabel').value.trim() || 'Design ' + ((o.designs||[]).length + 1); const file = document.getElementById('designFile').files[0]; if (!file) return toast('Choose an image file first', 'error'); if (!file.type.startsWith('image/')) return toast('File must be an image', 'error'); if (file.size > 15 * 1024 * 1024) return toast('Image too large (max 15 MB raw)', 'error'); const btn = document.querySelector('button[onclick*="uploadDesign"]'); if (btn) { btn.disabled = true; btn.textContent = '⏳ Uploading…'; } try { let imageUrl; if (SupabaseSync.isEnabled()) { imageUrl = await SupabaseSync.uploadImageToStorage(file, 'mockups', orderId); } else { imageUrl = await compressImage(file, 1280, 0.82); } const newDesign = { id: uid('DSN'), label, image: imageUrl, uploadedAt: new Date().toISOString(), uploadedBy: currentUser.name, status: 'Awaiting Approval', feedback: '' }; if (!o.designs) o.designs = []; o.designs.push(newDesign); o.timeline.push({ time: new Date().toISOString(), text: `Design "${label}" uploaded by ${currentUser.name}`, user: currentUser.name }); touch(o); logActivity('design_uploaded', { orderId: o.id, branchId: o.branchId, description: `Uploaded design: ${label}` }); saveState(); if (SupabaseSync.isEnabled()) { await SupabaseSync.pushDesign(o.id, newDesign); await SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]); } toast('✅ Images uploaded successfully', 'success'); refreshOrderDetailTab(o, 'tab-designs'); setTimeout(() => { if (confirm('📨 Notify the customer now so they can approve the design?')) notifyCustomerForDesign(o.id); }, 600); } catch (e) { console.error(e); toast('Upload failed: ' + (e.message || e), 'error'); } finally { if (btn) { btn.disabled = false; btn.textContent = '⬆ Upload'; } } } async function uploadCompositeDesign(orderId) { const o = state.orders.find(x => x.id === orderId); if (!o) return; const file = document.getElementById('compositeDesignFile').files[0]; if (!file) return toast('Choose an image file', 'error'); if (!file.type.startsWith('image/')) return toast('File must be an image', 'error'); if (file.size > 15 * 1024 * 1024) return toast('Image too large (max 15 MB)', 'error'); const btn = document.querySelector('button[onclick*="uploadCompositeDesign"]'); if (btn) { btn.disabled = true; btn.textContent = '⏳ Uploading…'; } try { let imageUrl; if (SupabaseSync.isEnabled()) { imageUrl = await SupabaseSync.uploadImageToStorage(file, 'composites', orderId); } else { imageUrl = await compressImage(file, 1280, 0.85); } o.compositeDesign = { id: uid('CMP'), image: imageUrl, uploadedAt: new Date().toISOString(), uploadedBy: currentUser.name, status: 'Pending Review' }; o.needsRevision = false; const designStage = o.stages.find(s => s.name === 'Jersey Design'); if (designStage) designStage.internalNote = ''; o.timeline.push({ time: new Date().toISOString(), text: `Composite jersey design uploaded by ${currentUser.name}`, user: currentUser.name }); touch(o); logActivity('composite_design_uploaded', { orderId: o.id, branchId: o.branchId }); saveState(); if (SupabaseSync.isEnabled()) { if (!o.designSpec) o.designSpec = {}; o.designSpec.composite = o.compositeDesign; await SupabaseSync.client.from('orders').update({ design_spec: o.designSpec }).eq('id', o.id); await SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]); } toast('✅ Composite design uploaded. Click "Mark as Completed → Send to QA" to proceed.', 'success'); refreshOrderDetailTab(o, 'tab-qa'); } catch (e) { toast('Upload failed: ' + e.message, 'error'); } finally { if (btn) { btn.disabled = false; btn.textContent = '⬆ Upload'; } } } async function uploadRevisedComposite(orderId) { const o = state.orders.find(x => x.id === orderId); if (!o) return; const file = document.getElementById('revisedCompositeFile').files[0]; if (!file) return toast('Choose a revised image file', 'error'); if (!file.type.startsWith('image/')) return toast('File must be an image', 'error'); if (file.size > 15 * 1024 * 1024) return toast('Image too large (max 15 MB)', 'error'); const btn = document.querySelector('button[onclick*="uploadRevisedComposite"]'); if (btn) { btn.disabled = true; btn.textContent = '⏳ Uploading…'; } try { if (o.compositeDesign && o.compositeDesign.image && SupabaseSync.isEnabled()) { await SupabaseSync.deleteFileFromStorage(o.compositeDesign.image); } let imageUrl; if (SupabaseSync.isEnabled()) { imageUrl = await SupabaseSync.uploadImageToStorage(file, 'composites', orderId); } else { imageUrl = await compressImage(file, 1280, 0.85); } o.compositeDesign = { id: uid('CMP'), image: imageUrl, uploadedAt: new Date().toISOString(), uploadedBy: currentUser.name, status: 'Pending Review' }; o.needsRevision = false; const designStage = o.stages.find(s => s.name === 'Jersey Design'); if (designStage) designStage.internalNote = ''; o.timeline.push({ time: new Date().toISOString(), text: `Designer uploaded revised composite design (old image deleted)`, user: currentUser.name }); touch(o); logActivity('composite_revised_uploaded', { orderId: o.id, branchId: o.branchId }); if (SupabaseSync.isEnabled()) { if (!o.designSpec) o.designSpec = {}; o.designSpec.composite = o.compositeDesign; await SupabaseSync.client.from('orders').update({ design_spec: o.designSpec }).eq('id', o.id); await SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]); } await sendToQA(orderId); toast('✅ Revised composite uploaded and sent to QA for review', 'success'); } catch (e) { toast('Upload failed: ' + e.message, 'error'); } finally { if (btn) { btn.disabled = false; btn.textContent = '⬆ Upload Revised'; } } } async function deleteDesign(orderId, designId) { if (!confirm('Remove this design? (Will be deleted from DATABASE too.)')) return; const o = state.orders.find(x => x.id === orderId); if (!o || !o.designs) return; if (SupabaseSync.isEnabled()) { const r = await SupabaseSync.deleteDesign(designId); if (!r.ok && r.error) return toast('❌ Supabase delete failed: ' + r.error, 'error'); } o.designs = o.designs.filter(d => d.id !== designId); touch(o); logActivity('design_deleted', { orderId: o.id, branchId: o.branchId, description: 'Removed a design upload' }); saveState(); toast('🗑️ Design deleted successfully', 'success'); // Also append the latest timeline event (INSERT-only) if (o.timeline && o.timeline.length) SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]).catch(()=>{}); closeModal(); setTimeout(() => openOrderDetail(o.id), 100); } // Customer-side: from the public tracking page function clientApproveDesign(orderId, designId, approve) { const o = state.orders.find(x => x.id === orderId); if (!o || !o.designs) return; const d = o.designs.find(x => x.id === designId); if (!d) return; if (approve) { d.status = 'Approved'; d.approvedAt = new Date().toISOString(); o.timeline.push({ time: new Date().toISOString(), text: `Design "${d.label}" APPROVED by client`, user: 'Customer' }); } else { const feedback = prompt('What changes would you like? (Your feedback will be visible to the design team)'); if (feedback === null) return; d.status = 'Changes Requested'; d.feedback = feedback; d.feedbackAt = new Date().toISOString(); o.timeline.push({ time: new Date().toISOString(), text: `Design "${d.label}" — client requested changes: ${feedback}`, user: 'Customer' }); } touch(o); if (!state.activityLog) state.activityLog = []; state.activityLog.push({ id: uid('LOG'), userId: null, userName: o.contactPerson, userRole: 'Customer', action: approve ? 'design_client_approved' : 'design_client_changes_requested', orderId: o.id, branchId: o.branchId, description: approve ? `Client approved design "${d.label}"` : `Client requested changes on "${d.label}"`, timestamp: new Date().toISOString(), _updatedAt: Date.now() }); saveState(); // Refresh tracking page showTrackingPage(o.id); } function addStageNote(orderId) { const o = state.orders.find(x => x.id === orderId); const note = document.getElementById('stageNote').value.trim(); if (!note) return; const stage = o.stages.find(s => s.name === o.currentStage); stage.notes = note; o.timeline.push({ time: new Date().toISOString(), text: `Note on "${o.currentStage}": ${note}`, user: currentUser.name }); touch(o); saveState(); SupabaseSync.syncOrderUpdates(o).catch(()=>{}); // Also append the latest timeline event (INSERT-only) if (o.timeline && o.timeline.length) SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]).catch(()=>{}); toast('Note saved', 'success'); openOrderDetail(orderId); } async function approvePayment(orderId, payId, approve) { const o = state.orders.find(x => x.id === orderId); const p = o.payments.find(x => x.id === payId); if (!p) return; if (approve) { if (p.status === 'Approved') { toast('Payment already approved', 'info'); return; } p.status = 'Approved'; applyPayment(o); o.timeline.push({ time: new Date().toISOString(), text: `Payment ${p.id} approved by ${currentUser.name}`, user: currentUser.name }); notify(o, 'payment_approved', `Payment of ${fmt(p.amount)} approved for order ${o.id}`); SysNotif.create('payment_approved', `${fmt(p.amount)} via ${p.method} approved for ${o.teamName}`, o.id); logActivity('payment_approved', { orderId: o.id, branchId: o.branchId, description: `Approved payment of ${fmt(p.amount)} via ${p.method}` }); } else { p.status = 'Rejected'; applyPayment(o); o.timeline.push({ time: new Date().toISOString(), text: `Payment ${p.id} rejected by ${currentUser.name}`, user: currentUser.name }); SysNotif.create('payment_rejected', `${fmt(p.amount)} payment rejected for ${o.teamName}`, o.id); logActivity('payment_rejected', { orderId: o.id, branchId: o.branchId, description: `Rejected payment of ${fmt(p.amount)} via ${p.method}` }); } touch(o); saveState(); // Sync with Supabase if (SupabaseSync.isEnabled() && SupabaseSync.ready) { await SupabaseSync.updatePaymentStatus(p.id, p.status).catch(e => console.warn(e)); await SupabaseSync.syncOrderUpdates(o).catch(e => console.warn(e)); if (o.timeline && o.timeline.length) { await SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]).catch(e => console.warn(e)); } } else { SupabaseSync.syncOrderUpdates(o).catch(()=>{}); if (o.timeline && o.timeline.length) SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]).catch(()=>{}); } toast('Payment ' + (approve ? 'approved' : 'rejected'), 'success'); // Refresh the payments page if currently on it if (currentPage === 'payments') navigate('payments'); else openOrderDetail(orderId); } async function addPayment(orderId) { const o = state.orders.find(x => x.id === orderId); const amt = parseFloat(document.getElementById('addPayAmount').value); if (!amt || amt <= 0) return toast('Enter a valid amount', 'error'); if (amt > o.balance) return toast(`Cannot exceed balance of ${fmt(o.balance)}`, 'error'); const method = document.getElementById('addPayMethod').value; const file = document.getElementById('addPayProof').files[0]; let proof = file ? await fileToBase64(file) : null; const pay = { id: uid('PAY'), amount: amt, method, proof, status: proof ? 'Pending Approval' : 'Approved', date: new Date().toISOString() }; o.payments.push(pay); if (pay.status === 'Approved') { applyPayment(o); // recalc if auto‑approved } o.timeline.push({ time: new Date().toISOString(), text: `Payment of ${fmt(amt)} via ${method} added (${pay.status})`, user: currentUser.name }); logActivity('payment_added', { orderId: o.id, branchId: o.branchId, description: `Added payment of ${fmt(amt)} via ${method} (${pay.status})` }); touch(o); saveState(); // Sync with Supabase if (SupabaseSync.isEnabled() && SupabaseSync.ready) { await SupabaseSync.pushPayment(o.id, pay).catch(e => console.warn(e)); await SupabaseSync.syncOrderUpdates(o).catch(e => console.warn(e)); if (o.timeline.length) await SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]).catch(e => console.warn(e)); } else { SupabaseSync.syncOrderUpdates(o).catch(()=>{}); if (o.timeline.length) SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]).catch(()=>{}); } toast('Payment added', 'success'); openOrderDetail(orderId); } function applyPayment(o) { // Recalculate paid amount from all approved payments only const totalApproved = o.payments.reduce((sum, p) => { if (p.status === 'Approved') return sum + p.amount; return sum; }, 0); o.paidAmount = totalApproved; o.balance = Math.max(0, o.totalAmount - o.paidAmount); o.paymentStatus = o.balance <= 0 ? 'Fully Paid' : (o.paidAmount > 0 ? 'Partially Paid' : 'Unpaid'); } async function addPayment(orderId) { const o = state.orders.find(x => x.id === orderId); if (!o) return toast('Order not found', 'error'); const amt = parseFloat(document.getElementById('addPayAmount').value); if (!amt || amt <= 0) return toast('Enter a valid amount', 'error'); if (amt > o.balance) return toast(`Cannot exceed balance of ${fmt(o.balance)}`, 'error'); const method = document.getElementById('addPayMethod').value; const fileInput = document.getElementById('addPayProof'); const file = fileInput?.files[0]; let proof = null; if (file) { if (file.size > 10 * 1024 * 1024) { return toast('Proof of payment too large (max 10 MB)', 'error'); } try { if (file.type.startsWith('image/')) { // Try compression, fallback to raw base64 if compression fails try { proof = await compressImage(file, 1024, 0.78); } catch (compressErr) { console.warn('Image compression failed, using raw base64:', compressErr); proof = await fileToBase64(file); } } else { // PDF or other non‑image proof = await fileToBase64(file); } } catch (err) { console.error('File processing error:', err); return toast('Failed to process proof file: ' + err.message, 'error'); } } const pay = { id: uid('PAY'), amount: amt, method, proof, status: proof ? 'Pending Approval' : 'Approved', date: new Date().toISOString() }; o.payments.push(pay); if (pay.status === 'Approved') { applyPayment(o); } o.timeline.push({ time: new Date().toISOString(), text: `Payment of ${fmt(amt)} via ${method} added (${pay.status})`, user: currentUser.name }); logActivity('payment_added', { orderId: o.id, branchId: o.branchId, description: `Added payment of ${fmt(amt)} via ${method} (${pay.status})` }); touch(o); saveState(); // Sync to Supabase if (SupabaseSync.isEnabled() && SupabaseSync.ready) { try { await SupabaseSync.pushPayment(o.id, pay); await SupabaseSync.syncOrderUpdates(o); if (o.timeline.length) { await SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length - 1]); } } catch (e) { console.warn('Supabase sync error:', e); toast('Payment added locally but sync to database failed', 'error'); } } else { SupabaseSync.syncOrderUpdates(o).catch(() => {}); if (o.timeline.length) SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length - 1]).catch(() => {}); } // Reset form fields document.getElementById('addPayAmount').value = ''; fileInput.value = ''; toast('Payment added', 'success'); openOrderDetail(orderId); } async function deleteOrder(orderId) { if (!confirm('Delete this order? This cannot be undone. (Will also be removed from DATABASE.)')) return; const o = state.orders.find(x => x.id === orderId); if (!o) return toast('Order not found', 'error'); // 1. Delete from Supabase FIRST (source of truth). If this fails, abort. if (SupabaseSync.isEnabled()) { const result = await SupabaseSync.deleteOrder(orderId); if (!result.ok && result.reason !== 'not-configured') { return toast('❌ Failed to delete from Supabase: ' + (result.error || 'unknown'), 'error'); } } // 2. Update local state only after DB delete succeeds state.orders = state.orders.filter(x => x.id !== orderId); logActivity('order_deleted', { orderId: o.id, branchId: o.branchId, description: `Deleted order for ${o.teamName}` }); saveState(); closeModal(); navigate(currentPage); toast('🗑️ Order deleted successfully', 'success'); } function closeModal() { document.getElementById('modal').classList.remove('active'); } // ================================================================= // PAYMENTS PAGE // ================================================================= // ================================================================= // INVOICES & EXCEL EXPORT — BIR-compliance ready bulk exports // ================================================================= let invoiceFilters = { dateFrom: '', dateTo: '', status: '', customer: '', selected: new Set() }; function renderInvoices() { const allOrders = scopedOrders(); // Apply filters const filtered = allOrders.filter(o => { if (invoiceFilters.dateFrom && o.createdAt < invoiceFilters.dateFrom) return false; if (invoiceFilters.dateTo && o.createdAt > invoiceFilters.dateTo + 'T23:59:59') return false; if (invoiceFilters.status && o.paymentStatus !== invoiceFilters.status) return false; if (invoiceFilters.customer) { const q = invoiceFilters.customer.toLowerCase(); if (!(o.teamName.toLowerCase().includes(q) || o.contactPerson.toLowerCase().includes(q))) return false; } return true; }); const allSelected = filtered.length > 0 && filtered.every(o => invoiceFilters.selected.has(o.id)); const totalRevenue = filtered.reduce((s,o) => s + (o.totalAmount||0), 0); const totalPaid = filtered.reduce((s,o) => s + (o.paidAmount||0), 0); return `
📊 BIR-compliant export: The exported file contains the columns commonly required for BIR Sales/Official Receipt journals — TIN-friendly format with order ID, customer name, date, gross sales, paid amount, balance, and payment method. Opens in Excel, Google Sheets, or any spreadsheet app.
Filters
Total Invoices
${filtered.length}
Total Revenue
${fmt(totalRevenue)}
Collected
${fmt(totalPaid)}
Outstanding
${fmt(totalRevenue - totalPaid)}
${filtered.length === 0 ? '' : filtered.map(o => ` `).join('')}
Invoice / OrderDateCustomerTotalPaidBalanceStatusSource
No invoices match your filters.
${o.id} ${fmtDate(o.createdAt)} ${o.teamName}
${o.contactPerson}
${fmt(o.totalAmount)} ${fmt(o.paidAmount)} ${fmt(o.balance)} ${paymentBadge(o)} ${o.orderSource || 'Direct'}
`; } function toggleInvoice(orderId, checked) { if (checked) invoiceFilters.selected.add(orderId); else invoiceFilters.selected.delete(orderId); // Don't re-navigate — just update the count in the header const countEl = document.querySelector('.page-sub'); if (countEl) countEl.textContent = countEl.textContent.replace(/\d+ selected/, invoiceFilters.selected.size + ' selected'); } function toggleAllInvoices(checked) { const allOrders = scopedOrders(); const filtered = allOrders.filter(o => { if (invoiceFilters.dateFrom && o.createdAt < invoiceFilters.dateFrom) return false; if (invoiceFilters.dateTo && o.createdAt > invoiceFilters.dateTo + 'T23:59:59') return false; if (invoiceFilters.status && o.paymentStatus !== invoiceFilters.status) return false; if (invoiceFilters.customer) { const q = invoiceFilters.customer.toLowerCase(); if (!(o.teamName.toLowerCase().includes(q) || o.contactPerson.toLowerCase().includes(q))) return false; } return true; }); if (checked) filtered.forEach(o => invoiceFilters.selected.add(o.id)); else filtered.forEach(o => invoiceFilters.selected.delete(o.id)); navigate('invoices'); } // Export invoices as Excel-compatible file (uses XML SpreadsheetML — opens in Excel/Sheets/LibreOffice) function exportInvoicesExcel(selectedOnly) { const allOrders = scopedOrders(); let orders = allOrders.filter(o => { if (invoiceFilters.dateFrom && o.createdAt < invoiceFilters.dateFrom) return false; if (invoiceFilters.dateTo && o.createdAt > invoiceFilters.dateTo + 'T23:59:59') return false; if (invoiceFilters.status && o.paymentStatus !== invoiceFilters.status) return false; if (invoiceFilters.customer) { const q = invoiceFilters.customer.toLowerCase(); if (!(o.teamName.toLowerCase().includes(q) || o.contactPerson.toLowerCase().includes(q))) return false; } return true; }); if (selectedOnly) orders = orders.filter(o => invoiceFilters.selected.has(o.id)); if (orders.length === 0) return toast('No invoices to export', 'error'); // BIR-compliant column set: most journals need at minimum // Invoice/Receipt No., Date, Customer Name, Address, TIN, Gross, Discount, Net, VAT, Total // We provide the standard columns + optional fields businesses commonly customize. const columns = [ 'Invoice No.','Date','Customer Name','Contact Person','Email','Phone','Address', 'Branch','Order Source','Items Count','Gross Sales','Paid Amount','Balance', 'Payment Status','Payment Method','Fulfillment','ETA','Notes' ]; // Build SpreadsheetML XML (Excel 2003 format — universally supported by Excel/Sheets/LibreOffice) const xmlEscape = (s) => String(s == null ? '' : s).replace(/[<>&"']/g, c => ({'<':'<','>':'>','&':'&','"':'"',"'":"'"}[c])); const headerRow = columns.map(c => `${xmlEscape(c)}`).join(''); const rows = orders.map(o => { const br = getBranch(o.branchId); const items = (o.players ? o.players.length : 0) + (o.extras||[]).reduce((s,e)=>s+e.qty,0); const cells = [ o.id, fmtDate(o.createdAt), o.teamName, o.contactPerson, o.email, o.contactNumber, o.deliveryAddress || '', br?.name || '', o.orderSource || 'Direct', items, o.totalAmount, o.paidAmount, o.balance, o.paymentStatus, o.paymentMode, o.fulfillment, o.eta || '', '' ]; return '' + cells.map((c, i) => { const type = (i >= 9 && i <= 12) ? 'Number' : 'String'; return `${xmlEscape(c)}`; }).join('') + ''; }).join(''); const xml = ` ${headerRow} ${rows}
`; const blob = new Blob([xml], { type: 'application/vnd.ms-excel' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `invoices-${new Date().toISOString().slice(0,10)}.xls`; a.click(); URL.revokeObjectURL(url); logActivity('invoices_exported', { description: `Exported ${orders.length} invoices to Excel` }); saveState(); toast(`✓ Exported ${orders.length} invoices to Excel`, 'success'); } function renderPayments() { const orders = scopedOrders(); const pending = []; orders.forEach(o => o.payments.forEach(p => { if (p.status === 'Pending Approval') pending.push({ order: o, payment: p }); })); const overdue = orders.filter(o => o.balance > 0 && o.currentStage !== 'Ready for Release'); return `
Pending Approvals (${pending.length})
${pending.length === 0 ? '' : pending.map(({order, payment}) => ` `).join('')}
OrderTeamAmountMethodProofDate
No payments awaiting approval
${order.id} ${order.teamName} ${fmt(payment.amount)} ${payment.method} ${payment.proof ? `View` : '—'} ${fmtDateTime(payment.date)}
Outstanding Balances (${overdue.length})
${overdue.length === 0 ? '' : overdue.map(o => ` `).join('')}
OrderTeamTotalPaidBalanceStage
All orders fully paid
${o.id} ${o.teamName} ${fmt(o.totalAmount)} ${fmt(o.paidAmount)} ${fmt(o.balance)} ${o.currentStage}
`; } function viewProofModal(imageData) { // Remove existing modal if any const existing = document.getElementById('proofModal'); if (existing) existing.remove(); const modal = document.createElement('div'); modal.id = 'proofModal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.92);z-index:10001;display:flex;align-items:center;justify-content:center;padding:20px;'; modal.innerHTML = `

Proof of Payment

Proof of Payment
`; document.body.appendChild(modal); // Close on escape key const escHandler = (e) => { if (e.key === 'Escape') { closeProofModal(); document.removeEventListener('keydown', escHandler); } }; document.addEventListener('keydown', escHandler); } function closeProofModal() { const modal = document.getElementById('proofModal'); if (modal) modal.remove(); } function zoomProofImage(img) { // Toggle full-screen zoom on the image if (img.style.transform === 'scale(1.5)') { img.style.transform = 'scale(1)'; img.style.transition = 'transform 0.2s'; } else { img.style.transform = 'scale(1.5)'; img.style.transition = 'transform 0.2s'; } } function downloadProofImage(imageData) { const link = document.createElement('a'); link.href = imageData; link.download = 'proof-of-payment.png'; link.click(); } function sendReminder(orderId) { const o = state.orders.find(x => x.id === orderId); notify(o, 'payment_reminder', `Payment reminder sent to ${o.contactPerson} (${o.email}) for ${fmt(o.balance)} balance`); toast('Reminder sent', 'success'); } // ================================================================= // DELIVERY // ================================================================= function renderDelivery() { const deliveries = scopedOrders().filter(o => o.fulfillment === 'Delivery'); return `
${deliveries.length === 0 ? '' : deliveries.map(o => ` `).join('')}
OrderTeamAddressStageRiderStatusAction
No delivery orders
${o.id} ${o.teamName} ${o.deliveryAddress || '—'} ${o.currentStage}
`; } function assignRider(orderId, riderId) { const o = state.orders.find(x => x.id === orderId); o.rider = riderId || null; const rider = state.riders.find(r => r.id === riderId); o.timeline.push({ time: new Date().toISOString(), text: `Rider assigned: ${rider ? rider.name : 'Unassigned'}`, user: currentUser.name }); logActivity('rider_assigned', { orderId: o.id, branchId: o.branchId, description: `Rider: ${rider ? rider.name : 'Unassigned'}` }); touch(o); saveState(); SupabaseSync.syncOrderUpdates(o).catch(()=>{}); // Also append the latest timeline event (INSERT-only) if (o.timeline && o.timeline.length) SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]).catch(()=>{}); toast('Rider updated', 'success'); } function setDeliveryStatus(orderId, status) { const o = state.orders.find(x => x.id === orderId); o.deliveryStatus = status; o.timeline.push({ time: new Date().toISOString(), text: `Delivery status: ${status}`, user: currentUser.name }); logActivity('delivery_status_changed', { orderId: o.id, branchId: o.branchId, description: `Delivery: ${status}` }); if (status === 'Delivered') notify(o, 'delivered', `Order ${o.id} marked as delivered`); touch(o); saveState(); SupabaseSync.syncOrderUpdates(o).catch(()=>{}); // Also append the latest timeline event (INSERT-only) if (o.timeline && o.timeline.length) SupabaseSync.pushTimelineEntry(o.id, o.timeline[o.timeline.length-1]).catch(()=>{}); toast('Status updated', 'success'); } // ================================================================= // PRODUCTS & PRICING (ADMIN) // // // ================================================================= function renderProducts() { const byCat = {}; state.products.forEach(p => { if (!byCat[p.category]) byCat[p.category] = []; byCat[p.category].push(p); }); const cats = Object.keys(byCat).sort(); return `
${cats.map(cat => `
${cat}
${byCat[cat].map(p => `
${p.name} ${fmt(p.price)}
${p.perPlayer ? 'Per‑Player' : 'Extra Item'} • ${p.sized ? 'Sized' : 'No Size'}
`).join('')}
`).join('')}
`; } function openBasicEditProductModal(productId) { const prod = state.products.find(p => p.id === productId); if (!prod) return; const catOptions = (state.categories || []) .map(c => `