const UNSELECTED_SVG = ``;
const SELECTED_SVG = ``;
class TrackAndTraceWidget {
constructor(baseUrl, mountSelector) {
this.baseUrl = baseUrl;
this.root = document.querySelector(mountSelector);
this.data = [];
this.activeIndex = 0;
this.signatureModal = null;
this.signatureImageEl = null;
this.signatureMetaEl = null;
this.currentSignatureUrl = null;
}
init() {
this.renderSkeleton();
this.bindSearch();
}
renderSkeleton() {
this.root.innerHTML = `
`;
this.setupSignatureModal();
}
bindSearch() {
const input = this.root.querySelector('input');
const button = this.root.querySelector('button');
button.onclick = () => this.fetchData(input.value.trim());
}
async fetchData(search) {
if (!search) return;
try {
const res = await fetch(
`${this.baseUrl}/MVC/CourierStatus/GetStatusWithDetails?search=${encodeURIComponent(search)}`
);
if (!res.ok) {
throw new Error('API request failed');
}
const json = await res.json();
if (!Array.isArray(json) || json.length === 0) {
console.warn('No tracking data found');
return;
}
this.data = json;
this.activeIndex = 0;
// đĨ SHOW LAYOUT ONLY AFTER DATA IS VALID
const layout = this.root.querySelector('#tt-layout');
layout.style.display = 'flex';
this.render();
} catch (err) {
console.error('Track & Trace error:', err);
}
}
render() {
this.renderMaster();
this.renderSwitcher();
this.renderTimeline();
}
renderMaster() {
const el = this.root.querySelector('#tt-master');
const d = this.data[this.activeIndex];
if (!d) {
el.innerHTML = '';
return;
}
const totalEvents = Array.isArray(d.Details) ? d.Details.length : 0;
el.innerHTML = `
Tracking Summary
${this.row('Job No', d.JobNumber)}
${this.row('Booking No', d.BookingNumber)}
${this.row('Place of Receipt', d.Origin)}
${this.row('Place of Delivery', d.Destination)}
${this.row('Vessel / Voyage', d.VesselName)}
${this.row('Complete Loading', d.CompleteLoading)}
${this.row('Complete Discharge', d.CompleteDischarge)}
`;
}
row(label, value) {
return `
${this.iconForLabel(label)}
${label}
${value || '-'}
`;
}
iconForLabel(label) {
const map = {
'Job No': 'đ',
'Booking No': 'đī¸',
'Place of Receipt': 'đ',
'Place of Delivery': 'đ',
'Vessel / Voyage': 'â´ī¸',
'Complete Loading': 'đĸ',
'Complete Discharge': 'â',
};
return map[label] || 'âšī¸';
}
renderSwitcher() {
const el = this.root.querySelector('#tt-switcher');
el.innerHTML = '';
this.data.forEach((c, i) => {
const btn = document.createElement('div');
btn.className = 'tt-truck-btn';
const svg = (i === this.activeIndex)
? SELECTED_SVG
: UNSELECTED_SVG;
btn.innerHTML = `
${svg}
${c.ContainerId}
`;
btn.onclick = () => {
this.activeIndex = i;
this.render();
};
el.appendChild(btn);
});
}
renderTimeline() {
const el = this.root.querySelector('#tt-timeline');
const container = this.data[this.activeIndex];
if (!container || !Array.isArray(container.Details)) {
el.innerHTML = '';
return;
}
const details = [...container.Details].sort(
(a, b) => new Date(b.Date) - new Date(a.Date)
);
el.innerHTML = details.map(d => {
const dateParts = this.formatDateParts(d.Date);
const trimmedStatus = (d.Status || '').trim();
const normalizedStatus = trimmedStatus.toLowerCase();
const colorClass = this.statusColorClass(normalizedStatus);
const saleId = this.extractSaleId(d) || this.extractSaleId(container);
const shouldShowAction = this.shouldShowSignatureButton(normalizedStatus);
const saleAttr = saleId ? `data-sale-id="${saleId}"` : '';
const actionButton = shouldShowAction
? ``
: '';
return `
${dateParts.day}
${dateParts.month}
${dateParts.year}
${dateParts.fullDate}${d.Time ? ' âĸ ' + d.Time : ''}
`;
}).join('');
this.attachSignatureHandlers();
}
attachSignatureHandlers() {
const actions = this.root.querySelectorAll('.tt-event-action');
actions.forEach(btn => {
const saleId = btn.dataset.saleId;
const eventTitle = btn.dataset.eventTitle;
btn.onclick = () => this.fetchAndShowSignature(saleId, eventTitle);
});
}
setupSignatureModal() {
this.signatureModal = this.root.querySelector('#tt-signature-modal');
if (!this.signatureModal) return;
const backdrop = this.signatureModal.querySelector('[data-role="backdrop"]');
const closeButton = this.signatureModal.querySelector('.tt-modal-close');
this.signatureImageEl = this.signatureModal.querySelector('.tt-signature-img');
this.signatureMetaEl = this.signatureModal.querySelector('[data-role="meta"]');
const closeHandler = () => this.closeSignatureModal();
backdrop?.addEventListener('click', closeHandler);
closeButton?.addEventListener('click', closeHandler);
}
shouldShowSignatureButton(normalizedStatus) {
return normalizedStatus === 'deliver to customer';
}
extractSaleId(detail) {
return detail?.SaleId || detail?.saleId || detail?.SaleID || detail?.saleID || null;
}
escapeHtml(value) {
return (value || '').replace(/[&<>"']/g, char => {
const escapeMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
return escapeMap[char];
});
}
async fetchAndShowSignature(saleId, title) {
if (!saleId) return;
try {
const payload = await this.fetchJson(`${this.getApiUrl('api/digitalsignature/get-data')}?SaleId=${saleId}`);
const attachments = Array.isArray(payload?.attachments) ? payload.attachments : [];
const attachment = attachments.find(item => this.getAttachmentArchiveId(item));
if (!attachment) {
console.warn('No digital signature attachment available');
return;
}
const archiveId = this.getAttachmentArchiveId(attachment);
if (!archiveId) return;
const downloadUrl = `${this.getApiUrl('MVC/Archive/DownloadArchive')}?ArchiveId=${archiveId}`;
const imageBlob = await this.fetchBlob(downloadUrl);
const objectUrl = URL.createObjectURL(imageBlob);
if (this.currentSignatureUrl) {
URL.revokeObjectURL(this.currentSignatureUrl);
}
this.currentSignatureUrl = objectUrl;
const metaText = `${title || attachment?.title || 'DigitalSignature'} ${attachment?.date || ''} ${attachment?.time || ''}`.trim();
this.openSignatureModal(objectUrl, metaText);
} catch (err) {
console.error('Digital signature error', err);
}
}
openSignatureModal(imageUrl, metaText) {
if (!this.signatureModal || !this.signatureImageEl) return;
this.signatureImageEl.src = imageUrl;
if (this.signatureMetaEl) {
this.signatureMetaEl.textContent = metaText;
}
this.signatureModal.classList.add('is-open');
}
closeSignatureModal() {
if (!this.signatureModal) return;
this.signatureModal.classList.remove('is-open');
if (this.signatureImageEl) {
this.signatureImageEl.removeAttribute('src');
}
if (this.currentSignatureUrl) {
URL.revokeObjectURL(this.currentSignatureUrl);
this.currentSignatureUrl = null;
}
}
getAttachmentArchiveId(attachment) {
return attachment?.archiveId ?? attachment?.ArchiveId ?? null;
}
getApiUrl(path) {
const normalizedBase = this.baseUrl.replace(/\/+$/, '');
return `${normalizedBase}/${path.replace(/^\/+/, '')}`;
}
async fetchJson(url) {
const res = await fetch(url);
if (!res.ok) {
throw new Error('Digital signature request failed');
}
return res.json();
}
async fetchBlob(url) {
const res = await fetch(url);
if (!res.ok) {
throw new Error('Archive download failed');
}
return res.blob();
}
formatDateParts(dateString) {
if (!dateString) {
return { day: '--', month: '', year: '', fullDate: '' };
}
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) {
return { day: '--', month: '', year: '', fullDate: dateString };
}
const day = String(date.getDate()).padStart(2, '0');
const month = date.toLocaleString('en-US', { month: 'short' }).toUpperCase();
const year = date.getFullYear();
const fullDate = `${month} ${day}, ${year}`;
return { day, month, year, fullDate };
}
statusColorClass(status) {
const map = {
'deliver to customer': 'status-primary',
'arrived at hub': 'status-success',
'departed hub': 'status-warning',
'pending': 'status-warning',
'delivered': 'status-success',
};
return map[status] || 'status-default';
}
}