Files
hotel777-manager/public/index.html
2026-05-04 21:09:42 +05:00

353 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru" data-title="Заявки на заселение" data-description="Управление заявками гостиницы">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
<title>Заявки на заселение</title>
</head>
<body>
<header></header>
<div class="toast-container" id="toastContainer"></div>
<main>
<h1>Заявки на заселение</h1>
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<div class="stat-value" id="statTotal">0</div>
<div class="stat-label">Всего</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statNew">0</div>
<div class="stat-label">Новые</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statInProgress">0</div>
<div class="stat-label">В работе</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statConfirmed">0</div>
<div class="stat-label">Подтверждены</div>
</div>
</div>
<div class="filter-bar">
<div class="search-input-wrapper">
<input type="text" id="searchInput" placeholder="Поиск по имени, телефону или комментарию">
</div>
<select id="statusFilter">
<option value="">Все статусы</option>
<option value="Новая">Новая</option>
<option value="В работе">В работе</option>
<option value="Подтверждена">Подтверждена</option>
<option value="Заселение">Заселение</option>
<option value="Завершена">Завершена</option>
<option value="Отменена">Отменена</option>
</select>
</div>
<div id="tableContainer" class="table-container">
<table id="bookingsTable">
<thead>
<tr>
<th>ID</th>
<th>Имя гостя</th>
<th>Телефон</th>
<th>Даты проживания</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="bookingsBody"></tbody>
</table>
</div>
<div id="bookingCards" class="booking-cards"></div>
<div id="emptyState" class="empty-state" style="display: none;">
<div class="empty-state-icon">
<svg width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
</div>
<h3>Заявки не найдены</h3>
<p>Попробуйте изменить параметры поиска</p>
</div>
<div id="editModal" class="modal">
<div class="modal-content">
<h2>Редактировать заявку</h2>
<form id="editForm">
<input type="hidden" id="editId">
<div class="form-group">
<label for="editStatus">Статус заявки</label>
<select id="editStatus">
<option value="Новая">Новая</option>
<option value="В работе">В работе</option>
<option value="Подтверждена">Подтверждена</option>
<option value="Заселение">Заселение</option>
<option value="Завершена">Завершена</option>
<option value="Отменена">Отменена</option>
</select>
</div>
<div class="form-group">
<label for="editComments">Комментарий</label>
<textarea id="editComments" rows="3" placeholder="Добавьте комментарий к заявке..."></textarea>
</div>
<div class="form-group">
<label for="editPhone">Переназначить на клиента (по телефону)</label>
<input type="text" id="editPhone" placeholder="Введите номер телефона">
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button type="submit" id="saveBtn" style="flex:1;">Сохранить</button>
<button type="button" id="closeModal" class="btn-secondary">Отмена</button>
</div>
</form>
</div>
</div>
</main>
<script src="nav.js"></script>
<script src="seo.js"></script>
<script>
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function getStatusClass(status) {
const map = {
'Новая': 'status-new',
'В работе': 'status-in-progress',
'Подтверждена': 'status-confirmed',
'Заселение': 'status-checkin',
'Завершена': 'status-completed',
'Отменена': 'status-cancelled'
};
return map[status] || 'status-new';
}
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `<span>${escapeHtml(message)}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-exit');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
async function getCsrfToken() {
const stored = window.localStorage.getItem('csrfToken');
if (stored) return stored;
try {
const res = await fetch('/api/csrf-token');
const data = await res.json();
window.localStorage.setItem('csrfToken', data.csrfToken);
return data.csrfToken;
} catch (e) {
return null;
}
}
fetch('/api/me').then(r => r.json()).then(data => {
if (!data.isAdmin) window.location.href = '/login.html';
});
const tableBody = document.getElementById('bookingsBody');
const bookingCards = document.getElementById('bookingCards');
const modal = document.getElementById('editModal');
const emptyState = document.getElementById('emptyState');
const tableContainer = document.getElementById('tableContainer');
let currentBookings = [];
function updateStats(bookings) {
const total = bookings.length;
const stats = { 'Новая': 0, 'В работе': 0, 'Подтверждена': 0 };
bookings.forEach(b => {
if (stats[b.status] !== undefined) stats[b.status]++;
});
animateValue('statTotal', total);
animateValue('statNew', stats['Новая']);
animateValue('statInProgress', stats['В работе']);
animateValue('statConfirmed', stats['Подтверждена']);
}
function animateValue(id, value) {
const el = document.getElementById(id);
const start = parseInt(el.textContent) || 0;
const duration = 500;
const startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
el.textContent = Math.round(start + (value - start) * progress);
if (progress < 1) requestAnimationFrame(update);
}
requestAnimationFrame(update);
}
async function loadBookings() {
const status = document.getElementById('statusFilter').value;
const search = document.getElementById('searchInput').value;
let url = '/api/bookings?';
if (status) url += `status=${encodeURIComponent(status)}&`;
if (search) url += `search=${encodeURIComponent(search)}&`;
tableBody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></td></tr>';
bookingCards.innerHTML = '<div style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></div>';
try {
const res = await fetch(url);
if (!res.ok) throw new Error('Ошибка загрузки');
currentBookings = await res.json();
updateStats(currentBookings);
renderTable();
renderCards();
} catch (err) {
showToast('Ошибка загрузки заявок', 'error');
tableBody.innerHTML = '';
bookingCards.innerHTML = '';
}
}
function renderTable() {
tableBody.innerHTML = '';
if (currentBookings.length === 0) {
tableContainer.style.display = 'none';
bookingCards.innerHTML = '';
emptyState.style.display = 'block';
return;
}
tableContainer.style.display = 'block';
emptyState.style.display = 'none';
currentBookings.forEach((b, i) => {
const row = document.createElement('tr');
row.style.animationDelay = `${i * 0.03}s`;
row.innerHTML = `
<td>${escapeHtml(b.external_id) || '—'}</td>
<td><strong>${escapeHtml(b.name)}</strong></td>
<td>${escapeHtml(b.phone_raw)}</td>
<td>${escapeHtml(b.checkin_date)} ${escapeHtml(b.checkout_date)}</td>
<td><span class="status-badge ${getStatusClass(b.status)}">${escapeHtml(b.status)}</span></td>
<td><button class="editBtn btn-secondary btn-sm" data-id="${b.id}">Изменить</button></td>
`;
tableBody.appendChild(row);
});
}
function renderCards() {
bookingCards.innerHTML = '';
if (currentBookings.length === 0) return;
emptyState.style.display = 'none';
currentBookings.forEach((b, i) => {
const card = document.createElement('div');
card.className = 'booking-card';
card.style.animationDelay = `${i * 0.03}s`;
card.innerHTML = `
<div class="booking-card-header">
<div>
<div class="booking-card-name">${escapeHtml(b.name)}</div>
<div class="booking-card-id">ID: ${escapeHtml(b.external_id) || '—'}</div>
</div>
<span class="status-badge ${getStatusClass(b.status)}">${escapeHtml(b.status)}</span>
</div>
<div class="booking-card-body">
<div class="booking-card-field">
<span class="booking-card-label">Телефон</span>
<span class="booking-card-value">${escapeHtml(b.phone_raw)}</span>
</div>
<div class="booking-card-field">
<span class="booking-card-label">Даты</span>
<span class="booking-card-value">${escapeHtml(b.checkin_date)} ${escapeHtml(b.checkout_date)}</span>
</div>
</div>
<div class="booking-card-footer">
<button class="editBtn btn-secondary btn-sm" data-id="${b.id}">Редактировать</button>
</div>
`;
bookingCards.appendChild(card);
});
}
document.getElementById('statusFilter').addEventListener('change', loadBookings);
let searchTimeout;
document.getElementById('searchInput').addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(loadBookings, 400);
});
document.getElementById('searchInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') { clearTimeout(searchTimeout); loadBookings(); }
});
document.addEventListener('click', (e) => {
if (e.target.classList.contains('editBtn')) {
const id = e.target.dataset.id;
const booking = currentBookings.find(b => b.id == id);
if (!booking) return;
document.getElementById('editId').value = booking.id;
document.getElementById('editStatus').value = booking.status;
document.getElementById('editComments').value = booking.comments || '';
document.getElementById('editPhone').value = '';
modal.classList.add('active');
}
});
document.getElementById('closeModal').addEventListener('click', () => modal.classList.remove('active'));
modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('active'); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.classList.contains('active')) modal.classList.remove('active'); });
document.getElementById('editForm').addEventListener('submit', async (e) => {
e.preventDefault();
const saveBtn = document.getElementById('saveBtn');
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="loading-spinner"></span>';
const id = document.getElementById('editId').value;
const status = document.getElementById('editStatus').value;
const comments = document.getElementById('editComments').value;
const phone = document.getElementById('editPhone').value.trim();
const body = { comments };
if (status) body.status = status;
if (phone) body.phone = phone;
const csrfToken = await getCsrfToken();
try {
const res = await fetch(`/api/bookings/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
body: JSON.stringify(body)
});
if (res.ok) {
modal.classList.remove('active');
showToast('Заявка обновлена', 'success');
loadBookings();
} else {
const err = await res.json();
showToast(err.error || 'Ошибка сохранения', 'error');
}
} catch (err) {
showToast('Ошибка соединения', 'error');
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = 'Сохранить';
}
});
loadBookings();
</script>
</body>
</html>