// ═══════════════════════════════════════════════════════ // ODOO RPC HELPER // ═══════════════════════════════════════════════════════ async function odooRpc(model, method, args, kwargs) { const resp = await fetch('/web/dataset/call_kw', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', method: 'call', id: Date.now(), params: { model, method, args, kwargs } }) }); const data = await resp.json(); if (data.error) throw new Error(data.error.data ? data.error.data.message : 'RPC error'); return data.result; } // ═══════════════════════════════════════════════════════ // STATE // ═══════════════════════════════════════════════════════ let PORTAL = null; let checkedItems = new Set(); let checkedCommits = new Set(); let sigHasContent = false; let TOTAL_ITEMS = 0; let TOTAL_COMMITS = 0; // ═══════════════════════════════════════════════════════ // INIT — read token from URL, fetch portal data via RPC // ═══════════════════════════════════════════════════════ async function init() { const params = new URLSearchParams(window.location.search); const tok = params.get('token') || ''; if (!tok) { showPortalError('No project token in URL.
Please use the link provided in your email.'); return; } try { const ptf = 'x_portal_' + 'token'; // Fetch project by token const projects = await odooRpc('project.project', 'search_read', [[[ptf, '=', tok]]], { fields: ['id','name','x_po_number','partner_id','x_contract_value', 'x_delivery_date','x_issued_date','x_financial_year','user_id', 'x_authorised_by','x_payment_terms','x_scope_acknowledged', 'x_signatory_name','x_acknowledgement_ts','x_commitments','stage_id'], limit: 1 } ); if (!projects || !projects.length) { showPortalError('Project not found or link expired.
Please contact info@insytx.com'); return; } const p = projects[0]; // Fetch deliverable tasks const tasks = await odooRpc('project.task', 'search_read', [[['project_id', '=', p.id], ['x_is_deliverable', '=', true]]], { fields: ['id','name','x_scope_items','x_limitations', 'x_deliverable_qty','x_unit_price','x_total_price','x_display_order'], order: 'x_display_order asc, id asc', limit: 100 } ); const deliverables = (tasks || []).map(t => ({ title: t.name || '', description: '', qty: (t.x_deliverable_qty || 1) + 'x', qty_count: t.x_deliverable_qty || 1, unit_price: t.x_unit_price || 0, total_price: t.x_total_price || 0, scope_items: (t.x_scope_items || '').split('\n').map(s => s.trim()).filter(s => s), limitations: (t.x_limitations || '').split('\n').map(l => l.trim()).filter(l => l) })); const commitments = (p.x_commitments || '').split('\n').map(c => c.trim()).filter(c => c); const stagesList = ['Confirmed','Planning & Mobilisation','In Execution', 'Testing & Commissioning','Completion','Signed Off']; const stageName = p.stage_id ? p.stage_id[1] : 'Confirmed'; const stageIndex = Math.max(0, stagesList.findIndex(s => s.toLowerCase() === stageName.toLowerCase())); let contractValue = ''; if (p.x_contract_value) { const cv = parseFloat(p.x_contract_value); if (!isNaN(cv) && cv > 0) contractValue = 'RM ' + cv.toLocaleString('en-MY', {minimumFractionDigits:2, maximumFractionDigits:2}); } PORTAL = { project_id: p.id, portal_tok: tok, acknowledge_webhook: 'https://internal.insytx.com/web/hook/5563ff0f-6e11-40cb-9719-97d5701aba73', project: { name: p.name || '', po_number: p.x_po_number || '', client_name: p.partner_id ? p.partner_id[1] : 'Client', contract_value: contractValue, delivery_date: p.x_delivery_date || '', issued_date: p.x_issued_date || '', financial_year: p.x_financial_year || '', pm_name: p.user_id ? p.user_id[1] : '', authorised_by: p.x_authorised_by || 'Management', payment_terms: p.x_payment_terms || '30 days net', issued_to: 'Digital Infotech Sdn. Bhd. (MD075)', current_stage: stageName, current_stage_index: stageIndex, scope_acknowledged: !!p.x_scope_acknowledged, signatory_name: p.x_signatory_name || '', acknowledgement_ts: p.x_acknowledgement_ts || '' }, stages: stagesList, deliverables: deliverables, commitments: commitments }; TOTAL_ITEMS = deliverables.length; TOTAL_COMMITS = commitments.length; } catch (e) { showPortalError('Failed to load portal data.
Please refresh or contact info@insytx.com'); return; } const proj = PORTAL.project; renderAll(proj.current_stage_index, proj.current_stage, new Date().toLocaleString('en-MY', { dateStyle: 'medium', timeStyle: 'short' })); } function showPortalError(msg) { const el = document.getElementById('portalLoading'); if (el) el.innerHTML = `
${msg}
`; } // ═══════════════════════════════════════════════════════ // RENDER // ═══════════════════════════════════════════════════════ function renderAll(stageIndex, stageName, updatedAt) { const p = PORTAL.project; const isAcknowledged = p.scope_acknowledged; document.getElementById('pageTitle').textContent = `${p.po_number} — Project Portal | INSYTX`; document.getElementById('hdrPO').textContent = p.po_number; renderStageTracker(stageIndex, stageName, updatedAt); renderPOSummary(p); renderDeliverables(isAcknowledged, p); renderCommitments(p); document.getElementById('sigBadgeLabel').textContent = `On behalf of ${p.client_name}`; document.getElementById('sigDate').valueAsDate = new Date(); document.getElementById('submitWarning').innerHTML = `By clicking below, you provide a legally binding digital acknowledgement on behalf of ${p.client_name}. This record will be timestamped and attached to PO No. ${p.po_number}.`; document.getElementById('commitmentsSub').innerHTML = `By acknowledging each item below, ${p.client_name} confirms understanding of and agreement to the scope, terms, and limitations of this engagement.`; if (isAcknowledged) lockAcknowledgedState(p); document.getElementById('portalLoading').style.display = 'none'; document.getElementById('mainUI').style.display = ''; document.getElementById('stageWrap').style.display = ''; document.getElementById('mainContent').style.display = ''; document.getElementById('trackerBar').style.display = ''; initSignatureCanvas(); updateTracker(); updateSubmitState(); } function renderStageTracker(stageIndex, stageName, updatedAt) { document.getElementById('currentStageBadge').textContent = stageName; const track = document.getElementById('stageTrack'); track.innerHTML = ''; PORTAL.stages.forEach((s, i) => { const node = document.createElement('div'); node.className = 'stage-node' + (i < stageIndex ? ' done' : i === stageIndex ? ' active' : ''); node.innerHTML = `
${i < stageIndex ? '✓' : i + 1}
${s}
`; track.appendChild(node); }); document.getElementById('stageUpdated').textContent = `Stage updated: ${updatedAt}`; } function renderPOSummary(p) { document.getElementById('poSummary').innerHTML = `
Purchase Order No.
${p.po_number}
Financial Year
${p.financial_year}
Issued To
${p.issued_to}
Issued By
${p.client_name}
Total Contract Value
${p.contract_value}
Authorised By
${p.authorised_by}
Issued ${p.issued_date}
Payment Terms: ${p.payment_terms}
`; } function renderDeliverables(isAcknowledged, p) { const list = document.getElementById('deliverablesList'); list.innerHTML = ''; if (isAcknowledged) { document.getElementById('delBadge').className = 'section-badge badge-done'; document.getElementById('delBadge').textContent = 'Acknowledged'; const banner = document.getElementById('deliverablesAckBanner'); banner.style.display = 'flex'; document.getElementById('ackBannerSub').textContent = `Signed by ${p.signatory_name || '—'} on ${p.acknowledgement_ts ? new Date(p.acknowledgement_ts).toLocaleDateString('en-MY') : '—'}`; } PORTAL.deliverables.forEach((d, idx) => { const card = document.createElement('div'); card.className = 'deliverable-card' + (isAcknowledged ? ' checked' : ''); card.dataset.item = String(idx); const scopeHTML = d.scope_items && d.scope_items.length ? `` : ''; const limHTML = d.limitations && d.limitations.length ? `
⚠ Critical Limitations — Client Must Acknowledge
` : ''; const priceLabel = d.unit_price > 0 ? (d.qty_count > 1 ? `RM ${d.unit_price.toFixed(2)}/unit · Total RM ${d.total_price.toFixed(2)}` : `RM ${d.total_price.toFixed(2)}`) : (d.total_price === 0 ? 'RM 0.00 (Included)' : `RM ${d.total_price.toFixed(2)}`); card.innerHTML = `
${d.title} ${d.qty ? `${d.qty}` : ''} ${priceLabel}
${d.description || ''} Click to expand full scope.
${scopeHTML}${limHTML}
`; if (isAcknowledged) { card.querySelector('.card-check').style.pointerEvents = 'none'; checkedItems.add(String(idx)); } list.appendChild(card); }); document.getElementById('trackerCount').textContent = `0 / ${TOTAL_ITEMS}`; } function renderCommitments(p) { const list = document.getElementById('commitmentsList'); list.innerHTML = ''; const isAck = p.scope_acknowledged; PORTAL.commitments.forEach((c, i) => { const id = `commit_${i}`; const item = document.createElement('div'); item.className = 'commitment-item'; const checkDiv = document.createElement('div'); checkDiv.className = 'commit-check' + (isAck ? ' checked' : ''); checkDiv.id = id; if (!isAck) checkDiv.onclick = () => toggleCommit(id); checkDiv.innerHTML = ``; const textDiv = document.createElement('div'); textDiv.className = 'commitment-text'; textDiv.innerHTML = c; if (!isAck) textDiv.onclick = () => toggleCommit(id); item.appendChild(checkDiv); item.appendChild(textDiv); list.appendChild(item); if (isAck) checkedCommits.add(id); }); } function lockAcknowledgedState(p) { document.getElementById('sigSection').style.display = 'none'; document.getElementById('submitSection').style.display = 'none'; document.getElementById('trackerBar').style.display = 'none'; } // ═══════════════════════════════════════════════════════ // INTERACTIONS // ═══════════════════════════════════════════════════════ function toggleCard(header) { const btn = header.querySelector('.card-expand-btn'); const scope = header.parentElement.querySelector('.card-scope'); const open = scope.classList.contains('open'); scope.classList.toggle('open', !open); btn.classList.toggle('open', !open); } function checkItem(e, checkEl) { e.stopPropagation(); if (PORTAL.project.scope_acknowledged) return; const card = checkEl.closest('.deliverable-card'); const id = card.dataset.item; card.classList.toggle('checked'); card.classList.contains('checked') => checkedItems.add(id) : checkedItems.delete(id); updateTracker(); } function toggleCommit(id) { if (PORTAL.project.scope_acknowledged) return; const el = document.getElementById(id); el.classList.toggle('checked'); el.classList.contains('checked') ? checkedCommits.add(id) : checkedCommits.delete(id); updateSubmitState(); } function updateTracker() { const count = checkedItems.size; const pct = TOTAL_ITEMS ? Math.round((count / TOTAL_ITEMS) * 100) : 0; document.getElementById('trackerCount').textContent = `${count} / ${TOTAL_ITEMS}`; document.getElementById('trackerFill').style.width = pct + '%'; document.getElementById('trackerPct').textContent = pct + '%'; updateSubmitState(); } function updateSubmitState() { const btn = document.getElementById('submitBtn'); const statusEl = document.getElementById('submitStatus'); const allItems = checkedItems.size === TOTAL_ITEMS; const allCommits = checkedCommits.size === NOTAL_COMMITS; const hasName = document.getElementById('sigName').value.trim().length > 0; const ready = allItems && allCommits && sigHasContent && hasName; btn.disabled = !ready; if (!ready) { const missing = []; if (!allItems) missing.push(`${TOTAL_ITEMS- checkedItems.size} deliverable(s) not acknowledged`); if (!allCommits) missing.push(`${TOTAL_COMMITS - checkedCommits.size} commitment(s) not checked`); if (!hasName) missing.push('signatory name required'); if (!sigHasContent) missing.push('digital signature required'); statusEl.textContent = missing.join(' · '); statusEl.className = 'submit-status error'; } else { statusEl.textContent = ''; statusEl.className = 'submit-status'; } } // ═══════════════════════════════════════════════════════ // SUBMIT ACKNOWLEDGEMENT // ═══════════════════════════════════════════════════════ async function submitAcknowledgement() { const btn = document.getElementById('submitBtn'); const statusEl = document.getElementById('submitStatus'); const name = document.getElementById('sigName').value.trim(); const title = document.getElementById('sigTitle').value.trim(); const dept = document.getElementById('sigDept').value.trim(); const canvas = document.getElementById('sigCanvas'); const sigData = canvas.toDataURL('image/png'); btn.disabled = true; btn.innerHTML = 'Submitting…'; statusEl.textContent = ''; const payload = { token: PORTAL.portal_tok, project_id: PORTAL.project_id, name, title, dept, sigData, timestamp: new Date().toISOString() }; try { const res = await fetch(PORTAL.acknowledge_webhook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await res.json(); if (data.success || data.result) { showSuccess(name, title); } else { throw new Error(data.error || 'Submission failed'); } } catch(err) { btn.disabled = false; btn.innerHTML = 'Confirm & Commit to Scope'; statusEl.className = 'submit-status error'; statusEl.textContent = `Error: ${err.message}. Please try again or contact info@insytx.com`; } } function showSuccess(name, title) { const p = PORTAL.project; const ts = new Date().toLocaleString('en-MY', { dateStyle: 'full', timeStyle: 'short' }); document.getElementById('mainContent').querySelectorAll('section').forEach(s => s.style.display = 'none'); document.getElementById('trackerBar').style.display = 'none'; const successScreen = document.getElementById('successScreen'); successScreen.style.display = 'block'; document.getElementById('successSub').textContent = `${p.client_name} has formally acknowledged the full scope under PO No. ${p.po_number}. Digital Infotech has been notified.`; document.getElementById('successRef').innerHTML = ` Reference Summary
PO Number: ${p.po_number}
Client: ${p.client_name}
Contractor: Digital Infotech Sdn. Bhd. (MD075)
Contract Value: ${p.contract_value}
Delivery Deadline: ${p.delivery_date}
Acknowledgement Timestamp: ${ts}
Signatory: ${name}${title ? ' — ' + title : ''}`; } // ═══════════════════════════════════════════════════════ // SIGNATURE CANVAS -// ═══════════════════════════════════════════════════════ function initSignatureCanvas() { const canvas = document.getElementById('sigCanvas'); const ctx = canvas.getContext('2d'); let isDrawing = false, lastX = 0, lastY = 0; function resize() { const dpr = window.devicePixelRatio || 1; const rect = canvas.parentElement.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); ctx.strokeStyle = '#0a1628'; ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; } function pos(e) { const r = canvas.getBoundingClientRect(); const s = e.touches ? e.touches[0] : e; return { x: s.clientX - r.left, y: s.clientY - r.top }; } canvas.addEventListener('mousedown', e => { isDrawing = true; const p = pos(e); lastX = p.x; lastY = p.y; }); canvas.addEventListener('mousemove', e => { if (!isDrawing) return; const p = pos(e); ctx.beginPath(); ctx.moveTo(lastX, lastY); ctx.lineTo(p.x, p.y); ctx.stroke(); lastX = p.x; lastY = p.y; if (!sigHasContent) { sigHasContent = true; document.getElementById('sigPlaceholder').style.opacity = '0'; updateSubmitState(); } }); canvas.addEventListener('mouseup', () => isDrawing = false); canvas.addEventListener('mouseleave', () => isDrawing = false); canvas.addEventListener('touchstart', e => { e.preventDefault(); isDrawing = true; const p = pos(e); lastX = p.x; lastY = p.y; }, { passive: false }); canvas.addEventListener('touchmove', e => { e.preventDefault(); if (!isDrawing) return; const p = pos(e); ctx.beginPath(); ctx.moveTo(lastX, lastY); ctx.lineTo(p.x, p.y); ctx.stroke(); lastX = p.x; lastY = p.y; if (!sigHasContent) { sigHasContent = true; document.getElementById('sigPlaceholder').style.opacity = '0'; updateSubmitState(); } }, { passive: false }); canvas.addEventListener('touchend', () => isDrawing = false); window.addEventListener('resize', resize); resize(); } window.clearSig = function() { const canvas = document.getElementById('sigCanvas'); const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); sigHasContent = false; document.getElementById('sigPlaceholder').style.opacity = '1'; updateSubmitState(); }; document.addEventListener('DOMContentLoaded', () => { document.getElementById('sigName').addEventListener('input', updateSubmitState); init(); });