asset_browser/public/modules/assetCard.js
2026-03-27 09:18:39 -04:00

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function formatDate(dateStr) {
if (!dateStr) return '—';
try {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric'
});
} catch {
return dateStr;
}
}