// ═══════════════════════════════════════════════════════
// 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 = `
INSYTX
${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 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
? `${d.scope_items.map(s => `- ${s}
`).join('')}
` : '';
const limHTML = d.limitations && d.limitations.length
? `⚠ Critical Limitations — Client Must Acknowledge
${d.limitations.map(l => `- ${l}
`).join('')}
` : '';
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 = `
${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();
});