345 lines
15 KiB
JavaScript
345 lines
15 KiB
JavaScript
|
|
/**
|
||
|
|
* pc-live | Parkuhr Dashboard
|
||
|
|
* Integration with qb-parkuhr (data via qb-parkuhr:getPanelData callback)
|
||
|
|
*/
|
||
|
|
const ParkuhrApp = (() => {
|
||
|
|
const WIN_ID = 'app-parkuhr';
|
||
|
|
let _data = null; // { devices[], typeTariffs{}, currency }
|
||
|
|
let _selectedDevice = null; // currently shown device or null = overview
|
||
|
|
|
||
|
|
const TYPE_META = {
|
||
|
|
machine: { label: 'Automat', icon: '🏧', color: 'var(--accent)' },
|
||
|
|
meter_small: { label: 'Parkuhr (S)', icon: '🅿', color: 'var(--success)' },
|
||
|
|
meter_big: { label: 'Parkuhr (L)', icon: '🅿', color: 'var(--warning)' },
|
||
|
|
};
|
||
|
|
|
||
|
|
/* ── Format helpers ──────────────────────────────────────── */
|
||
|
|
function fmtMoney(val, cur = '$') {
|
||
|
|
const n = parseFloat(val) || 0;
|
||
|
|
return `${cur}${n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function fmtDuration(min) {
|
||
|
|
if (min >= 10080) return `${min / 10080} W`;
|
||
|
|
if (min >= 1440) return `${min / 1440} T`;
|
||
|
|
if (min >= 60) return `${min / 60} Std.`;
|
||
|
|
return `${min} Min.`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function fmtPos(pos) {
|
||
|
|
if (!pos) return '—';
|
||
|
|
return `${Math.round(pos.x)}, ${Math.round(pos.y)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function fmtTime(ts) {
|
||
|
|
if (!ts) return '—';
|
||
|
|
try {
|
||
|
|
return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||
|
|
} catch { return String(ts); }
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ── KPI card ────────────────────────────────────────────── */
|
||
|
|
function kpiCard(icon, label, value, color = 'var(--text-muted)') {
|
||
|
|
return `
|
||
|
|
<div style="background:var(--bg-sidebar);border:1px solid var(--border);
|
||
|
|
border-radius:var(--radius);padding:12px">
|
||
|
|
<div style="font-size:.63rem;color:var(--text-dim);margin-bottom:4px">${icon} ${label}</div>
|
||
|
|
<div style="font-size:1.05rem;font-weight:700;color:${color}">${value}</div>
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ── Overview page ───────────────────────────────────────── */
|
||
|
|
function renderOverview() {
|
||
|
|
if (!_data) {
|
||
|
|
return `<div class="empty-state"><div class="empty-icon">⏳</div><p>Daten werden geladen…</p></div>`;
|
||
|
|
}
|
||
|
|
const devices = _data.devices || [];
|
||
|
|
const cur = _data.currency || '$';
|
||
|
|
|
||
|
|
const totalRevenue = devices.reduce((s, d) => s + (parseFloat(d.revenue) || 0), 0);
|
||
|
|
const totalSales = devices.reduce((s, d) => s + (d.sales?.length || 0), 0);
|
||
|
|
const machineCount = devices.filter(d => d.type === 'machine').length;
|
||
|
|
const meterCount = devices.filter(d => d.type !== 'machine').length;
|
||
|
|
|
||
|
|
// Merge & sort all sales across devices, show last 12
|
||
|
|
const allSales = [];
|
||
|
|
for (const dev of devices) {
|
||
|
|
for (const s of (dev.sales || []).slice(-8)) {
|
||
|
|
allSales.push({ ...s, devId: dev.id, devType: dev.type });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
allSales.sort((a, b) => new Date(b.ts) - new Date(a.ts));
|
||
|
|
const recent = allSales.slice(0, 12);
|
||
|
|
|
||
|
|
return `
|
||
|
|
<div style="display:flex;flex-direction:column;gap:14px">
|
||
|
|
|
||
|
|
<!-- KPI row -->
|
||
|
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px">
|
||
|
|
${kpiCard('💰', 'Gesamteinnahmen', fmtMoney(totalRevenue, cur), 'var(--accent)')}
|
||
|
|
${kpiCard('📋', 'Transaktionen', totalSales)}
|
||
|
|
${kpiCard('🏧', 'Automaten', machineCount)}
|
||
|
|
${kpiCard('🅿', 'Parkuhren', meterCount)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Device revenue bar -->
|
||
|
|
${devices.length > 0 ? `
|
||
|
|
<div>
|
||
|
|
<div class="sidebar-section" style="padding:0 0 6px">Gerät-Einnahmen</div>
|
||
|
|
<div style="display:flex;flex-direction:column;gap:4px">
|
||
|
|
${devices.map(d => {
|
||
|
|
const meta = TYPE_META[d.type] || { icon: '📍', label: d.type, color: 'var(--text-muted)' };
|
||
|
|
const rev = parseFloat(d.revenue) || 0;
|
||
|
|
const pct = totalRevenue > 0 ? Math.round((rev / totalRevenue) * 100) : 0;
|
||
|
|
return `
|
||
|
|
<div style="display:flex;align-items:center;gap:8px;font-size:.72rem;cursor:pointer"
|
||
|
|
onclick="ParkuhrApp._select(${d.id})">
|
||
|
|
<span style="min-width:60px;color:var(--text-muted)">${meta.icon} #${d.id}</span>
|
||
|
|
<div style="flex:1;background:var(--bg-input);border-radius:99px;height:6px;overflow:hidden">
|
||
|
|
<div style="width:${pct}%;height:100%;background:${meta.color};border-radius:99px"></div>
|
||
|
|
</div>
|
||
|
|
<span style="min-width:70px;text-align:right;color:var(--text)">${fmtMoney(rev, cur)}</span>
|
||
|
|
</div>`;
|
||
|
|
}).join('')}
|
||
|
|
</div>
|
||
|
|
</div>` : ''}
|
||
|
|
|
||
|
|
<!-- Recent transactions -->
|
||
|
|
<div>
|
||
|
|
<div class="sidebar-section" style="padding:0 0 6px">Letzte Transaktionen</div>
|
||
|
|
${recent.length === 0
|
||
|
|
? `<div class="empty-state" style="padding:20px"><p>Keine Transaktionen vorhanden</p></div>`
|
||
|
|
: `<div style="display:flex;flex-direction:column;gap:2px">
|
||
|
|
${recent.map(s => {
|
||
|
|
const meta = TYPE_META[s.devType] || { icon: '📍' };
|
||
|
|
return `
|
||
|
|
<div style="display:flex;align-items:center;gap:8px;padding:5px 10px;
|
||
|
|
background:var(--bg-sidebar);border-radius:var(--radius-sm);font-size:.71rem">
|
||
|
|
<span style="color:var(--text-dim);width:34px">${fmtTime(s.ts)}</span>
|
||
|
|
<span class="tag" style="font-size:.58rem">${meta.icon} #${s.devId}</span>
|
||
|
|
<span style="color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${s.label || '—'}</span>
|
||
|
|
<span style="color:var(--success);font-weight:600;min-width:52px;text-align:right">
|
||
|
|
${s.price != null ? fmtMoney(s.price, cur) : '—'}
|
||
|
|
</span>
|
||
|
|
</div>`;
|
||
|
|
}).join('')}
|
||
|
|
</div>`
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ── Device detail page ──────────────────────────────────── */
|
||
|
|
function renderDevice(device) {
|
||
|
|
const cur = _data?.currency || '$';
|
||
|
|
const meta = TYPE_META[device.type] || { icon: '📍', label: device.type, color: 'var(--accent)' };
|
||
|
|
const sales = (device.sales || []).slice(-20).reverse();
|
||
|
|
const isMach = device.type === 'machine';
|
||
|
|
|
||
|
|
// Tariff list: device-specific for machines, type-wide for meters
|
||
|
|
const tariffList = isMach
|
||
|
|
? (device.tariffs || [])
|
||
|
|
: (_data?.typeTariffs?.[device.type] || []);
|
||
|
|
|
||
|
|
return `
|
||
|
|
<div style="display:flex;flex-direction:column;gap:14px">
|
||
|
|
|
||
|
|
<!-- Device info card -->
|
||
|
|
<div style="background:var(--bg-sidebar);border:1px solid var(--border);
|
||
|
|
border-radius:var(--radius);padding:14px">
|
||
|
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
|
||
|
|
<span style="font-size:2rem">${meta.icon}</span>
|
||
|
|
<div style="flex:1">
|
||
|
|
<div style="font-size:.92rem;font-weight:700;color:#fff">Gerät #${device.id}</div>
|
||
|
|
<div style="font-size:.7rem;color:var(--text-muted)">
|
||
|
|
${meta.label} · <span style="color:var(--text-dim)">${device.model || '—'}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div style="text-align:right">
|
||
|
|
<div style="font-size:1.1rem;font-weight:700;color:${meta.color}">${fmtMoney(device.revenue, cur)}</div>
|
||
|
|
<div style="font-size:.62rem;color:var(--text-dim)">Kassenstand</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div style="display:flex;gap:14px;font-size:.68rem;color:var(--text-dim);
|
||
|
|
border-top:1px solid var(--border);padding-top:8px;margin-top:4px">
|
||
|
|
<span>📍 ${fmtPos(device.pos)}</span>
|
||
|
|
<span>👤 ${device.placed_by || '—'}</span>
|
||
|
|
<span>📋 ${device.sales?.length || 0} Verkäufe (gespeichert)</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Tariffs -->
|
||
|
|
<div>
|
||
|
|
<div class="sidebar-section" style="padding:0 0 8px">
|
||
|
|
Tarife
|
||
|
|
${isMach
|
||
|
|
? '<span style="color:var(--text-dim);font-size:.6rem;margin-left:4px">(gerätespezifisch)</span>'
|
||
|
|
: '<span style="color:var(--text-dim);font-size:.6rem;margin-left:4px">(Typstandard, nicht editierbar)</span>'}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:${isMach ? '10px' : '0'}">
|
||
|
|
${tariffList.length === 0
|
||
|
|
? `<span style="color:var(--text-dim);font-size:.73rem">Keine Tarife definiert</span>`
|
||
|
|
: tariffList.map(t => {
|
||
|
|
const dur = t.duration ?? t.duration_min;
|
||
|
|
return `
|
||
|
|
<div style="background:var(--bg-input);border:1px solid var(--border);
|
||
|
|
border-radius:var(--radius);padding:8px 10px;text-align:center;min-width:76px">
|
||
|
|
<div style="font-size:.65rem;color:var(--text-dim)">${fmtDuration(dur)}</div>
|
||
|
|
<div style="font-size:.9rem;font-weight:700;color:${meta.color};margin:2px 0">${fmtMoney(t.price, cur)}</div>
|
||
|
|
${isMach ? `
|
||
|
|
<button class="btn-danger btn-sm" style="padding:1px 5px;font-size:.58rem;margin-top:2px"
|
||
|
|
onclick="ParkuhrApp._deleteTariff(${device.id},${dur})">✕</button>` : ''}
|
||
|
|
</div>`;
|
||
|
|
}).join('')}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
${isMach ? `
|
||
|
|
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||
|
|
<select id="tariff-dur" style="width:88px;height:28px;font-size:.72rem;padding:0 6px">
|
||
|
|
<option value="60">1 Stunde</option>
|
||
|
|
<option value="1440">1 Tag</option>
|
||
|
|
<option value="10080">7 Tage</option>
|
||
|
|
</select>
|
||
|
|
<input id="tariff-price" type="number" min="0" step="0.01" placeholder="Preis (${cur})"
|
||
|
|
style="width:110px;height:28px;padding:0 8px;font-size:.72rem"/>
|
||
|
|
<button class="btn-primary btn-sm" onclick="ParkuhrApp._saveTariff(${device.id})">
|
||
|
|
Tarif speichern
|
||
|
|
</button>
|
||
|
|
</div>` : ''}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Sales history -->
|
||
|
|
<div>
|
||
|
|
<div class="sidebar-section" style="padding:0 0 6px">
|
||
|
|
Verkaufshistorie (letzte ${sales.length})
|
||
|
|
</div>
|
||
|
|
${sales.length === 0
|
||
|
|
? `<div class="empty-state" style="padding:16px"><p>Keine Einträge</p></div>`
|
||
|
|
: `<div style="display:flex;flex-direction:column;gap:2px">
|
||
|
|
${sales.map(s => `
|
||
|
|
<div style="display:flex;align-items:center;gap:8px;padding:5px 10px;
|
||
|
|
background:var(--bg-sidebar);border-radius:var(--radius-sm);font-size:.71rem">
|
||
|
|
<span style="color:var(--text-dim);width:34px">${fmtTime(s.ts)}</span>
|
||
|
|
<span style="color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${s.label || '—'}</span>
|
||
|
|
<span style="color:var(--success);font-weight:600;min-width:52px;text-align:right">
|
||
|
|
${s.price != null ? fmtMoney(s.price, cur) : '—'}
|
||
|
|
</span>
|
||
|
|
</div>`).join('')}
|
||
|
|
</div>`}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ── Full window render ──────────────────────────────────── */
|
||
|
|
function _refresh() {
|
||
|
|
const body = WindowManager.getBody(WIN_ID);
|
||
|
|
if (!body) return;
|
||
|
|
|
||
|
|
const devices = _data?.devices || [];
|
||
|
|
const cur = _data?.currency || '$';
|
||
|
|
|
||
|
|
body.innerHTML = `
|
||
|
|
<div class="app-layout" style="height:100%">
|
||
|
|
|
||
|
|
<!-- Sidebar -->
|
||
|
|
<div class="app-sidebar" style="width:170px;overflow-y:auto">
|
||
|
|
<div style="display:flex;align-items:center;justify-content:space-between;
|
||
|
|
padding:8px 14px 4px">
|
||
|
|
<span class="sidebar-section" style="padding:0;font-size:.6rem">
|
||
|
|
Geräte (${devices.length})
|
||
|
|
</span>
|
||
|
|
<button class="btn-ghost btn-sm" style="height:20px;padding:0 6px;font-size:.65rem"
|
||
|
|
title="Aktualisieren" onclick="ParkuhrApp._reload()">↻</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="sidebar-item ${_selectedDevice === null ? 'active' : ''}"
|
||
|
|
onclick="ParkuhrApp._select(null)">
|
||
|
|
📊 Übersicht
|
||
|
|
</div>
|
||
|
|
<div class="divider"></div>
|
||
|
|
|
||
|
|
${devices.length === 0
|
||
|
|
? `<div style="padding:8px 14px;font-size:.7rem;color:var(--text-dim)">Keine Geräte</div>`
|
||
|
|
: devices.map(d => {
|
||
|
|
const m = TYPE_META[d.type] || { icon: '📍', label: d.type };
|
||
|
|
const sel = _selectedDevice?.id === d.id;
|
||
|
|
return `
|
||
|
|
<div class="sidebar-item ${sel ? 'active' : ''}"
|
||
|
|
onclick="ParkuhrApp._select(${d.id})">
|
||
|
|
<div>
|
||
|
|
<div style="font-size:.72rem;font-weight:600">${m.icon} #${d.id}</div>
|
||
|
|
<div style="font-size:.6rem;color:var(--text-dim)">${fmtMoney(d.revenue, cur)}</div>
|
||
|
|
</div>
|
||
|
|
</div>`;
|
||
|
|
}).join('')}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Content -->
|
||
|
|
<div class="app-content">
|
||
|
|
${_selectedDevice === null ? renderOverview() : renderDevice(_selectedDevice)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ── Public actions ──────────────────────────────────────── */
|
||
|
|
function _select(deviceId) {
|
||
|
|
_selectedDevice = deviceId === null
|
||
|
|
? null
|
||
|
|
: (_data?.devices || []).find(d => d.id === deviceId) || null;
|
||
|
|
_refresh();
|
||
|
|
}
|
||
|
|
|
||
|
|
function _saveTariff(deviceId) {
|
||
|
|
const dur = parseInt(document.getElementById('tariff-dur')?.value);
|
||
|
|
const price = parseFloat(document.getElementById('tariff-price')?.value);
|
||
|
|
if (isNaN(dur) || isNaN(price) || price < 0) return;
|
||
|
|
fetchNui('saveParkuhrTariff', { deviceId, duration: dur, price }).then(_reload);
|
||
|
|
}
|
||
|
|
|
||
|
|
function _deleteTariff(deviceId, duration) {
|
||
|
|
fetchNui('deleteParkuhrTariff', { deviceId, duration }).then(_reload);
|
||
|
|
}
|
||
|
|
|
||
|
|
function _reload() {
|
||
|
|
const body = WindowManager.getBody(WIN_ID);
|
||
|
|
if (!body) return;
|
||
|
|
body.innerHTML = `<div class="empty-state"><div class="empty-icon">⏳</div><p>Aktualisiere…</p></div>`;
|
||
|
|
fetchNui('getParkuhrPanel', {}).then(data => {
|
||
|
|
_data = data || { devices: [], typeTariffs: {}, currency: '$' };
|
||
|
|
// Re-select from fresh data
|
||
|
|
if (_selectedDevice) {
|
||
|
|
_selectedDevice = (_data.devices || []).find(d => d.id === _selectedDevice.id) || null;
|
||
|
|
}
|
||
|
|
_refresh();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ── Open ────────────────────────────────────────────────── */
|
||
|
|
function open() {
|
||
|
|
const created = WindowManager.create({
|
||
|
|
id: WIN_ID,
|
||
|
|
title: 'Parkuhr Dashboard',
|
||
|
|
icon: '🅿',
|
||
|
|
width: 860,
|
||
|
|
height: 540,
|
||
|
|
content: '',
|
||
|
|
});
|
||
|
|
if (!created) return;
|
||
|
|
|
||
|
|
_data = null;
|
||
|
|
_selectedDevice = null;
|
||
|
|
_refresh();
|
||
|
|
|
||
|
|
fetchNui('getParkuhrPanel', {}).then(data => {
|
||
|
|
_data = data || { devices: [], typeTariffs: {}, currency: '$' };
|
||
|
|
_refresh();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return { open, _select, _saveTariff, _deleteTariff, _reload };
|
||
|
|
})();
|