334 lines
14 KiB
JavaScript
334 lines
14 KiB
JavaScript
let html5QrCode = null;
|
|
let currentToken = null;
|
|
let availableCameras = [];
|
|
let autoResetTimer = null;
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
|
|
|
|
const qrReader = document.getElementById("qr-reader");
|
|
const cameraSelect = document.getElementById("camera-select");
|
|
const startScanBtn = document.getElementById("start-scan");
|
|
const stopScanBtn = document.getElementById("stop-scan");
|
|
const searchInput = document.getElementById("search-input");
|
|
const searchResults = document.getElementById("search-results");
|
|
const guestCard = document.getElementById("guest-card");
|
|
const guestPhoto = document.getElementById("guest-photo");
|
|
const guestName = document.getElementById("guest-name");
|
|
const guestDepartment = document.getElementById("guest-department");
|
|
const guestPosition = document.getElementById("guest-position");
|
|
const guestEmail = document.getElementById("guest-email");
|
|
const guestEvent = document.getElementById("guest-event");
|
|
const eventSelect = document.getElementById("event-select");
|
|
const checkInStatus = document.getElementById("check-in-status");
|
|
const checkInBtn = document.getElementById("check-in-btn");
|
|
const undoCheckInBtn = document.getElementById("undo-check-in-btn");
|
|
const resetBtn = document.getElementById("reset-btn");
|
|
|
|
function signalResult(success) {
|
|
if (navigator.vibrate) navigator.vibrate(success ? 180 : [100, 80, 100]);
|
|
try {
|
|
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
const context = new AudioContext();
|
|
const oscillator = context.createOscillator();
|
|
const gain = context.createGain();
|
|
oscillator.frequency.value = success ? 880 : 220;
|
|
gain.gain.setValueAtTime(0.12, context.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, context.currentTime + 0.2);
|
|
oscillator.connect(gain); gain.connect(context.destination);
|
|
oscillator.onended = () => context.close();
|
|
oscillator.start(); oscillator.stop(context.currentTime + 0.2);
|
|
} catch (_error) {}
|
|
}
|
|
|
|
function scheduleReset(delay = 8000) {
|
|
clearTimeout(autoResetTimer);
|
|
autoResetTimer = setTimeout(resetGuestCard, delay);
|
|
}
|
|
|
|
function resetGuestCard() {
|
|
clearTimeout(autoResetTimer);
|
|
guestCard.classList.add("d-none");
|
|
currentToken = null;
|
|
guestPhoto.src = "";
|
|
guestName.textContent = "";
|
|
guestDepartment.textContent = "";
|
|
guestPosition.textContent = "";
|
|
guestEmail.textContent = "";
|
|
const placeholder = guestPhoto.nextElementSibling;
|
|
if (placeholder && placeholder.classList.contains("guest-photo-placeholder")) placeholder.remove();
|
|
if (guestEvent) guestEvent.textContent = "";
|
|
checkInStatus.classList.add("d-none");
|
|
checkInStatus.className = "alert d-none";
|
|
checkInBtn.disabled = false;
|
|
checkInBtn.textContent = "Отметить вход";
|
|
undoCheckInBtn.classList.add("d-none");
|
|
}
|
|
|
|
function showGuest(guest) {
|
|
clearTimeout(autoResetTimer);
|
|
currentToken = guest.token;
|
|
guestCard.classList.remove("d-none");
|
|
|
|
if (guest.photo_url) {
|
|
guestPhoto.src = guest.photo_url;
|
|
guestPhoto.classList.remove("d-none");
|
|
const placeholder = guestPhoto.nextElementSibling;
|
|
if (placeholder && placeholder.classList.contains("guest-photo-placeholder")) placeholder.remove();
|
|
} else {
|
|
guestPhoto.src = "";
|
|
guestPhoto.classList.add("d-none");
|
|
// Show placeholder
|
|
if (!guestPhoto.nextElementSibling || !guestPhoto.nextElementSibling.classList.contains("guest-photo-placeholder")) {
|
|
const placeholder = document.createElement("div");
|
|
placeholder.className = "guest-photo-placeholder mx-auto mb-3";
|
|
placeholder.textContent = "Нет фото";
|
|
guestPhoto.parentNode.insertBefore(placeholder, guestPhoto.nextSibling);
|
|
}
|
|
}
|
|
|
|
guestName.textContent = guest.full_name;
|
|
guestDepartment.textContent = guest.department;
|
|
guestPosition.textContent = guest.position || "";
|
|
guestEmail.textContent = guest.email;
|
|
if (guestEvent) {
|
|
guestEvent.textContent = guest.event_name ? `Мероприятие: ${guest.event_name}` : "";
|
|
}
|
|
|
|
if (guest.checked_in) {
|
|
checkInStatus.classList.remove("d-none", "alert-success", "alert-warning");
|
|
checkInStatus.classList.add("alert-warning");
|
|
checkInStatus.textContent = "Гость уже отмечен как вошедший";
|
|
checkInBtn.disabled = true;
|
|
undoCheckInBtn.classList.remove("d-none");
|
|
} else {
|
|
checkInStatus.classList.add("d-none");
|
|
checkInBtn.disabled = false;
|
|
undoCheckInBtn.classList.add("d-none");
|
|
}
|
|
|
|
window.scrollTo({ top: guestCard.offsetTop - 20, behavior: "smooth" });
|
|
}
|
|
|
|
function loadGuestByToken(token) {
|
|
fetch(`/api/guest/${token}`)
|
|
.then(response => {
|
|
if (!response.ok) throw new Error("Гость не найден");
|
|
return response.json();
|
|
})
|
|
.then(guest => showGuest(guest))
|
|
.catch(error => {
|
|
alert(error.message);
|
|
});
|
|
}
|
|
|
|
// Camera selection
|
|
function populateCameras() {
|
|
Html5Qrcode.getCameras()
|
|
.then(cameras => {
|
|
availableCameras = cameras || [];
|
|
cameraSelect.innerHTML = '<option value="">-- Выберите камеру --</option>';
|
|
|
|
if (availableCameras.length === 0) {
|
|
cameraSelect.innerHTML += '<option value="" disabled>Камеры не найдены</option>';
|
|
return;
|
|
}
|
|
|
|
availableCameras.forEach((camera, index) => {
|
|
const option = document.createElement("option");
|
|
option.value = camera.id;
|
|
option.textContent = camera.label || `Камера ${index + 1}`;
|
|
cameraSelect.appendChild(option);
|
|
});
|
|
|
|
// Try to select back camera by default
|
|
const backCamera = availableCameras.find(c =>
|
|
c.label.toLowerCase().includes("back") ||
|
|
c.label.toLowerCase().includes("задняя") ||
|
|
c.label.toLowerCase().includes("environment")
|
|
);
|
|
if (backCamera) {
|
|
cameraSelect.value = backCamera.id;
|
|
} else if (availableCameras.length > 0) {
|
|
cameraSelect.value = availableCameras[availableCameras.length - 1].id;
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Ошибка получения списка камер:", err);
|
|
cameraSelect.innerHTML = '<option value="">Ошибка доступа к камерам</option>';
|
|
});
|
|
}
|
|
|
|
// QR Scanner
|
|
startScanBtn.addEventListener("click", () => {
|
|
const cameraId = cameraSelect.value;
|
|
if (!cameraId) {
|
|
alert("Сначала выберите камеру");
|
|
return;
|
|
}
|
|
|
|
html5QrCode = new Html5Qrcode("qr-reader");
|
|
html5QrCode.start(
|
|
cameraId,
|
|
{ fps: 10, qrbox: { width: 250, height: 250 } },
|
|
(decodedText) => {
|
|
html5QrCode.stop();
|
|
stopScanBtn.classList.add("d-none");
|
|
startScanBtn.classList.remove("d-none");
|
|
|
|
// Extract token from URL
|
|
try {
|
|
const url = new URL(decodedText);
|
|
const token = url.searchParams.get("token");
|
|
if (token) {
|
|
loadGuestByToken(token);
|
|
}
|
|
} catch (e) {
|
|
// If decodedText is not a URL, treat it as token
|
|
loadGuestByToken(decodedText);
|
|
}
|
|
},
|
|
(errorMessage) => {}
|
|
);
|
|
startScanBtn.classList.add("d-none");
|
|
stopScanBtn.classList.remove("d-none");
|
|
});
|
|
|
|
stopScanBtn.addEventListener("click", () => {
|
|
if (html5QrCode) {
|
|
html5QrCode.stop();
|
|
}
|
|
stopScanBtn.classList.add("d-none");
|
|
startScanBtn.classList.remove("d-none");
|
|
});
|
|
|
|
// Search by name
|
|
let searchTimeout = null;
|
|
searchInput.addEventListener("input", (e) => {
|
|
const query = e.target.value.trim();
|
|
searchResults.innerHTML = "";
|
|
|
|
if (query.length < 2) return;
|
|
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(() => {
|
|
const eventId = eventSelect ? eventSelect.value : "";
|
|
let url = `/api/guests/search?q=${encodeURIComponent(query)}`;
|
|
if (eventId) {
|
|
url += `&event_id=${encodeURIComponent(eventId)}`;
|
|
}
|
|
fetch(url)
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
return response.json().then(data => {
|
|
throw new Error(data.error || "Ошибка поиска");
|
|
});
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(guests => {
|
|
searchResults.innerHTML = "";
|
|
guests.forEach(guest => {
|
|
const item = document.createElement("button");
|
|
item.className = "list-group-item list-group-item-action";
|
|
const name = document.createElement("strong");
|
|
name.textContent = guest.full_name;
|
|
const department = document.createElement("small");
|
|
department.textContent = guest.department || "";
|
|
item.append(name, document.createElement("br"), department);
|
|
item.addEventListener("click", () => {
|
|
searchResults.innerHTML = "";
|
|
searchInput.value = "";
|
|
showGuest(guest);
|
|
});
|
|
searchResults.appendChild(item);
|
|
});
|
|
})
|
|
.catch(error => {
|
|
const item = document.createElement("div");
|
|
item.className = "list-group-item text-danger";
|
|
item.textContent = error.message;
|
|
searchResults.replaceChildren(item);
|
|
});
|
|
}, 300);
|
|
});
|
|
|
|
// Check-in button
|
|
checkInBtn.addEventListener("click", () => {
|
|
if (!currentToken) return;
|
|
|
|
checkInBtn.disabled = true;
|
|
checkInBtn.textContent = "Отметка...";
|
|
|
|
fetch(`/api/check-in/${currentToken}`, {
|
|
method: "POST",
|
|
headers: { "X-CSRFToken": csrfToken },
|
|
})
|
|
.then(response => response.json().then(data => ({ status: response.status, data })))
|
|
.then(({ status, data }) => {
|
|
checkInStatus.classList.remove("d-none", "alert-success", "alert-warning", "alert-danger");
|
|
if (data.success) {
|
|
checkInStatus.classList.add("alert-success");
|
|
checkInStatus.textContent = "Вход успешно отмечен!";
|
|
checkInBtn.textContent = "Вход отмечен";
|
|
undoCheckInBtn.classList.remove("d-none"); signalResult(true); scheduleReset();
|
|
} else if (status === 409) {
|
|
checkInStatus.classList.add("alert-warning");
|
|
checkInStatus.textContent = "Гость уже отмечен как вошедший";
|
|
checkInBtn.textContent = "Уже отмечен";
|
|
undoCheckInBtn.classList.remove("d-none"); signalResult(false); scheduleReset();
|
|
} else {
|
|
checkInStatus.classList.add("alert-danger");
|
|
checkInStatus.textContent = data.message || "Ошибка";
|
|
checkInBtn.disabled = false;
|
|
checkInBtn.textContent = "Отметить вход";
|
|
}
|
|
})
|
|
.catch(error => {
|
|
checkInStatus.classList.remove("d-none", "alert-success", "alert-warning", "alert-danger");
|
|
checkInStatus.classList.add("alert-danger");
|
|
checkInStatus.textContent = "Ошибка сети: " + error.message;
|
|
checkInBtn.disabled = false;
|
|
checkInBtn.textContent = "Отметить вход";
|
|
});
|
|
});
|
|
|
|
undoCheckInBtn.addEventListener("click", () => {
|
|
if (!currentToken) return;
|
|
clearTimeout(autoResetTimer);
|
|
const reason = window.prompt("Укажите причину отмены отметки входа:");
|
|
if (!reason || reason.trim().length < 3) return;
|
|
undoCheckInBtn.disabled = true;
|
|
fetch(`/api/check-in/${currentToken}/undo`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json", "X-CSRFToken": csrfToken},
|
|
body: JSON.stringify({reason: reason.trim()}),
|
|
})
|
|
.then(response => response.json().then(data => ({status: response.status, data})))
|
|
.then(({status, data}) => {
|
|
checkInStatus.classList.remove("d-none", "alert-success", "alert-warning", "alert-danger");
|
|
if (status === 200 && data.success) {
|
|
checkInStatus.classList.add("alert-warning");
|
|
checkInStatus.textContent = "Отметка входа отменена";
|
|
checkInBtn.disabled = false; checkInBtn.textContent = "Отметить вход";
|
|
undoCheckInBtn.classList.add("d-none");
|
|
} else {
|
|
checkInStatus.classList.add("alert-danger");
|
|
checkInStatus.textContent = data.message || "Не удалось отменить отметку";
|
|
}
|
|
})
|
|
.catch(error => { checkInStatus.className = "alert alert-danger"; checkInStatus.textContent = "Ошибка сети: " + error.message; })
|
|
.finally(() => { undoCheckInBtn.disabled = false; });
|
|
});
|
|
|
|
resetBtn.addEventListener("click", resetGuestCard);
|
|
|
|
// If token is in URL (scanned via external scanner)
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
populateCameras();
|
|
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const token = urlParams.get("token");
|
|
if (token) {
|
|
loadGuestByToken(token);
|
|
}
|
|
});
|