initialize

This commit is contained in:
2026-01-28 14:47:47 +03:30
commit 6216ff39b4
4 changed files with 920 additions and 0 deletions
+396
View File
@@ -0,0 +1,396 @@
const UNSELECTED_SVG = `<svg width="86" height="36" viewBox="0 0 86 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-outside-1_650_3844" maskUnits="userSpaceOnUse" x="0" y="0" width="86" height="36" fill="black">
<rect fill="white" width="86" height="36"/>
<path d="M67 3C68.1045 3 68.9999 3.89551 69 5V12H75.5869C76.1668 12.0001 76.7178 12.2523 77.0977 12.6904L82.5117 18.9365C82.8267 19.3001 83 19.7651 83 20.2461V28C83 29.1046 82.1046 30 81 30H74.9639C74.7212 31.6961 73.2632 33 71.5 33C69.7368 33 68.2788 31.6961 68.0361 30H17.9639C17.7212 31.6961 16.2632 33 14.5 33C12.7368 33 11.2788 31.6961 11.0361 30H5C3.89543 30 3 29.1046 3 28V5C3 3.89543 3.89543 3 5 3H67Z"/>
</mask>
<path d="M67 3C68.1045 3 68.9999 3.89551 69 5V12H75.5869C76.1668 12.0001 76.7178 12.2523 77.0977 12.6904L82.5117 18.9365C82.8267 19.3001 83 19.7651 83 20.2461V28C83 29.1046 82.1046 30 81 30H74.9639C74.7212 31.6961 73.2632 33 71.5 33C69.7368 33 68.2788 31.6961 68.0361 30H17.9639C17.7212 31.6961 16.2632 33 14.5 33C12.7368 33 11.2788 31.6961 11.0361 30H5C3.89543 30 3 29.1046 3 28V5C3 3.89543 3.89543 3 5 3H67Z" fill="#C9E3FF"/>
<path d="M67 3L67 0H67V3ZM69 5H72V4.99974L69 5ZM69 12H66V15H69V12ZM75.5869 12L75.5872 9H75.5869V12ZM77.0977 12.6904L79.3646 10.7255L79.3645 10.7254L77.0977 12.6904ZM82.5117 18.9365L84.7791 16.9721L84.7786 16.9716L82.5117 18.9365ZM83 20.2461H86V20.2459L83 20.2461ZM74.9639 30V27H72.3625L71.9941 29.5751L74.9639 30ZM68.0361 30L71.0059 29.5751L70.6375 27H68.0361V30ZM17.9639 30V27H15.3625L14.9941 29.5751L17.9639 30ZM11.0361 30L14.0059 29.5751L13.6375 27H11.0361V30ZM5 30L4.99999 33H5V30ZM5 3V6V3ZM67 3L67 6C66.4474 6 66 5.55217 66 5.00026L69 5L72 4.99974C71.9998 2.23885 69.7616 7.86781e-06 67 0L67 3ZM69 5H66V12H69H72V5H69ZM69 12V15H75.5869V12V9H69V12ZM75.5869 12L75.5867 15C75.2946 15 75.0193 14.8729 74.8308 14.6555L77.0977 12.6904L79.3645 10.7254C78.4163 9.63156 77.0391 9.00013 75.5872 9L75.5869 12ZM77.0977 12.6904L74.8307 14.6554L80.2448 20.9015L82.5117 18.9365L84.7786 16.9716L79.3646 10.7255L77.0977 12.6904ZM82.5117 18.9365L80.2443 20.901C80.0864 20.7186 80 20.4861 80 20.2463L83 20.2461L86 20.2459C85.9999 19.044 85.567 17.8815 84.7791 16.9721L82.5117 18.9365ZM83 20.2461H80V28H83H86V20.2461H83ZM83 28H80C80 27.4477 80.4477 27 81 27V30V33C83.7614 33 86 30.7614 86 28H83ZM81 30V27H74.9639V30V33H81V30ZM74.9639 30L71.9941 29.5751C71.9597 29.8155 71.7485 30 71.5 30V33V36C74.7779 36 77.4827 33.5767 77.9336 30.4249L74.9639 30ZM71.5 33V30C71.2515 30 71.0403 29.8155 71.0059 29.5751L68.0361 30L65.0664 30.4249C65.5173 33.5767 68.2221 36 71.5 36V33ZM68.0361 30V27H17.9639V30V33H68.0361V30ZM17.9639 30L14.9941 29.5751C14.9597 29.8155 14.7485 30 14.5 30V33V36C17.7779 36 20.4827 33.5767 20.9336 30.4249L17.9639 30ZM14.5 33V30C14.2515 30 14.0403 29.8155 14.0059 29.5751L11.0361 30L8.06637 30.4249C8.5173 33.5767 11.2221 36 14.5 36V33ZM11.0361 30V27H5V30V33H11.0361V30ZM5 30L5.00001 27C5.55228 27 6 27.4477 6 28H3H0C0 30.7614 2.23858 33 4.99999 33L5 30ZM3 28H6V5H3H0V28H3ZM3 5H6C6 5.55229 5.55228 6 5 6V3V0C2.23858 0 0 2.23858 0 5H3ZM5 3V6H67V3V0H5V3Z" fill="#96C8FF" mask="url(#path-1-outside-1_650_3844)"/>
</svg>`;
const SELECTED_SVG = `<svg width="86" height="36" viewBox="0 0 86 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-outside-1_650_3579" maskUnits="userSpaceOnUse" x="0" y="0" width="86" height="36" fill="black">
<rect fill="white" width="86" height="36"/>
<path d="M67 3C68.1045 3 68.9999 3.89551 69 5V12H75.5869C76.1668 12.0001 76.7178 12.2523 77.0977 12.6904L82.5117 18.9365C82.8267 19.3001 83 19.7651 83 20.2461V28C83 29.1046 82.1046 30 81 30H74.9639C74.7212 31.6961 73.2632 33 71.5 33C69.7368 33 68.2788 31.6961 68.0361 30H17.9639C17.7212 31.6961 16.2632 33 14.5 33C12.7368 33 11.2788 31.6961 11.0361 30H5C3.89543 30 3 29.1046 3 28V5C3 3.89543 3.89543 3 5 3H67Z"/>
</mask>
<path d="M67 3C68.1045 3 68.9999 3.89551 69 5V12H75.5869C76.1668 12.0001 76.7178 12.2523 77.0977 12.6904L82.5117 18.9365C82.8267 19.3001 83 19.7651 83 20.2461V28C83 29.1046 82.1046 30 81 30H74.9639C74.7212 31.6961 73.2632 33 71.5 33C69.7368 33 68.2788 31.6961 68.0361 30H17.9639C17.7212 31.6961 16.2632 33 14.5 33C12.7368 33 11.2788 31.6961 11.0361 30H5C3.89543 30 3 29.1046 3 28V5C3 3.89543 3.89543 3 5 3H67Z" fill="#007AFF"/>
<path d="M67 3L67 0H67V3ZM69 5H72V4.99974L69 5ZM69 12H66V15H69V12ZM75.5869 12L75.5872 9H75.5869V12ZM77.0977 12.6904L79.3646 10.7255L79.3645 10.7254L77.0977 12.6904ZM82.5117 18.9365L84.7791 16.9721L84.7786 16.9716L82.5117 18.9365ZM83 20.2461H86V20.2459L83 20.2461ZM74.9639 30V27H72.3625L71.9941 29.5751L74.9639 30ZM68.0361 30L71.0059 29.5751L70.6375 27H68.0361V30ZM17.9639 30V27H15.3625L14.9941 29.5751L17.9639 30ZM11.0361 30L14.0059 29.5751L13.6375 27H11.0361V30ZM5 30L4.99999 33H5V30ZM5 3V6V3ZM67 3L67 6C66.4474 6 66 5.55217 66 5.00026L69 5L72 4.99974C71.9998 2.23885 69.7616 7.86781e-06 67 0L67 3ZM69 5H66V12H69H72V5H69ZM69 12V15H75.5869V12V9H69V12ZM75.5869 12L75.5867 15C75.2946 15 75.0193 14.8729 74.8308 14.6555L77.0977 12.6904L79.3645 10.7254C78.4163 9.63156 77.0391 9.00013 75.5872 9L75.5869 12ZM77.0977 12.6904L74.8307 14.6554L80.2448 20.9015L82.5117 18.9365L84.7786 16.9716L79.3646 10.7255L77.0977 12.6904ZM82.5117 18.9365L80.2443 20.901C80.0864 20.7186 80 20.4861 80 20.2463L83 20.2461L86 20.2459C85.9999 19.044 85.567 17.8815 84.7791 16.9721L82.5117 18.9365ZM83 20.2461H80V28H83H86V20.2461H83ZM83 28H80C80 27.4477 80.4477 27 81 27V30V33C83.7614 33 86 30.7614 86 28H83ZM81 30V27H74.9639V30V33H81V30ZM74.9639 30L71.9941 29.5751C71.9597 29.8155 71.7485 30 71.5 30V33V36C74.7779 36 77.4827 33.5767 77.9336 30.4249L74.9639 30ZM71.5 33V30C71.2515 30 71.0403 29.8155 71.0059 29.5751L68.0361 30L65.0664 30.4249C65.5173 33.5767 68.2221 36 71.5 36V33ZM68.0361 30V27H17.9639V30V33H68.0361V30ZM17.9639 30L14.9941 29.5751C14.9597 29.8155 14.7485 30 14.5 30V33V36C17.7779 36 20.4827 33.5767 20.9336 30.4249L17.9639 30ZM14.5 33V30C14.2515 30 14.0403 29.8155 14.0059 29.5751L11.0361 30L8.06637 30.4249C8.5173 33.5767 11.2221 36 14.5 36V33ZM11.0361 30V27H5V30V33H11.0361V30ZM5 30L5.00001 27C5.55228 27 6 27.4477 6 28H3H0C0 30.7614 2.23858 33 4.99999 33L5 30ZM3 28H6V5H3H0V28H3ZM3 5H6C6 5.55229 5.55228 6 5 6V3V0C2.23858 0 0 2.23858 0 5H3ZM5 3V6H67V3V0H5V3Z" fill="#96C8FF" mask="url(#path-1-outside-1_650_3579)"/>
</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 = `
<div class="tt-widget">
<div class="tt-search">
<input type="text" placeholder="Enter container / booking number" />
<button>Search</button>
</div>
<!-- HIDDEN UNTIL SEARCH -->
<div class="tt-layout" id="tt-layout" style="display:none;">
<div class="tt-master-card" id="tt-master"></div>
<div class="tt-timeline-area">
<div class="tt-container-switcher" id="tt-switcher"></div>
<div class="tt-timeline" id="tt-timeline"></div>
</div>
</div>
</div>
<div class="tt-signature-modal" id="tt-signature-modal">
<div class="tt-signature-backdrop" data-role="backdrop"></div>
<div class="tt-signature-card">
<header class="tt-signature-header">
<div class="tt-signature-title">Digital Signature</div>
<button class="tt-modal-close" aria-label="Close signature preview">&times;</button>
</header>
<div class="tt-signature-body">
<img class="tt-signature-img" alt="Digital signature preview" />
<div class="tt-signature-meta" data-role="meta"></div>
</div>
</div>
</div>
`;
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 = `
<div class="tt-master-title-row">
<div class="tt-master-title">Tracking Summary</div>
<button class="tt-event-count-btn">${totalEvents} Events</button>
</div>
${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 `
<div class="tt-master-row">
<div class="tt-master-row-label">
<span class="tt-master-icon">${this.iconForLabel(label)}</span>
<span>${label}</span>
</div>
<strong>${value || '-'}</strong>
</div>
`;
}
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 = `
<div class="tt-truck-svg">
${svg}
<span class="tt-truck-label">${c.ContainerId}</span>
</div>
`;
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
? `<button class="tt-event-action" ${saleAttr} data-event-title="${this.escapeHtml(trimmedStatus)}" aria-label="View digital signature">👁️</button>`
: '';
return `
<div class="tt-timeline-row ${colorClass}">
<div class="tt-timeline-date">
<div class="tt-timeline-day">${dateParts.day}</div>
<div class="tt-timeline-month">${dateParts.month}</div>
<div class="tt-timeline-year">${dateParts.year}</div>
</div>
<div class="tt-timeline-line">
<span></span>
</div>
<div class="tt-timeline-card">
<div class="tt-timeline-card-header">
<div class="tt-status-dot ${colorClass}"></div>
<div class="tt-card-text">
<div class="tt-event-title">${d.Status || '—'}</div>
<div class="tt-event-location">${d.Location || ''}</div>
</div>
<span class="tt-event-time ${colorClass}">${d.Time || ''}</span>
${actionButton}
</div>
<div class="tt-event-meta">${dateParts.fullDate}${d.Time ? ' • ' + d.Time : ''}</div>
</div>
</div>
`;
}).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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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';
}
}