Rapihin setup/member/lot-parking + fitur intercom, exclude WIP controller & file sampah dari git

Kumpulan perubahan yang udah numpuk di working tree: refactor Setup
(Gate/Vehicle/Member), rapihin memberin & stiker, service parking baru
(ParkingUtilsServices, NetworkService), model/migration lot-parking &
DHCP reservation, dan fitur baru Intercom (controller/model/migration/
view).

ReportTransaksiController versi lokal ini WIP (~90%, masih didebug) -
versi yang bener ada di server, jadi dikeluarin dari git (ditambahin
ke .gitignore) biar gak numpuk nindihin punya server pas git pull di
sana. Sekalian keluarin dump mentah tabel softseting (1.8MB, nyasar
ke-commit), file .bak, dan arsip ad-hoc (api.tgz, c.zip) yang emang
bukan source code.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EAJc2mrpj2cSTe4oowLPzQ
This commit is contained in:
2026-10-04 07:15:54 +07:00
co-authored by Claude Sonnet 5
parent efdea9d739
commit 2a9b28581c
91 changed files with 5716 additions and 2470 deletions
+235
View File
@@ -0,0 +1,235 @@
@extends('layouts.master')
@section('styles')
<link href="{{ asset('vendor/datatables/css/jquery.dataTables.min.css') }}" rel="stylesheet">
<style>
.dev-card { text-align: center; padding: 14px 8px; border-radius: 8px; }
.dev-id { font-size: 22px; font-weight: 800; }
.badge-status { font-size: 12px; padding: 4px 10px; border-radius: 20px; }
.st-idle { background: #e8f5e9; color: #2e7d32; }
.st-calling { background: #fff3e0; color: #ef6c00; animation: blink 1s infinite; }
.st-active { background: #e3f2fd; color: #1565c0; }
.st-busy { background: #fce4ec; color: #c62828; }
.st-offline { background: #eeeeee; color: #616161; }
@keyframes blink { 50% { opacity: .45; } }
.queue-card { border-left: 5px solid #fb8c00; }
.queue-card.active-session { border-left-color: #1e88e5; }
.timer { font-variant-numeric: tabular-nums; font-weight: 700; }
#talkHint kbd { background: #333; color: #fff; border-radius: 4px; padding: 1px 7px; }
</style>
@endsection
@section('content')
<div class="container-fluid page-body-wrapper">
<div class="main-panel">
<div class="content-wrapper">
<div class="row mb-2">
<div class="col-md-8">
<h4 class="font-weight-bold">Intercom Pusat <small class="text-muted">— local network only</small></h4>
<p class="text-muted mb-0">Client ketok via 1 tombol &rarr; masuk antrean &rarr; operator TERIMA &rarr; sesi private (client lain tidak dengar).</p>
</div>
<div class="col-md-4 text-end">
<span id="srvStatus" class="badge badge-secondary">menghubungkan…</span>
<button class="btn btn-sm btn-light border ml-2" onclick="loadAll()">⟳ Refresh</button>
</div>
</div>
<div class="row">
{{-- ANTREAN --}}
<div class="col-md-5 grid-margin stretch-card">
<div class="card">
<div class="card-body">
<h5>Antrean <span id="queueCount" class="badge badge-warning">0</span></h5>
<p class="text-muted small">Bunyi <i>ting</i> tiap ada request baru. Klik TERIMA untuk buka sesi private.</p>
<div id="queueList"><p class="text-muted">Tidak ada antrean.</p></div>
</div>
</div>
</div>
{{-- SESI AKTIF --}}
<div class="col-md-7 grid-margin stretch-card">
<div class="card">
<div class="card-body">
<h5>Sesi Aktif</h5>
<div id="activeBox"><p class="text-muted">Belum ada sesi. Terima salah satu antrean.</p></div>
<div id="talkHint" class="alert alert-info mt-3 mb-0">
Voice tetap lewat hardware/headset via Go relay UDP :5000.
Dashboard ini <b>kontrolnya</b> (terima / end / panggil).
Tombol fisik <kbd>TALK</kbd> = tahan buat ngomong (PTT half-duplex).
</div>
</div>
</div>
</div>
</div>
{{-- STATUS SEMUA UNIT --}}
<div class="row">
<div class="col-md-12 grid-margin stretch-card">
<div class="card">
<div class="card-body">
<h5>Status Unit</h5>
<div id="deviceGrid" class="row"></div>
</div>
</div>
</div>
</div>
{{-- RIWAYAT --}}
<div class="row">
<div class="col-md-12 grid-margin stretch-card">
<div class="card">
<div class="card-body">
<h5>Riwayat 50 terakhir</h5>
<div class="table-responsive">
<table class="table table-striped" id="historyTable">
<thead>
<tr><th>ID</th><th>Unit</th><th>Arah</th><th>Status</th><th>Diminta</th><th>Durasi</th><th>Rekaman</th></tr>
</thead>
<tbody id="historyBody"><tr><td colspan="7" class="text-muted">Memuat…</td></tr></tbody>
</table>
</div>
<p class="text-muted small mt-2">Rekaman masih raw PCM 16kHz mono — klik unduh lalu putar: <code>ffplay -f s16le -ar 16000 file.raw</code></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
@section('javascript')
<script>
const API = '/api/v1/intercom';
// Helper tanpa jQuery (layouts.master tidak selalu expose $ global)
function el(id) { return document.getElementById(id); }
function setBadge(ok, txt) {
const b = el('srvStatus');
b.setAttribute('class', 'badge ' + (ok ? 'badge-success' : 'badge-danger'));
b.textContent = txt;
}
async function fetchJSON(url, opts) {
const r = await fetch(url, opts);
if (!r.ok) throw new Error('HTTP ' + r.status + ' @ ' + url);
return r.json();
}
let seenWaiting = new Set();
let audioCtx = null;
function beep() {
try {
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
const o = audioCtx.createOscillator(), g = audioCtx.createGain();
o.connect(g); g.connect(audioCtx.destination);
o.frequency.value = 880; o.type = 'sine';
g.gain.setValueAtTime(0.25, audioCtx.currentTime);
o.start(); o.stop(audioCtx.currentTime + 0.35);
} catch (e) {}
}
function elapsed(ts) {
if (!ts) return '-';
const s = Math.max(0, Math.floor((Date.now() - new Date(ts).getTime()) / 1000));
const m = String(Math.floor(s / 60)).padStart(2, '0');
return m + ':' + String(s % 60).padStart(2, '0');
}
function badge(st) {
const map = { idle: 'st-idle', calling: 'st-calling', waiting: 'st-calling', active: 'st-active', busy: 'st-busy', offline: 'st-offline', ended: 'st-offline', missed: 'st-busy' };
return '<span class="badge-status ' + (map[st] || 'st-offline') + '">' + (st || '-').toUpperCase() + '</span>';
}
async function loadQueue() {
try {
const q = await fetchJSON(API + '/queue');
setBadge(true, '● online');
const waiting = q.filter(x => x.status === 'waiting');
const active = q.filter(x => x.status === 'active');
el('queueCount').textContent = waiting.length;
// bunyi kalau ada waiting baru
waiting.forEach(w => { if (!seenWaiting.has(w.id)) { seenWaiting.add(w.id); beep(); } });
el('queueList').innerHTML = waiting.length ? '' : '<p class="text-muted">Tidak ada antrean.</p>';
waiting.forEach(w => {
el('queueList').insertAdjacentHTML('beforeend',
'<div class="card queue-card mb-2"><div class="card-body py-2 d-flex justify-content-between align-items-center">' +
'<div><b>' + w.device_id + '</b> <span class="timer">' + elapsed(w.requested_at) + '</span><br>' +
'<small class="text-muted">' + (w.arah === 'out' ? '⬅ pusat memanggil' : '➡ client mengetok') + ' • #' + w.id + '</small></div>' +
'<button class="btn btn-sm btn-warning" onclick="acceptCall(' + w.id + ')">TERIMA</button>' +
'</div></div>'
);
});
if (active.length) {
const a = active[0];
el('activeBox').innerHTML =
'<div class="card queue-card active-session"><div class="card-body">' +
'<h3>' + a.device_id + ' <span class="timer" id="activeTimer">' + elapsed(a.started_at || a.requested_at) + '</span></h3>' +
'<p class="text-muted mb-2">Sesi #' + a.id + ' • private, unit lain tidak dengar</p>' +
'<button class="btn btn-danger" onclick="endCall(' + a.id + ')">END</button> ' +
'<span class="text-muted small ml-2">atau tombol END fisik di box pusat</span>' +
'</div></div>';
} else {
el('activeBox').innerHTML = '<p class="text-muted">Belum ada sesi. Terima salah satu antrean.</p>';
}
} catch (e) {
setBadge(false, '● ' + e.message);
}
}
async function loadDevices() {
try {
const devs = await fetchJSON(API + '/devices');
el('deviceGrid').innerHTML = '';
devs.forEach(d => {
el('deviceGrid').insertAdjacentHTML('beforeend',
'<div class="col-md-2 col-6 mb-2"><div class="border dev-card">' +
'<div class="dev-id">' + d.device_id + '</div>' +
'<div class="small text-muted">' + (d.nama || '') + '</div>' +
'<div class="my-1">' + badge(d.status) + '</div>' +
'<button class="btn btn-sm btn-primary btn-block mt-1" onclick="callDevice(\'' + d.device_id + '\')">PANGGIL</button>' +
'</div></div>'
);
});
if (!devs.length) el('deviceGrid').innerHTML = '<p class="text-muted">Belum ada device. Seed C1–C5 + ADMIN dulu.</p>';
} catch (e) {}
}
async function loadHistory() {
try {
const h = await fetchJSON(API + '/history?limit=50');
el('historyBody').innerHTML = '';
h.forEach(r => {
const rec = r.recording_path
? '<a href="/intercom/recording/' + r.id + '" class="btn btn-sm btn-light border">⬇ unduh</a>'
: '<span class="text-muted">-</span>';
el('historyBody').insertAdjacentHTML('beforeend', '<tr><td>#' + r.id + '</td><td><b>' + r.device_id + '</b></td><td>' + r.arah + '</td><td>' + badge(r.status) + '</td><td class="small">' + (r.requested_at || '-') + '</td><td>' + (r.durasi_detik || 0) + 's</td><td>' + rec + '</td></tr>');
});
} catch (e) {}
}
async function acceptCall(id) {
await fetch(API + '/accept', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ call_id: id }) });
loadAll();
}
async function endCall(id) {
await fetch(API + '/end', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ call_id: id, ended_by: 'admin' }) });
loadAll();
}
// Panggil manual pusat -> client: buat request arah out lalu langsung accept (sesi private langsung kebuka)
async function callDevice(dev) {
if (dev === 'ADMIN') return;
const r = await (await fetch(API + '/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ device_id: dev, arah: 'out' }) })).json();
if (r.call_id) await fetch(API + '/accept', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ call_id: r.call_id }) });
loadAll();
}
function loadAll() { loadQueue(); loadDevices(); loadHistory(); }
loadAll();
setInterval(loadQueue, 1000);
setInterval(loadDevices, 2000);
setInterval(loadHistory, 5000);
</script>
@endsection