611 lines
22 KiB
PHP
611 lines
22 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="id">
|
|
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>QR Absensi | Sistem Absensi Digital</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
<meta name="description"
|
|
content="QR Absensi adalah sistem absensi digital berbasis QR Code untuk memudahkan pencatatan kehadiran secara cepat, akurat, dan efisien.">
|
|
<meta name="keywords"
|
|
content="qr absensi, absensi digital, absensi qr code, sistem absensi, kehadiran karyawan, absensi online">
|
|
<meta name="author" content="NJK">
|
|
<meta name="robots" content="index, follow">
|
|
<meta name="theme-color" content="#ffffff">
|
|
|
|
<link rel="canonical" href="{{ url()->current() }}">
|
|
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:title" content="QR Absensi | Sistem Absensi Digital">
|
|
<meta property="og:description"
|
|
content="Sistem absensi digital berbasis QR Code untuk pencatatan kehadiran yang cepat dan efisien.">
|
|
<meta property="og:url" content="{{ url()->current() }}">
|
|
<meta property="og:image" content="{{ asset('assets/images/njk-logo.png') }}">
|
|
<meta property="og:site_name" content="QR Absensi">
|
|
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<meta name="twitter:title" content="QR Absensi | Sistem Absensi Digital">
|
|
<meta name="twitter:description"
|
|
content="Sistem absensi digital berbasis QR Code untuk pencatatan kehadiran yang cepat dan efisien.">
|
|
<meta name="twitter:image" content="{{ asset('assets/images/njk-logo.png') }}">
|
|
|
|
<link rel="icon" type="image/png" href="{{ asset('assets/images/njk-logo.png') }}">
|
|
<link rel="shortcut icon" href="{{ asset('assets/images/njk-logo.png') }}">
|
|
<link rel="apple-touch-icon" href="{{ asset('assets/images/njk-logo.png') }}">
|
|
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="{{ asset('landing/assets/vendor/bootstrap/css/bootstrap.min.css') }}">
|
|
|
|
<style>
|
|
:root {
|
|
--teal: #0ea5a5;
|
|
--soft: #f5f7fb;
|
|
--card: #fff;
|
|
--muted: #6b7280;
|
|
--line: #eef2f7;
|
|
--green: #22c55e;
|
|
--red: #ef4444;
|
|
--page-pad: clamp(12px, 2vw, 32px);
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
min-height: 100svh;
|
|
/* full viewport height */
|
|
}
|
|
|
|
.page {
|
|
min-height: 100svh;
|
|
display: grid;
|
|
place-items: center;
|
|
/* center horizontal & vertical */
|
|
padding: var(--page-pad);
|
|
}
|
|
|
|
|
|
/* ⬇️ kontainer lebih lebar & responsif, padding samping kecil */
|
|
.wrap {
|
|
display: grid;
|
|
grid-template-columns: clamp(300px, 26vw, 380px) 1fr;
|
|
gap: clamp(16px, 2vw, 24px);
|
|
width: min(1400px, calc(100vw - (var(--page-pad) * 2)));
|
|
margin: 0;
|
|
/* penting */
|
|
}
|
|
|
|
/* responsif */
|
|
@media (max-width:980px) {
|
|
.wrap {
|
|
grid-template-columns: 1fr;
|
|
gap: 16px;
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
.qrbox {
|
|
background: var(--soft);
|
|
border-radius: 24px;
|
|
padding: 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 420px;
|
|
box-shadow: 0 10px 30px rgba(16, 24, 40, .06)
|
|
}
|
|
|
|
.qrbox h1 {
|
|
color: #077a7d;
|
|
letter-spacing: .5px;
|
|
margin: 0 0 20px 0;
|
|
font-weight: 800;
|
|
font-size: 32px
|
|
}
|
|
|
|
.qrframe {
|
|
border: 2px solid #cfe9e9;
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
display: inline-block
|
|
}
|
|
|
|
#qrcode {
|
|
width: 220px;
|
|
height: 220px
|
|
}
|
|
|
|
.hint {
|
|
margin-top: 14px;
|
|
color: var(--muted)
|
|
}
|
|
|
|
.timer {
|
|
margin-top: 6px;
|
|
font-weight: 700;
|
|
color: #077a7d
|
|
}
|
|
|
|
.card {
|
|
background: var(--card);
|
|
border-radius: 20px;
|
|
box-shadow: 0 10px 30px rgba(16, 24, 40, .06);
|
|
overflow: hidden
|
|
}
|
|
|
|
.card-header {
|
|
padding: 18px 20px;
|
|
border-bottom: 1px solid var(--line)
|
|
}
|
|
|
|
.title {
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
margin: 0
|
|
}
|
|
|
|
.sub {
|
|
font-size: 12px;
|
|
color: var(--teal);
|
|
margin-top: 6px
|
|
}
|
|
|
|
.table-wrap {
|
|
width: 100%;
|
|
overflow: auto
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse
|
|
}
|
|
|
|
th,
|
|
td {
|
|
font-size: 14px;
|
|
text-align: left;
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--line);
|
|
vertical-align: middle
|
|
}
|
|
|
|
th {
|
|
color: #334155;
|
|
background: #fafbff;
|
|
font-weight: 600
|
|
}
|
|
|
|
/* .pill {
|
|
display: inline-block;
|
|
padding: 6px 12px;
|
|
border-radius: 999px;
|
|
font-size: 12px;
|
|
font-weight: 700
|
|
}
|
|
|
|
.pill.green {
|
|
background: #dcfce7;
|
|
color: #166534
|
|
}
|
|
|
|
.pill.red {
|
|
background: #fee2e2;
|
|
color: #991b1b
|
|
} */
|
|
|
|
/* Mobile */
|
|
@media (max-width:980px) {
|
|
.wrap {
|
|
grid-template-columns: 1fr;
|
|
gap: 16px;
|
|
max-width: calc(100vw - (var(--page-pad) * 2))
|
|
}
|
|
|
|
.qrbox {
|
|
min-height: auto
|
|
}
|
|
}
|
|
|
|
.card-header {
|
|
padding: 18px 20px;
|
|
border-bottom: 1px solid var(--line);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
/* judul kiri, tombol kanan */
|
|
align-items: center;
|
|
}
|
|
|
|
.btn-back {
|
|
background: var(--teal);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 8px;
|
|
padding: 6px 14px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.btn-back:hover {
|
|
background: #0c8c8c;
|
|
}
|
|
|
|
.header-datetime {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #0f172a;
|
|
text-align: right;
|
|
white-space: nowrap;
|
|
}
|
|
</style>
|
|
<!-- QRCode lib -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
|
<!-- Echo (IIFE) untuk Reverb -->
|
|
<script src="https://js.pusher.com/8.4.0/pusher.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/laravel-echo@1.16.1/dist/echo.iife.js"></script>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="page">
|
|
<div class="wrap">
|
|
<!-- KIRI -->
|
|
<section class="qrbox">
|
|
<h1>SELAMAT DATANG</h1>
|
|
<div class="qrframe">
|
|
<div id="qrcode"></div>
|
|
</div>
|
|
<div class="timer" id="timer">Silakan Scan Disini</div>
|
|
</section>
|
|
|
|
<!-- KANAN -->
|
|
<section class="card">
|
|
<div class="card-header">
|
|
<div>
|
|
<div class="title">Absensi Perangkat Desa</div>
|
|
<div class="sub">Anggota aktif</div>
|
|
</div>
|
|
<div class="header-datetime" id="header-datetime">Memuat waktu...</div>
|
|
{{-- <a href="{{ route('login') }}" class="btn-back">← Kembali</a> --}}
|
|
</div>
|
|
|
|
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Nama</th>
|
|
<th>Jabatan</th>
|
|
<th>Phone Number</th>
|
|
<th>Tanggal</th>
|
|
<th>Chek-In</th>
|
|
<th>Chek-Out</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody id="members-body">
|
|
@foreach ($members as $m)
|
|
@php
|
|
$s = strtolower(trim($m['status'] ?? ''));
|
|
|
|
switch ($s) {
|
|
case 'hadir':
|
|
$pillClass = 'badge bg-success-subtle text-success fw-semibold px-3 py-2';
|
|
$label = 'Hadir';
|
|
break;
|
|
|
|
case 'izin':
|
|
$pillClass = 'badge bg-warning-subtle text-warning fw-semibold px-3 py-2';
|
|
$label = 'Izin';
|
|
break;
|
|
|
|
case 'sakit':
|
|
$pillClass = 'badge bg-info-subtle text-info fw-semibold px-3 py-2';
|
|
$label = 'Sakit';
|
|
break;
|
|
|
|
case 'alpha':
|
|
$pillClass = 'badge bg-danger-subtle text-danger fw-semibold px-3 py-2';
|
|
$label = 'Alpha';
|
|
break;
|
|
|
|
default:
|
|
// kosong atau status tak dikenal
|
|
$pillClass =
|
|
'badge bg-secondary-subtle text-secondary fw-semibold px-3 py-2';
|
|
$label = '-';
|
|
}
|
|
@endphp
|
|
<tr>
|
|
<td>{{ $m['name'] }}</td>
|
|
<td>{{ $m['role'] }}</td>
|
|
<td>{{ $m['phone'] }}</td>
|
|
<td>{{ $m['date'] }}</td>
|
|
<td>{{ $m['checkin'] }}</td>
|
|
<td>{{ $m['checkout'] }}</td>
|
|
<td>{!! "<span class=\"{$pillClass}\">{$label}</span>" !!}</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="{{ asset('landing/assets/vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
|
|
<script>
|
|
(function() {
|
|
// --- elemen & state
|
|
const SESSION_ID = @json($sessionId);
|
|
const QR_EL = document.getElementById('qrcode');
|
|
const TIMER_EL = document.getElementById('timer');
|
|
const MEMBERS_TBODY = document.getElementById('members-body');
|
|
|
|
let lastExp = null;
|
|
let refreshTimerId = null;
|
|
let countdownTimerId = null;
|
|
let fetching = false;
|
|
let currentToken = null;
|
|
let headerClockTimerId = null;
|
|
|
|
// --- util UI QR & timer
|
|
function drawQR(token) {
|
|
if (!token || token === currentToken) return;
|
|
QR_EL.innerHTML = "";
|
|
new QRCode(QR_EL, {
|
|
text: token,
|
|
width: 220,
|
|
height: 220,
|
|
correctLevel: QRCode.CorrectLevel.M
|
|
});
|
|
currentToken = token;
|
|
console.log('[QR] New token drawn:', token);
|
|
}
|
|
|
|
function updateTimer(exp) {
|
|
if (!exp) return;
|
|
const remain = exp - Math.floor(Date.now() / 1000);
|
|
if (remain <= 0) {
|
|
TIMER_EL.textContent = "Silakan Scan Disini";
|
|
fetchToken().catch(console.error);
|
|
return;
|
|
}
|
|
TIMER_EL.textContent = "Silakan Scan Disini";
|
|
}
|
|
|
|
function scheduleRefresh(exp, leadSeconds) {
|
|
if (refreshTimerId) clearTimeout(refreshTimerId);
|
|
const ms = Math.max(500, (exp * 1000 - Date.now()) - (leadSeconds * 1000));
|
|
console.log('[QR] Scheduling refresh in', ms, 'ms');
|
|
refreshTimerId = setTimeout(() => {
|
|
console.log('[QR] Auto-refreshing token...');
|
|
fetchToken().catch(console.error);
|
|
}, ms);
|
|
}
|
|
|
|
function scheduleManualRetry(ms) {
|
|
if (refreshTimerId) clearTimeout(refreshTimerId);
|
|
refreshTimerId = setTimeout(() => {
|
|
fetchToken().catch(console.error);
|
|
}, ms);
|
|
}
|
|
|
|
function formatTime(value) {
|
|
if (!value) return '-';
|
|
// Terima epoch detik, epoch ms, atau ISO string
|
|
let d;
|
|
if (typeof value === 'number') {
|
|
d = new Date(value > 1e12 ? value : value * 1000);
|
|
} else {
|
|
d = new Date(value);
|
|
}
|
|
if (isNaN(d.getTime())) return '-';
|
|
return d.toLocaleTimeString('id-ID', {
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
function formatDateToday() {
|
|
const d = new Date();
|
|
return d.toLocaleDateString('id-ID', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric'
|
|
});
|
|
}
|
|
|
|
function pillFor(statusRaw) {
|
|
const status = (statusRaw || '').toLowerCase().trim();
|
|
switch (status) {
|
|
case 'hadir':
|
|
return '<span class="badge bg-success-subtle text-success fw-semibold px-3 py-2">Hadir</span>';
|
|
case 'izin':
|
|
return '<span class="badge bg-warning-subtle text-warning fw-semibold px-3 py-2">Izin</span>';
|
|
case 'sakit':
|
|
return '<span class="badge bg-info-subtle text-info fw-semibold px-3 py-2">Sakit</span>';
|
|
case 'alpha':
|
|
return '<span class="badge bg-danger-subtle text-danger fw-semibold px-3 py-2">Alpha</span>';
|
|
default:
|
|
return '<span class="badge bg-secondary-subtle text-secondary fw-semibold px-3 py-2">-</span>';
|
|
}
|
|
}
|
|
// --- ambil token awal / setiap rotate
|
|
async function fetchToken() {
|
|
if (fetching) return;
|
|
fetching = true;
|
|
try {
|
|
const url = `/admin/attendance/sessions/${SESSION_ID}/qrcode`;
|
|
const res = await fetch(url, {
|
|
cache: "no-store",
|
|
headers: {
|
|
'Cache-Control': 'no-cache',
|
|
'Pragma': 'no-cache'
|
|
}
|
|
});
|
|
if (!res.ok) {
|
|
console.warn('[QR] fetch non-200:', res.status);
|
|
TIMER_EL.textContent = 'Memuat token...';
|
|
scheduleManualRetry(3000);
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
if (!data || !data.token || !data.exp) {
|
|
console.warn('[QR] response kosong/invalid:', data);
|
|
scheduleManualRetry(3000);
|
|
return;
|
|
}
|
|
drawQR(data.token);
|
|
lastExp = data.exp;
|
|
updateTimer(lastExp);
|
|
scheduleRefresh(lastExp, 2);
|
|
console.log('[QR] Token fetched successfully:', {
|
|
token: data.token,
|
|
exp: data.exp,
|
|
ttl: data.ttl
|
|
});
|
|
} catch (e) {
|
|
console.error('[QR] fetch error:', e);
|
|
TIMER_EL.textContent = 'Gagal memuat token, mencoba lagi...';
|
|
scheduleManualRetry(3000);
|
|
} finally {
|
|
fetching = false;
|
|
}
|
|
}
|
|
async function fetchMembers() {
|
|
try {
|
|
const url = `/admin/attendance/sessions/${SESSION_ID}/members`;
|
|
const res = await fetch(url, {
|
|
cache: 'no-store'
|
|
});
|
|
if (!res.ok) {
|
|
console.warn('[TABLE] members non-200:', res.status);
|
|
return [];
|
|
}
|
|
const data = await res.json();
|
|
// dukung dua bentuk: array langsung, atau { data: [...] }
|
|
const rows = Array.isArray(data?.data) ? data.data : (Array.isArray(data) ? data : []);
|
|
return rows;
|
|
} catch (e) {
|
|
console.error('[TABLE] fetch members error:', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// ---------- Render ----------
|
|
function refreshMembers(rows) {
|
|
if (!MEMBERS_TBODY) return;
|
|
const today = formatDateToday();
|
|
|
|
const html = (rows || []).map(r => `
|
|
<tr>
|
|
<td>${r?.name ?? '-'}</td>
|
|
<td>${r?.role ?? '-'}</td>
|
|
<td>${r?.phone ?? '-'}</td>
|
|
<td>${today}</td>
|
|
<td>${formatTime(r?.check_in)}</td>
|
|
<td>${formatTime(r?.check_out)}</td>
|
|
<td>${pillFor(r?.status)}</td>
|
|
</tr>
|
|
`).join('');
|
|
|
|
MEMBERS_TBODY.innerHTML = html;
|
|
console.log('[TABLE] Updated', (rows || []).length, 'rows');
|
|
}
|
|
|
|
// --- Realtime via Reverb + Echo
|
|
function initializeEcho() {
|
|
try {
|
|
console.log('[Echo] init Reverb...');
|
|
// Reverb mengikuti protokol Pusher → pastikan Pusher ada di global
|
|
window.Pusher = window.Pusher || Pusher;
|
|
|
|
const echo = new window.Echo({
|
|
broadcaster: 'pusher',
|
|
key: "{{ env('PUSHER_APP_KEY') }}",
|
|
cluster: "{{ env('PUSHER_APP_CLUSTER', 'ap1') }}",
|
|
forceTLS: "{{ env('PUSHER_SCHEME', 'https') }}" === 'https',
|
|
});
|
|
|
|
window.echo = echo; // untuk debug
|
|
|
|
const channelName = 'attendance.session.' + SESSION_ID;
|
|
const ch = echo.channel(channelName);
|
|
|
|
ch.subscribed(() => console.log('[Echo] Subscribed:', channelName))
|
|
.error(err => console.error('[Echo] Channel error:', err))
|
|
.listen('.qr.token', async (e) => {
|
|
console.log('[Echo] Event received:', e);
|
|
if (e && e.token && e.exp) {
|
|
drawQR(e.token);
|
|
lastExp = e.exp;
|
|
updateTimer(lastExp);
|
|
scheduleRefresh(lastExp, 2);
|
|
}
|
|
const rows = await fetchMembers();
|
|
refreshMembers(rows); // segarkan tabel
|
|
});
|
|
} catch (e) {
|
|
console.warn('[QR] Echo initialization failed:', e);
|
|
}
|
|
}
|
|
|
|
// --- countdown UI per detik
|
|
function startCountdown() {
|
|
if (countdownTimerId) clearInterval(countdownTimerId);
|
|
countdownTimerId = setInterval(() => {
|
|
if (lastExp) updateTimer(lastExp);
|
|
}, 1000);
|
|
}
|
|
|
|
function updateHeaderDateTime() {
|
|
const el = document.getElementById('header-datetime');
|
|
if (!el) return;
|
|
const now = new Date();
|
|
el.textContent = now.toLocaleString('id-ID', {
|
|
weekday: 'long',
|
|
day: '2-digit',
|
|
month: 'long',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
});
|
|
}
|
|
|
|
function startHeaderClock() {
|
|
updateHeaderDateTime();
|
|
if (headerClockTimerId) clearInterval(headerClockTimerId);
|
|
headerClockTimerId = setInterval(updateHeaderDateTime, 1000);
|
|
}
|
|
|
|
// --- init halaman
|
|
async function init() {
|
|
startCountdown();
|
|
startHeaderClock();
|
|
initializeEcho();
|
|
fetchToken(); // <--- WAJIB: ambil token awal supaya QR muncul
|
|
// Render awal dari Blade (kalau ada), lalu sinkron dari API
|
|
const INITIAL_ROWS = @json($members ?? []);
|
|
if (Array.isArray(INITIAL_ROWS) && INITIAL_ROWS.length) {
|
|
refreshMembers(INITIAL_ROWS);
|
|
}
|
|
const rows = await fetchMembers();
|
|
if (rows.length) refreshMembers(rows);
|
|
}
|
|
|
|
init();
|
|
})();
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|