391 lines
20 KiB
JavaScript
Executable file
391 lines
20 KiB
JavaScript
Executable file
// assetCard.js — renders the asset card into #asset-card-container
|
|
import CONFIG from '../config.js';
|
|
import { normalizeUsername, usernameFuzzyMatch, usernameFirstNameMatch, usernameNameInitialMatch } from './usernameUtils.js';
|
|
import { getCustomerContactNames } from './assetBrowser.js';
|
|
|
|
const { subdomain, baseUrl } = CONFIG.syncro;
|
|
|
|
// ── Public ───────────────────────────────────────────────────────────────────
|
|
|
|
export function renderAssetCard(asset) {
|
|
const container = document.getElementById('asset-card-container');
|
|
container.innerHTML = buildCardHTML(asset);
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function prop(asset, key) {
|
|
return asset?.properties?.[key] ?? null;
|
|
}
|
|
|
|
function buildCardHTML(a) {
|
|
const possessionStatus = prop(a, 'Possession Status');
|
|
const lifecycleStage = prop(a, 'Lifecycle Stage');
|
|
const lastScanDate = prop(a, 'Last Scan Date');
|
|
const lastAction = prop(a, 'Last Action');
|
|
const assetHistory = prop(a, 'Asset History');
|
|
|
|
const infraTag = prop(a, 'Tag');
|
|
const infraLocation = prop(a, 'Location');
|
|
const isInfra = prop(a, 'Infrastructure') === 'Yes';
|
|
const contactName = a.contact_fullname ?? a.contact?.name ?? null;
|
|
const rawLastUser = a.properties?.kabuto_information?.last_user ?? '';
|
|
const lastUser = normalizeUsername(rawLastUser);
|
|
const allContactNames = getCustomerContactNames(a.customer_id);
|
|
const sameUser = !!(lastUser && contactName && (
|
|
usernameFuzzyMatch(rawLastUser, contactName) ||
|
|
usernameFirstNameMatch(rawLastUser, contactName, allContactNames) ||
|
|
usernameNameInitialMatch(rawLastUser, contactName, allContactNames)
|
|
));
|
|
const contactEmail = a.contact?.email ?? null;
|
|
const customerName = a.customer?.business_name ?? a.customer?.business_then_name ?? a.customer?.name ?? '—';
|
|
const serialNumber = a.asset_serial ?? a.serial ?? a.serial_number ?? '—';
|
|
const assetType = a.properties?.form_factor
|
|
?? a.properties?.kabuto_information?.general?.form_factor
|
|
?? a.asset_type ?? 'Device';
|
|
|
|
const syncroUrl = `${baseUrl}/customer_assets/${a.id}`;
|
|
|
|
return `
|
|
<div class="asset-card" data-asset-id="${a.id}" data-customer-id="${a.customer_id ?? ''}">
|
|
|
|
<!-- Header -->
|
|
<div class="asset-card-header">
|
|
<div class="asset-card-title-block">
|
|
<div class="asset-name">${esc(a.name)}</div>
|
|
<div class="asset-type-row">
|
|
<span class="asset-type-badge">${esc(assetType)}</span>
|
|
<span class="asset-customer">${esc(customerName)}</span>
|
|
</div>
|
|
</div>
|
|
<div class="asset-header-id">
|
|
<div class="asset-id-label">Asset ID</div>
|
|
<div class="asset-id-value">#${a.id}</div>
|
|
<a href="${syncroUrl}" target="_blank" class="open-syncro-link" data-no-refocus>
|
|
${iconExternal()} Open in Syncro
|
|
</a>
|
|
</div>
|
|
<button type="button" class="asset-card-close" id="btn-close-asset" aria-label="Close" data-no-refocus>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Body -->
|
|
<div class="asset-card-body">
|
|
|
|
<!-- Status Badges -->
|
|
<div class="status-section">
|
|
<div class="status-group">
|
|
<div class="status-label">Possession</div>
|
|
${possessionBadge(possessionStatus)}
|
|
</div>
|
|
<div class="status-group">
|
|
<div class="status-label">Lifecycle</div>
|
|
${lifecycleBadge(lifecycleStage)}
|
|
</div>
|
|
${(!isInfra && lastUser) ? `
|
|
<div class="status-group">
|
|
<div class="status-label">Last Login</div>
|
|
<div class="status-last-seen">${esc(sameUser ? contactName : lastUser)}</div>
|
|
</div>` : ''}
|
|
<div class="status-group">
|
|
<div class="status-label">Last Sync</div>
|
|
<div class="status-last-seen">${a.properties?.kabuto_information?.last_synced_at ? formatDate(a.properties.kabuto_information.last_synced_at) : '<span style="color:var(--gray-400);font-style:italic">Never</span>'}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info Grid -->
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<div class="info-item-label">Serial Number</div>
|
|
<div class="info-item-value">${esc(serialNumber)}</div>
|
|
</div>
|
|
${infraLocation ? `
|
|
<div class="info-item">
|
|
<div class="info-item-label">Location</div>
|
|
<div class="info-item-value">${esc(infraLocation)}</div>
|
|
</div>` : ''}
|
|
${!isInfra ? `
|
|
<div class="info-item">
|
|
<div class="info-item-label">Assigned User</div>
|
|
<div class="info-item-value ${contactName ? '' : 'none'}">${
|
|
contactName
|
|
? `${esc(contactName)}${contactEmail ? `<br><small style="color:var(--gray-400);font-size:.78rem">${esc(contactEmail)}</small>` : ''}`
|
|
: 'Unassigned'
|
|
}</div>
|
|
</div>` : ''}
|
|
<div class="info-item">
|
|
<div class="info-item-label">Asset Type</div>
|
|
<div class="info-item-value">${esc(assetType)}</div>
|
|
</div>
|
|
<div class="info-item">
|
|
<div class="info-item-label">Customer</div>
|
|
<div class="info-item-value">${esc(customerName)}</div>
|
|
</div>
|
|
${infraTag ? `
|
|
<div class="info-item info-item-full">
|
|
<div class="info-item-label">Tags</div>
|
|
<div class="info-item-value">
|
|
<div class="asset-tags">${infraTag.split(',').map(t => t.trim()).filter(Boolean).map(t => `<span class="asset-tag-pill">${esc(t)}</span>`).join('')}</div>
|
|
</div>
|
|
</div>` : ''}
|
|
</div>
|
|
|
|
<!-- Metadata row -->
|
|
<div class="meta-section">
|
|
<div class="meta-item">
|
|
<div class="meta-item-label">Last Scan</div>
|
|
<div class="meta-item-value">${lastScanDate ? formatDate(lastScanDate) : '<span style="color:var(--gray-400);font-style:italic">Not set</span>'}</div>
|
|
</div>
|
|
<div class="meta-item">
|
|
<div class="meta-item-label">Last Action</div>
|
|
<div class="meta-item-value">${lastAction ? esc(lastAction) : '<span style="color:var(--gray-400);font-style:italic">None recorded</span>'}</div>
|
|
</div>
|
|
${a.warranty_expires_at ? `
|
|
<div class="meta-item">
|
|
<div class="meta-item-label">Warranty Expires</div>
|
|
<div class="meta-item-value">${formatDate(a.warranty_expires_at)}</div>
|
|
</div>` : ''}
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="action-section">
|
|
<div class="action-section-title">Actions</div>
|
|
<div class="action-buttons">
|
|
<button class="action-btn" id="action-toggle-possession" data-action="toggle-possession">
|
|
${iconSwap()} Toggle Possession
|
|
</button>
|
|
<div class="lc-wrap">
|
|
<button class="action-btn" id="action-lifecycle" data-action="lifecycle">
|
|
${iconStages()}
|
|
<span class="lc-btn-label">${lifecycleStage ? esc(lifecycleStage) : 'Lifecycle Stage'}</span>
|
|
<svg class="lc-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
|
</button>
|
|
<div class="lifecycle-dropdown" id="lifecycle-dropdown">
|
|
${['Pre-Deployment','Inventory','Active','For Repair','For Upgrade','For Parts','Decommissioned','Disposed of'].map(stage => {
|
|
const dotCls = 'lc-dot-' + stage.toLowerCase().replace(/\s+/g,'-').replace(/[^a-z0-9-]/g,'');
|
|
const isCurrent = lifecycleStage === stage;
|
|
return `<button class="lifecycle-option${isCurrent ? ' current' : ''}" data-stage="${stage}">
|
|
<span class="lc-dot ${dotCls}"></span>
|
|
<span>${esc(stage)}</span>
|
|
${isCurrent ? `<svg class="lc-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>` : ''}
|
|
</button>`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
<button class="action-btn" id="action-change-owner" data-action="change-owner">
|
|
${iconPerson()} Change User
|
|
</button>
|
|
${contactName ? `<button class="action-btn action-btn-remove" id="action-remove-user" data-action="remove-user">
|
|
${iconUserRemove()} Remove User
|
|
</button>` : ''}
|
|
<button class="action-btn accent" id="action-sign-out" data-action="sign-out">
|
|
${iconSignOut()} Sign Out Device
|
|
</button>
|
|
<button class="action-btn" id="action-print-label" data-action="print-label">
|
|
${iconPrint()} Quick Print
|
|
</button>
|
|
<button class="action-btn lc-add-btn" id="action-add-to-queue" data-action="add-to-queue">
|
|
${iconQueuePlus()} Add to Sheet
|
|
</button>
|
|
<button class="action-btn" id="action-infrastructure" data-action="infrastructure">
|
|
${iconServer()} ${isInfra ? 'Manage Infrastructure' : 'Mark as Infrastructure'}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Contact panel — used for Change Owner & Sign Out -->
|
|
<div class="contact-panel" id="contact-panel">
|
|
<div class="contact-panel-title" id="contact-panel-title">Select Contact</div>
|
|
<input type="text" class="contact-search" id="contact-search" placeholder="Filter by name..." autocomplete="off" data-no-refocus>
|
|
<div class="contact-list" id="contact-list">
|
|
<div class="contact-loading">${iconSpinner()} Loading contacts…</div>
|
|
</div>
|
|
<div style="margin-top:10px;display:flex;gap:8px">
|
|
<button class="btn btn-ghost" id="contact-cancel" style="font-size:.8rem;padding:6px 12px" data-no-refocus>Cancel</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Infrastructure panel -->
|
|
<div class="contact-panel" id="infra-panel">
|
|
<div class="contact-panel-title" id="infra-panel-title">${isInfra ? 'Manage Infrastructure' : 'Set Infrastructure'}</div>
|
|
<input type="text" class="contact-search" id="infra-tag-input" placeholder="Tags — comma separated (e.g. Server, UPS, RDP)" autocomplete="off" data-no-refocus>
|
|
<input type="text" class="contact-search" id="infra-location-input" placeholder="Location (e.g. Server Room, IT Closet…) — optional" autocomplete="off" data-no-refocus style="margin-top:6px">
|
|
<div style="margin-top:8px;display:flex;gap:8px">
|
|
<button class="btn btn-primary" id="infra-confirm-btn" style="font-size:.8rem;padding:6px 12px" data-no-refocus>Confirm</button>
|
|
<button class="btn btn-ghost" id="infra-cancel-btn" style="font-size:.8rem;padding:6px 12px" data-no-refocus>Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ticket History -->
|
|
<div class="ticket-section">
|
|
<button class="ticket-toggle" id="ticket-toggle">
|
|
<span>Recent Tickets</span>
|
|
<svg class="chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<path d="M6 9l6 6 6-6"/>
|
|
</svg>
|
|
</button>
|
|
<div class="ticket-list" id="ticket-list">
|
|
<div class="contact-loading">${iconSpinner()} Loading tickets…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Asset History -->
|
|
<div class="ticket-section history-section">
|
|
<button class="ticket-toggle" id="history-toggle">
|
|
<span>Asset History</span>
|
|
<svg class="chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<path d="M6 9l6 6 6-6"/>
|
|
</svg>
|
|
</button>
|
|
<div class="ticket-list history-list" id="history-list">
|
|
${buildHistoryHTML(assetHistory)}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ── Badge builders ────────────────────────────────────────────────────────────
|
|
|
|
function possessionBadge(status) {
|
|
const cls = !status ? 'badge-unknown'
|
|
: status === 'In IT Possession' ? 'badge-it-possession'
|
|
: (status === 'Deployed' || status === 'In User Possession') ? 'badge-user-possession'
|
|
: 'badge-unknown';
|
|
const labelInner = !status ? 'Unknown'
|
|
: status === 'In IT Possession' ? `${iconCheck()} In IT Possession`
|
|
: (status === 'Deployed' || status === 'In User Possession') ? `${iconUser()} Deployed`
|
|
: esc(status);
|
|
const chevron = `<svg class="status-badge-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>`;
|
|
const options = ['In IT Possession', 'Deployed'].map(opt => {
|
|
const isCurrent = status === opt || (opt === 'Deployed' && status === 'In User Possession');
|
|
return `<button class="status-dropdown-option${isCurrent ? ' current' : ''}" data-possession="${opt}">${opt}</button>`;
|
|
}).join('');
|
|
return `
|
|
<div class="status-badge-wrap">
|
|
<button class="badge ${cls} status-badge-btn" id="status-possession-btn" data-no-refocus>
|
|
${labelInner}${chevron}
|
|
</button>
|
|
<div class="status-dropdown" id="status-possession-dropdown">${options}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function lifecycleBadge(stage) {
|
|
const map = {
|
|
'Pre-Deployment':'badge-pre-deployment',
|
|
'Inventory': 'badge-inventory',
|
|
'Active': 'badge-active',
|
|
'For Repair': 'badge-for-repair',
|
|
'For Upgrade': 'badge-for-upgrade',
|
|
'Decommissioned':'badge-decommissioned',
|
|
'For Parts': 'badge-for-parts',
|
|
'Disposed of': 'badge-disposed-of',
|
|
};
|
|
const cls = !stage ? 'badge-unknown' : (map[stage] ?? 'badge-unknown');
|
|
const chevron = `<svg class="status-badge-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>`;
|
|
const stages = ['Pre-Deployment','Inventory','Active','For Repair','For Upgrade','For Parts','Decommissioned','Disposed of'];
|
|
const options = stages.map(s => {
|
|
const dotCls = 'lc-dot-' + s.toLowerCase().replace(/\s+/g,'-').replace(/[^a-z0-9-]/g,'');
|
|
const isCurrent = stage === s;
|
|
return `<button class="status-dropdown-option${isCurrent ? ' current' : ''}" data-stage="${s}">
|
|
<span class="lc-dot ${dotCls}"></span><span>${esc(s)}</span>
|
|
${isCurrent ? `<svg class="lc-check" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>` : ''}
|
|
</button>`;
|
|
}).join('');
|
|
return `
|
|
<div class="status-badge-wrap">
|
|
<button class="badge ${cls} status-badge-btn" id="status-lifecycle-btn" data-no-refocus>
|
|
${stage ? esc(stage) : 'Not Set'}${chevron}
|
|
</button>
|
|
<div class="status-dropdown" id="status-lifecycle-dropdown">${options}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function buildHistoryHTML(rawValue) {
|
|
if (!rawValue || !rawValue.trim()) {
|
|
return `<div class="contact-empty" style="font-style:italic">No history recorded yet.</div>`;
|
|
}
|
|
const entries = rawValue.trim().split('\n').filter(Boolean);
|
|
return entries.map(line => {
|
|
// Expected format: [YYYY-MM-DD HH:MM] — description
|
|
const match = line.match(/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\] — (.+)$/);
|
|
if (match) {
|
|
return `<div class="history-item">
|
|
<div class="history-dot"></div>
|
|
<div>
|
|
<div class="history-desc">${esc(match[2])}</div>
|
|
<div class="history-time">${esc(match[1])}</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
return `<div class="history-item">
|
|
<div class="history-dot"></div>
|
|
<div class="history-desc">${esc(line)}</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
// ── Tiny inline icons (SVG) ───────────────────────────────────────────────────
|
|
|
|
function iconExternal() {
|
|
return `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`;
|
|
}
|
|
function iconSwap() {
|
|
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4"/><path d="M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>`;
|
|
}
|
|
function iconStages() {
|
|
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>`;
|
|
}
|
|
function iconPerson() {
|
|
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
|
|
}
|
|
function iconSignOut() {
|
|
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>`;
|
|
}
|
|
function iconPrint() {
|
|
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 01-2-2v-5a2 2 0 012-2h16a2 2 0 012 2v5a2 2 0 01-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>`;
|
|
}
|
|
function iconQueuePlus() {
|
|
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>`;
|
|
}
|
|
function iconCheck() {
|
|
return `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>`;
|
|
}
|
|
function iconUser() {
|
|
return `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
|
|
}
|
|
function iconUserRemove() {
|
|
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="23" y1="11" x2="17" y2="11"/></svg>`;
|
|
}
|
|
function iconServer() {
|
|
return `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1" fill="currentColor"/><circle cx="6" cy="18" r="1" fill="currentColor"/></svg>`;
|
|
}
|
|
function iconSpinner() {
|
|
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="animation:spin .7s linear infinite;display:inline-block"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>`;
|
|
}
|
|
|
|
// ── Utils ─────────────────────────────────────────────────────────────────────
|
|
|
|
function esc(str) {
|
|
if (!str) return '';
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '—';
|
|
try {
|
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
|
month: 'short', day: 'numeric', year: 'numeric'
|
|
});
|
|
} catch {
|
|
return dateStr;
|
|
}
|
|
}
|