sidakpelem/resources/views/qrcode/qr-page.blade.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>