353 lines
14 KiB
HTML
353 lines
14 KiB
HTML
<!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>
|