VonTrack · Live Demo
Sample data for a facility-management company. Changes stay in this browser only.
Loading…
'); w.document.close(); setTimeout(function () { try { w.focus(); w.print(); } catch (e) {} }, 400); } /* ------------------------------------------------------------ CSV */ function csv(name, rows, cols) { var out = [cols.map(function (c) { return c[1]; }).join(',')].concat(rows.map(function (r) { return cols.map(function (c) { var v = typeof c[0] === 'function' ? c[0](r) : r[c[0]]; return '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"'; }).join(','); })).join('\n'); var a = document.createElement('a'); a.href = URL.createObjectURL(new Blob(['' + out], { type: 'text/csv' })); a.download = name + '-' + today() + '.csv'; a.click(); } /* ------------------------------------------------------------ shell + router */ function navItems() { if (C.mode === 'admin') { return [['dashboard', 'Dashboard'], ['assets', 'Asset Register'], ['assignments', 'Issue & Return'], ['people', 'Employees'], ['reports', 'Reports'], ['scan', 'Scan QR']]; } var n = [['dashboard', 'Home'], ['mine', 'My Items'], ['scan', 'Scan']]; if (CAN.operate) n.push(['assets', 'Assets'], ['assignments', 'Issue / Return']); else n.push(['assets', 'Assets']); return n; } function shell() { var solo = root.getAttribute('data-solo'); var logo = B.logo ? '' : '' + ico('qr') + ''; root.innerHTML = (solo ? '' : '') + '
' + (solo ? '' : '
' + ico('search') + '
' + '
' + ico('scan') + 'Scan' + (CAN.manage ? '' : '') + '
') + '
'; if (!root._vtBound) { root.addEventListener('click', onClick); root._vtBound = true; } var gs = $('.vt-gsearch'); if (gs) gs.addEventListener('submit', function (e) { e.preventDefault(); state.cache.search = $('input', gs).value; go('assets'); }); var mb = $('.vt-menu-btn'); if (mb) mb.addEventListener('click', function () { root.classList.toggle('is-nav-open'); }); } function go(r) { if (location.hash === '#/' + r) route(); else location.hash = '#/' + r; } function route() { var hsh = location.hash.replace(/^#\/?/, ''); var parts = hsh.split('/'); state.route = parts[0] || root.getAttribute('data-route') || 'dashboard'; state.param = parts[1] || null; root.classList.remove('is-nav-open'); $$('.vt-nav a').forEach(function (a) { a.classList.toggle('is-active', a.getAttribute('data-r') === (state.route === 'asset' ? 'assets' : state.route)); }); var v = $('.vt-view'); v.innerHTML = '
'; stopCamera(); var fn = VIEWS[state.route] || VIEWS.dashboard; Promise.resolve(fn(v, state.param)).catch(function (e) { v.innerHTML = '
' + ico('alert') + '

' + h(e.message || e) + '

'; }); window.scrollTo && root.getBoundingClientRect().top < 0 && root.scrollIntoView({ behavior: 'smooth' }); } function onClick(e) { var t = e.target.closest('[data-act]'); if (!t) return; var act = t.getAttribute('data-act'), id = t.getAttribute('data-id'); if (ACTIONS[act]) { e.preventDefault(); ACTIONS[act](id, t); } } function head(title, sub, actions) { return '

' + h(title) + '

' + (sub ? '

' + sub + '

' : '') + '
' + (actions ? '
' + actions + '
' : '') + '
'; } /* ------------------------------------------------------------ charts */ function barChart(rows) { if (!rows.length) return '
No movements yet
'; var max = Math.max.apply(null, rows.map(function (r) { return Math.max(+r.issued, +r.returned); }).concat([1])); return '
' + rows.map(function (r) { var mo = new Date(r.m + '-01T00:00').toLocaleDateString('en-GB', { month: 'short' }); return '
' + h(mo) + '
'; }).join('') + '
IssuedReturned
'; } function donut(parts) { var tot = parts.reduce(function (s, p) { return s + p[1]; }, 0) || 1, acc = 0, R = 42, CIR = 2 * Math.PI * R; var segs = parts.map(function (p) { var len = p[1] / tot * CIR, s = '' + h(p[0]) + ': ' + p[1] + ''; acc += len; return s; }).join(''); return '
' + segs + '' + num(tot === 1 && !parts.some(function (p) { return p[1]; }) ? 0 : tot) + 'single assets
'; } function kpi(label, value, icon, tone, link, sub) { return '' + ico(icon) + '
' + h(label) + '' + h(value) + '' + (sub ? '' + sub + '' : '') + '
'; } function activity(list) { if (!list || !list.length) return '
No activity yet
'; return '
    ' + list.map(function (l) { return '
  1. ' + h(l.asset_name || l.asset_code || 'Asset') + ' ' + h(l.details) + '' + h(ago(l.created_at)) + ' · ' + h(l.user_name) + '
  2. '; }).join('') + '
'; } /* ------------------------------------------------------------ views */ var VIEWS = {}; VIEWS.dashboard = function (v) { if (C.mode !== 'admin') return VIEWS.team(v); return API.get('stats').then(function (s) { var lowP = API.get('assets', { status: 'low' }); v.innerHTML = head('Dashboard', 'Live overview of ' + h(B.name || 'your') + ' assets, stock and field teams.', '') + '
' + kpi('Single assets', num(s.single_total), 'assets', 'blue', 'assets', num(s.assigned) + ' assigned') + kpi('Bulk units tracked', num(s.bulk_units), 'box', 'green', 'assets', num(s.bulk_issued) + ' with staff · ' + num(s.bulk_skus) + ' SKUs') + kpi('Low stock items', num(s.low_stock), 'alert', s.low_stock ? 'amber' : 'green', 'assets/low') + kpi('Overdue returns', num(s.overdue), 'assignments', s.overdue ? 'red' : 'green', 'assignments/overdue') + kpi('Service due (14 days)', num(s.service_due), 'wrench', s.service_due ? 'amber' : 'blue', 'assets/maintenance') + kpi('Asset value', money(s.total_value), 'reports', 'navy', 'reports') + '
' + '
' + '

Issued vs returned

last 6 months · units
' + barChart(s.monthly || []) + '
' + '

Single asset status

' + donut([['Available', s.available, 'var(--vt-accent)'], ['Assigned', s.assigned, 'var(--vt-primary)'], ['Maintenance', s.maintenance, '#E9A23B'], ['Retired / lost', s.retired, '#9AA7B2']]) + '
' + '

Low stock

View all
Loading…
' + '

By category

Reports
' + catTable(s.by_category) + '
' + '

Recent activity

' + activity(s.activity) + '
' + '
'; countUp(v); lowP.then(function (rows) { var box = $('.vt-lowlist', v); if (!box) return; box.innerHTML = rows.length ? '' : '
All stock above minimum 👍
'; }); }); }; function catTable(rows) { if (!rows || !rows.length) return '
No assets yet
'; var max = Math.max.apply(null, rows.map(function (r) { return +r.units || 0; }).concat([1])); return ''; } function countUp(scope) { $$('[data-count]', scope).forEach(function (n) { var raw = n.getAttribute('data-count'), target = parseFloat(raw.replace(/,/g, '')); if (isNaN(target) || /[A-Za-z]/.test(raw) || target === 0) return; var t0 = null; function step(t) { if (!t0) t0 = t; var p = Math.min(1, (t - t0) / 700); n.textContent = num(Math.round(target * (1 - Math.pow(1 - p, 3)))); if (p < 1) requestAnimationFrame(step); } requestAnimationFrame(step); }); } /* Team home */ VIEWS.team = function (v) { return Promise.all([API.get('me'), CAN.operate ? API.get('stats') : Promise.resolve(null)]).then(function (r) { var me = r[0], s = r[1], first = (C.user && C.user.name || '').split(' ')[0]; var hr = new Date().getHours(), greet = hr < 12 ? 'Good morning' : hr < 17 ? 'Good afternoon' : 'Good evening'; v.innerHTML = '
' + greet + '

' + h(first || 'Team') + ' 👋

' + (me.person ? h(me.person.designation || '') + (me.person.site ? ' · ' + h(me.person.site) : '') : 'Your account is not linked to an employee profile yet — ask your asset manager.') + '

' + '' + ico('scan') + 'Scan asset QR
' + (s ? '
' + kpi('Bulk units out', num(s.bulk_issued), 'box', 'green', 'assignments') + kpi('Low stock', num(s.low_stock), 'alert', s.low_stock ? 'amber' : 'green', 'assets/low') + kpi('Overdue', num(s.overdue), 'assignments', s.overdue ? 'red' : 'green', 'assignments/overdue') + kpi('In maintenance', num(s.maintenance), 'wrench', 'blue', 'assets/maintenance') + '
' : '') + '

Items with me

View all
' + myItems(me.items, 5) + '
' + '

Quick actions

' + '' + ico('scan') + 'Scan & verifyCheck any tagged item' + '' + ico('mine') + 'My uniform & kitWhat is issued to me' + (CAN.operate ? '' + ico('box') + 'Record returnClose open issues' : '' + ico('assets') + 'Browse assetsSearch the register') + '
' + (s ? '

Latest activity

' + activity(s.activity.slice(0, 6)) + '
' : ''); countUp(v); }); }; function myItems(items, limit) { if (!items || !items.length) return '
' + ico('box') + ' Nothing issued to you right now.
'; return ''; } VIEWS.mine = function (v) { return API.get('me').then(function (me) { v.innerHTML = head('My Items', me.person ? h(me.person.name) + ' · ' + h(me.person.emp_code) + ' · ' + h(me.person.department) : '') + '
' + myItems(me.items) + '
' + '

Something missing or damaged? Open the item and tap Report a problem.

'; }); }; /* Asset register */ VIEWS.assets = function (v, preset) { var f = { type: '', status: '', category: '', search: state.cache.search || '' }; state.cache.search = ''; if (preset === 'low') f.status = 'low'; else if (preset === 'maintenance') f.status = 'maintenance'; else if (preset === 'bulk' || preset === 'single') f.type = preset; var data = [], sel = {}; v.innerHTML = head('Asset Register', 'Single (serialised) assets and bulk stock such as uniforms, tool kits and PPE.', '' + (CAN.manage ? '' : '')) + '
' + '
' + [['', 'All'], ['single', 'Single'], ['bulk', 'Bulk stock']].map(function (t) { return ''; }).join('') + '
' + '' + '' + '' + '
AssetTypeStock / QtyLocationStatus
'; var tb = $('tbody', v), foot = $('.vt-table-foot', v); function load() { tb.innerHTML = '
'; return API.get('assets', f).then(function (rows) { data = rows; sel = {}; render(); }); } function render() { state.cache.assets = data; if (!data.length) { tb.innerHTML = '
' + ico('assets') + '

No assets match these filters.

' + (CAN.manage ? '' : '') + '
'; foot.textContent = ''; return; } tb.innerHTML = data.map(function (a) { return '' + '' + (a.image_url ? '' : ico(a.type === 'bulk' ? 'box' : 'assets')) + '' + h(a.name) + '' + h(a.code) + ' · ' + h(a.category) + (a.size_variant ? ' · ' + h(a.size_variant) : '') + '' + '' + (a.type === 'bulk' ? 'Bulk' : 'Single') + '' + '' + stockBar(a) + '' + h(a.location) + '' + pill(a.status) + '' + '' + (CAN.operate && a.status !== 'retired' && a.status !== 'lost' && +a.qty_available > 0 ? '' : '') + ''; }).join(''); foot.textContent = data.length + ' assets · ' + data.filter(function (a) { return a.type === 'bulk'; }).reduce(function (s, a) { return s + (+a.qty_total); }, 0).toLocaleString() + ' bulk units'; } var tmr; $('.vt-f-search', v).addEventListener('input', function (e) { clearTimeout(tmr); tmr = setTimeout(function () { f.search = e.target.value; load(); }, 300); }); $('.vt-f-cat', v).addEventListener('change', function (e) { f.category = e.target.value; load(); }); $('.vt-f-status', v).addEventListener('change', function (e) { f.status = e.target.value; load(); }); $$('.vt-tabs button', v).forEach(function (b) { b.addEventListener('click', function () { $$('.vt-tabs button', v).forEach(function (x) { x.classList.remove('is-on'); }); b.classList.add('is-on'); f.type = b.getAttribute('data-type'); load(); }); }); $('.vt-all', v).addEventListener('change', function (e) { $$('tbody input[type=checkbox]', v).forEach(function (c) { c.checked = e.target.checked; }); }); state.cache.selected = function () { var ids = $$('tbody input:checked', v).map(function (c) { return c.value; }); return data.filter(function (a) { return !ids.length || ids.indexOf(String(a.id)) > -1; }); }; return load(); }; /* Asset detail */ VIEWS.asset = function (v, id) { return API.get('assets/' + id).then(function (a) { state.cache.asset = a; var open = a.assignments.filter(function (x) { return x.status !== 'returned'; }); var issued = a.type === 'bulk' ? (a.qty_total - a.qty_available) : (a.status === 'assigned' ? 1 : 0); var canIssue = CAN.operate && +a.qty_available > 0 && ['retired', 'lost', 'maintenance'].indexOf(a.status) < 0; var info = [['Category', a.category], ['Brand / model', (a.brand + ' ' + a.model).trim()], ['Serial no.', a.serial_no], ['Size / variant', a.size_variant], ['Location', a.location], ['Condition', a.item_condition], ['Purchased', a.purchase_date ? date(a.purchase_date) : ''], ['Cost', +a.purchase_cost ? money(a.purchase_cost) + (a.type === 'bulk' ? ' / ' + h(a.unit).replace(/s$/, '') : '') : ''], ['Vendor', a.vendor], ['Warranty until', a.warranty_until ? date(a.warranty_until) : ''], ['Next service', a.next_service ? date(a.next_service) : '']] .filter(function (r) { return r[1]; }); v.innerHTML = '← Asset Register' + '
' + '
' + (a.image_url ? '' : ico(a.type === 'bulk' ? 'box' : 'assets')) + '
' + '
' + pill(a.status) + '' + (a.type === 'bulk' ? 'Bulk stock' : 'Single asset') + '
' + '

' + h(a.name) + '

' + h(a.code) + '

' + (a.type === 'bulk' ? '
' + num(a.qty_available) + 'in store
' + num(issued) + 'with staff
' + num(a.qty_total) + 'total ' + h(a.unit) + '
' + num(a.min_stock) + 'min. level
' + stockBar(a) : '') + '
' + (canIssue ? '' : '') + (CAN.operate && a.type === 'bulk' ? '' : '') + (CAN.operate ? '' : '') + '' + (CAN.manage ? '' : '') + '
' + '

Currently with (' + open.length + ')

' + (open.length ? '
' + open.map(function (x) { var over = x.due_at && x.due_at < today(); return '' + date(x.due_at) + (over ? ' · overdue' : '') + ''; }).join('') + '
Employee / teamQtyIssuedDue
' + h(x.person_name) + '' + h(x.emp_code) + ' · ' + h(x.department) + '' + num(x.qty - x.returned_qty) + (x.returned_qty > 0 ? ' of ' + x.qty + '' : '') + '' + date(x.issued_at) + '' + (CAN.operate ? '' : '') + '
' : '
Not issued to anyone.
') + '
' + '

History

' + a.history.length + ' events
    ' + a.history.map(function (l) { return '
  1. ' + h(l.action.replace(/_/g, ' ')) + '' + (+l.qty ? ' ×' + l.qty + '' : '') + ' ' + h(l.details) + '' + h(date(l.created_at)) + ' · ' + h(String(l.created_at).slice(11, 16)) + ' · ' + h(l.user_name) + '
  2. '; }).join('') + '
' + '
'; }); }; /* Issue & return */ VIEWS.assignments = function (v, preset) { var tab = preset === 'overdue' ? 'overdue' : preset === 'all' ? 'all' : 'open'; v.innerHTML = head('Issue & Return', 'Track uniforms, tool kits and equipment issued to employees and teams.', '' + (CAN.operate ? '' : '')) + '
' + [['open', 'With staff'], ['overdue', 'Overdue'], ['all', 'All records']].map(function (t) { return ''; }).join('') + '
' + '
Employee / teamAssetQtyIssuedDueStatus
'; var tb = $('tbody', v), rows = []; function load() { tb.innerHTML = '
'; return API.get('assignments', { open: tab === 'open' ? 1 : '', overdue: tab === 'overdue' ? 1 : '' }).then(function (r) { rows = r; state.cache.assign = r; render(''); }); } function render(q) { q = q.toLowerCase(); var list = rows.filter(function (x) { return !q || (x.person_name + ' ' + x.asset_name + ' ' + x.asset_code + ' ' + x.emp_code).toLowerCase().indexOf(q) > -1; }); tb.innerHTML = list.length ? list.map(function (x) { var over = x.status !== 'returned' && x.due_at && x.due_at < today(); return '' + h(x.person_name) + '' + h(x.emp_code) + ' · ' + h(x.department) + '' + h(x.asset_name) + '' + h(x.asset_code) + '' + num(x.status === 'returned' ? x.qty : x.qty - x.returned_qty) + ' ' + h(x.unit) + '' + date(x.issued_at) + '' + date(x.due_at) + '' + (over ? pill('lost').replace('Lost', 'Overdue') : pill(x.status)) + '' + (CAN.operate && x.status !== 'returned' ? '' : '') + ''; }).join('') : '
' + ico('assignments') + '

No records here.

'; } $$('.vt-tabs button', v).forEach(function (b) { b.addEventListener('click', function () { $$('.vt-tabs button', v).forEach(function (x) { x.classList.remove('is-on'); }); b.classList.add('is-on'); tab = b.getAttribute('data-tab'); load(); }); }); $('.vt-f-search', v).addEventListener('input', function (e) { render(e.target.value); }); return load(); }; /* People */ VIEWS.people = function (v) { return API.get('people').then(function (rows) { state.cache.people = rows; v.innerHTML = head('Employees & Teams', rows.length + ' people, teams and sites that can receive assets.', CAN.manage ? '' : '') + '
'; var box = $('.vt-people', v); function render(q) { q = (q || '').toLowerCase(); var list = rows.filter(function (p) { return !q || (p.name + ' ' + p.emp_code + ' ' + p.department + ' ' + p.site).toLowerCase().indexOf(q) > -1; }); box.innerHTML = list.length ? list.map(function (p) { return ''; }).join('') : '
' + ico('people') + '

No employees yet.

'; } $('.vt-f-search', v).addEventListener('input', function (e) { render(e.target.value); }); render(''); }); }; /* Reports */ VIEWS.reports = function (v) { return Promise.all([API.get('stats'), API.get('assets', { type: 'bulk' }), API.get('assignments', { overdue: 1 })]).then(function (r) { var s = r[0], bulk = r[1], over = r[2]; v.innerHTML = head('Reports', 'Stock levels, valuation and accountability — export any table to Excel (CSV).', '') + '
' + kpi('Total asset value', money(s.total_value), 'reports', 'navy') + kpi('Bulk units', num(s.bulk_units), 'box', 'green', '', num(s.bulk_issued) + ' issued') + kpi('Employees equipped', num(s.people), 'people', 'blue') + kpi('QR scans (30d)', num(s.scans_30d), 'scan', 'green') + '
' + '

Bulk stock levels

available vs total
    ' + bulk.map(function (a) { var p = a.qty_total ? a.qty_available / a.qty_total * 100 : 0, low = +a.qty_available <= +a.min_stock; return '
  • ' + h(a.name) + 'min ' + num(a.min_stock) + ' ' + h(a.unit) + '
    ' + num(a.qty_available) + '/' + num(a.qty_total) + '
  • '; }).join('') + '

Value by category

' + catTable(s.by_category) + '
' + '

Overdue returns (' + over.length + ')

' + (over.length ? '
' + over.map(function (x) { return ''; }).join('') + '
EmployeeAssetQtyDue
' + h(x.person_name) + '' + h(x.asset_name) + '' + num(x.qty - x.returned_qty) + '' + date(x.due_at) + '
' : '
No overdue items 🎉
') + '
'; countUp(v); }); }; /* Scanner */ var camStream = null, camLoop = null; function stopCamera() { if (camLoop) cancelAnimationFrame(camLoop); camLoop = null; if (camStream) camStream.getTracks().forEach(function (t) { t.stop(); }); camStream = null; } VIEWS.scan = function (v) { var hasDetector = 'BarcodeDetector' in window; v.innerHTML = (root.getAttribute('data-solo') ? '' : head('Scan QR', 'Point the camera at any ' + h(B.product || 'VonTrack') + ' label — or type the asset code.')) + '
' + ico('scan') + '

' + (hasDetector ? 'Camera is off' : 'Live scanning needs Chrome on Android. On iPhone, scan the label with the Camera app – it opens the asset page directly.') + '

' + (hasDetector ? '' : '') + '
' + '
' + '
' + ico('qr') + ' Scan result will appear here.
'; $('.vt-manual', v).addEventListener('submit', function (e) { e.preventDefault(); lookup(e.target.code.value); }); }; function lookup(code) { var box = $('.vt-scan-result'); if (!box) return; box.innerHTML = '
'; API.get('lookup', { code: code }).then(function (a) { var open = (a.assignments || []).filter(function (x) { return x.status !== 'returned'; }); box.innerHTML = '
✓ Asset verified
' + pill(a.status) + '' + (a.type === 'bulk' ? 'Bulk' : 'Single') + '

' + h(a.name) + '

' + h(a.code) + ' · ' + h(a.category) + ' · ' + h(a.location) + '

' + (a.type === 'bulk' ? stockBar(a) : '') + (open.length ? '

With: ' + open.slice(0, 4).map(function (x) { return h(x.person_name) + ' (' + (x.qty - x.returned_qty) + ')'; }).join(', ') + (open.length > 4 ? ' +' + (open.length - 4) + ' more' : '') + '

' : '') + '
Open details & history' + (CAN.operate && +a.qty_available > 0 && a.status !== 'maintenance' ? '' : '') + '
'; }).catch(function (e) { box.innerHTML = '
Not found

' + h(e.message) + '

'; }); } /* ------------------------------------------------------------ actions */ var ACTIONS = {}; ACTIONS['cam-start'] = function () { var vid = $('.vt-cam video'); if (!vid) return; navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }).then(function (st) { camStream = st; vid.srcObject = st; vid.play(); $('.vt-cam').classList.add('is-on'); var det = new window.BarcodeDetector({ formats: ['qr_code'] }), busy = false; (function tick() { camLoop = requestAnimationFrame(tick); if (busy || vid.readyState < 2) return; busy = true; det.detect(vid).then(function (c) { busy = false; if (c && c.length) { if (navigator.vibrate) navigator.vibrate(80); stopCamera(); var cam = $('.vt-cam'); if (cam) cam.classList.remove('is-on'); lookup(c[0].rawValue); } }).catch(function () { busy = false; }); })(); }).catch(function () { toast('Camera permission denied', 'err'); }); }; function assetForm(a) { a = a || { type: 'single', qty_total: 1, min_stock: 0, unit: 'pcs', status: 'available', item_condition: 'new' }; var edit = !!a.id; return [ { name: 'type', label: 'Asset type', type: 'select', value: a.type, options: [{ value: 'single', label: 'Single – one serialised item (laptop, machine, vehicle)' }, { value: 'bulk', label: 'Bulk – quantity stock (uniforms, tool kits, PPE)' }], full: true, cls: edit ? 'is-locked' : '' }, { name: 'name', label: 'Asset name', value: a.name, required: true, full: true, placeholder: 'e.g. Staff Uniform – Shirt (Navy)' }, { name: 'code', label: 'Asset code', value: a.code, placeholder: 'Auto-generate', help: 'Leave empty to auto-generate' }, { name: 'category', label: 'Category', type: 'datalist', options: LISTS.categories, value: a.category }, { name: 'brand', label: 'Brand', value: a.brand }, { name: 'model', label: 'Model', value: a.model }, { name: 'serial_no', label: 'Serial / IMEI / chassis', value: a.serial_no, cls: 'only-single' }, { name: 'size_variant', label: 'Size / variant', value: a.size_variant, placeholder: 'S / M / L / XL', cls: 'only-bulk' }, { name: 'qty_total', label: 'Total quantity', type: 'number', min: 0, value: a.qty_total, cls: 'only-bulk' }, { name: 'unit', label: 'Unit', type: 'datalist', options: ['pcs', 'sets', 'kits', 'pairs', 'boxes'], value: a.unit, cls: 'only-bulk' }, { name: 'min_stock', label: 'Minimum stock alert', type: 'number', min: 0, value: a.min_stock, cls: 'only-bulk' }, { name: 'location', label: 'Location / site', type: 'datalist', options: LISTS.locations, value: a.location }, { name: 'status', label: 'Status', type: 'select', value: a.status, options: [{ value: 'available', label: 'Available' }, { value: 'maintenance', label: 'Maintenance' }, { value: 'retired', label: 'Retired' }, { value: 'lost', label: 'Lost' }].concat(a.status === 'assigned' ? [{ value: 'assigned', label: 'Assigned' }] : []) }, { name: 'item_condition', label: 'Condition', type: 'select', value: a.item_condition, options: ['new', 'good', 'fair', 'poor', 'damaged'] }, { name: 'purchase_date', label: 'Purchase date', type: 'date', value: a.purchase_date }, { name: 'purchase_cost', label: 'Unit cost (' + (B.currency || 'PKR') + ')', type: 'number', min: 0, value: a.purchase_cost }, { name: 'vendor', label: 'Vendor', value: a.vendor }, { name: 'warranty_until', label: 'Warranty until', type: 'date', value: a.warranty_until }, { name: 'next_service', label: 'Next service date', type: 'date', value: a.next_service }, { name: 'image_url', label: 'Image URL', type: 'url', value: a.image_url, full: true, placeholder: 'https://…' }, { name: 'notes', label: 'Notes', type: 'textarea', value: a.notes, full: true } ]; } function bindTypeToggle(f) { var t = f.querySelector('[name=type]'); function sync() { f.classList.toggle('is-bulk', t.value === 'bulk'); f.classList.toggle('is-single', t.value !== 'bulk'); } t.addEventListener('change', sync); sync(); } ACTIONS['new-asset'] = function () { var f = form(assetForm(), 'Save asset', function (d) { return API.post('assets', d).then(function (a) { closeModal(); toast('Asset ' + a.code + ' created'); location.hash = '#/asset/' + a.id; }); }); bindTypeToggle(f); modal('Add asset', f, { wide: true }); }; ACTIONS['edit-asset'] = function (id) { var a = state.cache.asset; var f = form(assetForm(a), 'Save changes', function (d) { return API.put('assets/' + id, d).then(function () { closeModal(); toast('Saved'); route(); }); }); bindTypeToggle(f); f.querySelector('[name=type]').disabled = true; var del = el(''); del.addEventListener('click', function () { if (confirm('Delete ' + a.name + ' and its full history?')) API.del('assets/' + id).then(function () { closeModal(); toast('Deleted'); go('assets'); }).catch(fail); }); f.querySelector('.vt-form-actions').prepend(del); modal('Edit ' + a.code, f, { wide: true }); }; function peopleOptions() { return (state.cache.people ? Promise.resolve(state.cache.people) : API.get('people')).then(function (p) { state.cache.people = p; return p.filter(function (x) { return +x.active; }).map(function (x) { return { value: x.id, label: x.name + (x.emp_code ? ' · ' + x.emp_code : '') + (x.department ? ' · ' + x.department : '') }; }); }); } function issueModal(asset) { return peopleOptions().then(function (opts) { var fields = []; if (!asset) fields.push({ name: 'asset_id', label: 'Asset', type: 'select', required: true, placeholder: 'Choose asset…', options: [], full: true }); fields.push({ name: 'person_id', label: 'Issue to', type: 'select', required: true, placeholder: 'Choose employee / team…', options: opts, full: true }); if (!asset || asset.type === 'bulk') fields.push({ name: 'qty', label: 'Quantity', type: 'number', min: 1, max: asset ? asset.qty_available : null, value: 1, help: asset ? asset.qty_available + ' ' + asset.unit + ' available' : '' }); fields.push({ name: 'due_at', label: 'Return due (optional)', type: 'date' }); fields.push({ name: 'notes', label: 'Note', placeholder: 'e.g. Size L, new joiner kit', full: true }); var f = form(fields, 'Issue', function (d) { var aid = asset ? asset.id : d.asset_id; return API.post('assets/' + aid + '/issue', d).then(function (a) { closeModal(); toast('Issued: ' + a.name); route(); }); }); if (!asset) { API.get('assets').then(function (all) { var s = f.querySelector('[name=asset_id]'); all.filter(function (a) { return +a.qty_available > 0 && ['retired', 'lost', 'maintenance'].indexOf(a.status) < 0; }).forEach(function (a) { var o = document.createElement('option'); o.value = a.id; o.textContent = a.name + ' · ' + a.code + (a.type === 'bulk' ? ' (' + a.qty_available + ' ' + a.unit + ' left)' : ''); s.appendChild(o); }); }); } if (!opts.length) toast('Add employees first (Employees page)', 'err'); modal(asset ? 'Issue · ' + asset.name : 'Issue items', f); }); } ACTIONS['new-issue'] = function () { issueModal(null).catch(fail); }; ACTIONS.issue = function (id) { var cached = (state.cache.asset && String(state.cache.asset.id) === String(id)) ? Promise.resolve(state.cache.asset) : API.get('assets/' + id); cached.then(issueModal).catch(fail); }; ACTIONS['return'] = function (id) { var x = (state.cache.asset && state.cache.asset.assignments || []).concat(state.cache.assign || []).filter(function (r) { return String(r.id) === String(id); })[0] || {}; var open = (x.qty || 1) - (x.returned_qty || 0); var fields = []; if ((x.asset_type || (state.cache.asset && state.cache.asset.type)) === 'bulk' || open > 1) fields.push({ name: 'qty', label: 'Quantity returned', type: 'number', min: 1, max: open, value: open, help: open + ' still with ' + (x.person_name || 'holder') }); fields.push({ name: 'condition', label: 'Condition on return', type: 'select', value: 'good', options: [{ value: 'good', label: 'Good – back to store' }, { value: 'fair', label: 'Fair – usable' }, { value: 'damaged', label: 'Damaged – write off / repair' }] }); fields.push({ name: 'notes', label: 'Note', full: true }); modal('Record return' + (x.person_name ? ' · ' + x.person_name : ''), form(fields, 'Confirm return', function (d) { return API.post('assignments/' + id + '/return', d).then(function () { closeModal(); toast('Return recorded'); route(); }); })); }; ACTIONS.stock = function (id) { var a = state.cache.asset; modal('Stock movement · ' + a.name, form([ { name: 'direction', label: 'Movement', type: 'select', options: [{ value: 'in', label: 'Stock in – new purchase / received' }, { value: 'out', label: 'Stock out – damaged / disposed' }], full: true }, { name: 'qty', label: 'Quantity (' + a.unit + ')', type: 'number', min: 1, value: 10, required: true }, { name: 'notes', label: 'Reference / note', placeholder: 'PO-2026-118 or reason' } ], 'Save', function (d) { return API.post('assets/' + id + '/stock', d).then(function () { closeModal(); toast('Stock updated'); route(); }); })); }; ACTIONS.status = function (id) { var a = state.cache.asset; modal('Status & service · ' + a.name, form([ { name: 'status', label: 'New status', type: 'select', value: a.status === 'assigned' ? 'maintenance' : a.status, options: [{ value: 'available', label: 'Available' }, { value: 'maintenance', label: 'Send to maintenance' }, { value: 'retired', label: 'Retire' }, { value: 'lost', label: 'Mark as lost' }], full: true }, { name: 'next_service', label: 'Next service date', type: 'date', value: a.next_service }, { name: 'notes', label: 'Note', placeholder: 'Vendor, job card, reason…' } ], 'Update', function (d) { return API.post('assets/' + id + '/status', d).then(function () { closeModal(); toast('Status updated'); route(); }); })); }; ACTIONS.report = function (id) { modal('Report a problem', form([ { name: 'kind', label: 'Problem', type: 'select', options: ['Damaged', 'Needs service', 'Lost / missing', 'Wrong size / replacement', 'Other'], full: true }, { name: 'message', label: 'Details', type: 'textarea', full: true } ], 'Send report', function (d) { return API.post('assets/' + id + '/report', d).then(function () { closeModal(); toast('Report sent to asset manager'); if (state.route === 'asset') route(); }); })); }; ACTIONS['report-any'] = function () { go('scan'); toast('Scan or type the item code, then tap Report problem'); }; ACTIONS.qr = function (id) { var a = (state.cache.assets || []).filter(function (x) { return String(x.id) === String(id); })[0]; if (!a) return; var b = el('
' + qrSvg(scanUrl(a.code), 220) + '' + h(a.code) + '' + h(a.name) + '
Open asset
'); b.addEventListener('click', function (e) { var q = e.target.closest('[data-q]'); if (!q) { if (e.target.closest('a')) closeModal(); return; } if (q.getAttribute('data-q') === 'png') qrPng(a); else printLabels([a]); }); modal('QR code', b); }; ACTIONS.png = function () { qrPng(state.cache.asset); }; ACTIONS.label = function () { printLabels([state.cache.asset]); }; ACTIONS['print-sel'] = function () { var l = state.cache.selected ? state.cache.selected() : []; if (!l.length) { toast('No assets to print', 'err'); return; } printLabels(l); }; ACTIONS['print-page'] = function () { window.print(); }; ACTIONS['csv-assets'] = function () { API.get('assets').then(function (r) { csv('assets', r, [['code', 'Code'], ['name', 'Name'], ['type', 'Type'], ['category', 'Category'], ['brand', 'Brand'], ['model', 'Model'], ['serial_no', 'Serial'], ['size_variant', 'Size'], ['qty_total', 'Total qty'], ['qty_available', 'Available'], ['unit', 'Unit'], ['min_stock', 'Min stock'], ['location', 'Location'], ['status', 'Status'], ['item_condition', 'Condition'], ['purchase_date', 'Purchased'], ['purchase_cost', 'Unit cost'], ['warranty_until', 'Warranty'], ['next_service', 'Next service'], [function (a) { return scanUrl(a.code); }, 'QR link']]); }).catch(fail); }; ACTIONS['csv-assign'] = function () { API.get('assignments').then(function (r) { csv('issue-register', r, [['person_name', 'Employee'], ['emp_code', 'Emp code'], ['department', 'Department'], ['asset_code', 'Asset code'], ['asset_name', 'Asset'], ['qty', 'Qty issued'], ['returned_qty', 'Qty returned'], ['unit', 'Unit'], ['issued_at', 'Issued'], ['due_at', 'Due'], ['returned_at', 'Returned'], ['status', 'Status']]); }).catch(fail); }; function personForm(p) { p = p || { kind: 'employee', active: 1 }; return [ { name: 'name', label: 'Full name / team name', value: p.name, required: true, full: true }, { name: 'kind', label: 'Type', type: 'select', value: p.kind, options: [{ value: 'employee', label: 'Employee' }, { value: 'team', label: 'Team / crew' }, { value: 'site', label: 'Site / location' }] }, { name: 'emp_code', label: 'Employee code', value: p.emp_code }, { name: 'department', label: 'Department', type: 'datalist', options: LISTS.departments, value: p.department }, { name: 'designation', label: 'Designation', value: p.designation }, { name: 'site', label: 'Site', type: 'datalist', options: LISTS.locations, value: p.site }, { name: 'phone', label: 'Phone', value: p.phone }, { name: 'user_id', label: 'Login account (for team dashboard)', type: 'select', value: p.user_id, options: [{ value: 0, label: '— None —' }], full: true, help: 'Link a WordPress user so this employee sees "My Items".' }, { name: 'active', label: 'Active', type: 'select', value: p.active, options: [{ value: 1, label: 'Active' }, { value: 0, label: 'Inactive / left' }] } ]; } function fillUsers(f, selected) { if (!CAN.manage) return; API.get('users').then(function (u) { var s = f.querySelector('[name=user_id]'); u.forEach(function (x) { var o = document.createElement('option'); o.value = x.id; o.textContent = x.name + ' (' + x.email + ')'; if (String(x.id) === String(selected)) o.selected = true; s.appendChild(o); }); }).catch(function () {}); } ACTIONS['new-person'] = function () { var f = form(personForm(), 'Save', function (d) { return API.post('people', d).then(function () { closeModal(); state.cache.people = null; toast('Employee added'); route(); }); }); fillUsers(f, 0); modal('Add employee / team', f, { wide: true }); }; ACTIONS.person = function (id) { var p = (state.cache.people || []).filter(function (x) { return String(x.id) === String(id); })[0]; if (!p) return; API.get('assignments', { person_id: id, open: 1 }).then(function (items) { var b = el('
' + h(p.name.charAt(0)) + '

' + h(p.name) + '

' + h([p.emp_code, p.designation, p.department, p.site, p.phone].filter(Boolean).join(' · ')) + '

Items held

' + myItems(items) + '
' + (CAN.manage ? '' : '') + (CAN.operate ? '' : '') + '
'); b.addEventListener('click', function (e) { var q = e.target.closest('[data-p]'); if (!q) { if (e.target.closest('a')) closeModal(); return; } var k = q.getAttribute('data-p'); if (k === 'issue') { issueModal(null).then(function () { var s = document.querySelector('.vt-modal [name=person_id]'); if (s) s.value = id; }); } if (k === 'del' && confirm('Delete ' + p.name + '?')) API.del('people/' + id).then(function () { closeModal(); state.cache.people = null; toast('Deleted'); route(); }).catch(fail); if (k === 'edit') { var f = form(personForm(p), 'Save', function (d) { return API.put('people/' + id, d).then(function () { closeModal(); state.cache.people = null; toast('Saved'); route(); }); }); fillUsers(f, p.user_id); modal('Edit · ' + p.name, f, { wide: true }); } }); modal('Employee', b); }).catch(fail); }; /* ------------------------------------------------------------ boot */ function boot() { root = document.getElementById('vt-app'); if (!root || !API) return; shell(); if (!window._vtHash) { window.addEventListener('hashchange', route); window._vtHash = true; } route(); } window.VTApp = { boot: boot, lookup: lookup, views: VIEWS, actions: ACTIONS, qrSvg: qrSvg, scanUrl: scanUrl, h: h, pill: pill, stockBar: stockBar, date: date, setConfig: function (c) { C = c; B = C.brand || {}; CAN = C.can || {}; LISTS = C.lists || LISTS; } }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot(); })();