329 lines
13 KiB
HTML
329 lines
13 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>
|
||
<style>
|
||
.booking-cards { display: none; }
|
||
|
||
.booking-card {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 1.25rem;
|
||
margin-bottom: 0.75rem;
|
||
box-shadow: var(--shadow-sm);
|
||
transition: all var(--transition-base);
|
||
animation: cardSlideIn 0.3s ease-out backwards;
|
||
}
|
||
|
||
.booking-card:hover { box-shadow: var(--shadow-md); }
|
||
|
||
.booking-card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.booking-card-status-line {
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.booking-card-body {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.booking-card-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.125rem;
|
||
}
|
||
|
||
.booking-card-label {
|
||
font-size: 0.6875rem;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.booking-card-value {
|
||
font-size: 0.875rem;
|
||
color: var(--text-primary);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.booking-card-comment {
|
||
margin-top: 0.75rem;
|
||
padding-top: 0.75rem;
|
||
border-top: 1px solid var(--border-light);
|
||
font-size: 0.875rem;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
@keyframes cardSlideIn {
|
||
from { opacity: 0; transform: translateY(12px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.table-container { display: none !important; }
|
||
.booking-cards { display: block; }
|
||
|
||
.booking-card-body {
|
||
grid-template-columns: 1fr;
|
||
gap: 0.5rem;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header></header>
|
||
|
||
<div class="toast-container" id="toastContainer"></div>
|
||
|
||
<main>
|
||
<a href="clients.html" class="btn btn-secondary btn-sm" style="display:inline-flex;align-items:center;gap:0.375rem;margin-bottom:1.5rem;text-decoration:none;">
|
||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
|
||
Назад
|
||
</a>
|
||
|
||
<h1>Профиль клиента</h1>
|
||
|
||
<div id="clientInfo" class="card" style="margin-bottom:2rem;"></div>
|
||
<div id="editNameRow" class="card" style="display:none; margin-bottom: 1rem;">
|
||
<div class="form-group" style="display:flex;gap:0.5rem;align-items:center;">
|
||
<label for="editNameInput" style="min-width:120px;">Имя клиента</label>
|
||
<input id="editNameInput" type="text" value="" style="flex:1;">
|
||
</div>
|
||
<div style="display:flex;gap:0.75rem;justify-content:flex-end; margin-top:0.5rem;">
|
||
<button id="saveNameBtn" class="btn-secondary">Сохранить</button>
|
||
<button id="cancelEditNameBtn" class="btn-secondary">Отмена</button>
|
||
</div>
|
||
</div>
|
||
|
||
<h2>История заявок</h2>
|
||
|
||
<div class="table-container">
|
||
<table id="clientBookingsTable">
|
||
<thead>
|
||
<tr>
|
||
<th>ID</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.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.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>
|
||
</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);
|
||
}
|
||
|
||
fetch('/api/me').then(r => r.json()).then(data => {
|
||
if (!data.isAdmin) window.location.href = '/login.html';
|
||
});
|
||
|
||
const params = new URLSearchParams(window.location.search);
|
||
const clientId = params.get('id');
|
||
|
||
if (!clientId) {
|
||
document.querySelector('main').innerHTML = '<h1>Не указан ID клиента</h1><a href="clients.html" class="btn">Вернуться к списку</a>';
|
||
throw new Error('No id');
|
||
}
|
||
|
||
async function loadClient() {
|
||
const clientInfoEl = document.getElementById('clientInfo');
|
||
const tbody = document.getElementById('bookingsBody');
|
||
const bookingCards = document.getElementById('bookingCards');
|
||
const emptyState = document.getElementById('emptyState');
|
||
const tableContainer = document.querySelector('#clientBookingsTable').parentElement;
|
||
|
||
clientInfoEl.innerHTML = '<div class="loading-spinner"></div>';
|
||
tbody.innerHTML = '<tr><td colspan="5" 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(`/api/clients/${clientId}`);
|
||
if (!res.ok) {
|
||
clientInfoEl.innerHTML = '<p style="color:var(--danger);">Клиент не найден</p>';
|
||
tbody.innerHTML = '';
|
||
bookingCards.innerHTML = '';
|
||
tableContainer.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
clientInfoEl.innerHTML = `
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1.5rem;">
|
||
<div>
|
||
<div style="color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;font-weight:600;margin-bottom:0.25rem;">Телефон</div>
|
||
<div style="font-size:1.0625rem;font-weight:600;color:var(--text-primary);">${escapeHtml(data.client.phone)}</div>
|
||
</div>
|
||
<div>
|
||
<div style="color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;font-weight:600;margin-bottom:0.25rem;">Имя</div>
|
||
<div style="font-size:1.0625rem;font-weight:600;color:var(--text-primary);"><span data-current-name>${escapeHtml(data.client.name) || '<span style="color:var(--text-muted);">не указано</span>'}</span></div>
|
||
</div>
|
||
<div>
|
||
<div style="color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;font-weight:600;margin-bottom:0.25rem;">Заявок</div>
|
||
<div style="font-size:1.0625rem;font-weight:600;color:var(--text-primary);">${data.bookings.length}</div>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:0.5rem;">
|
||
<button id="startEditName" class="btn-secondary">Изменить имя</button>
|
||
</div>
|
||
`;
|
||
// Pre-fill edit field if present after rendering
|
||
const editNameInput = document.getElementById('editNameInput');
|
||
if (editNameInput) {
|
||
editNameInput.value = data.client.name || '';
|
||
}
|
||
|
||
tbody.innerHTML = '';
|
||
bookingCards.innerHTML = '';
|
||
|
||
if (data.bookings.length === 0) {
|
||
tableContainer.style.display = 'none';
|
||
emptyState.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
tableContainer.style.display = 'block';
|
||
emptyState.style.display = 'none';
|
||
|
||
data.bookings.forEach((b, i) => {
|
||
const tr = document.createElement('tr');
|
||
tr.style.animationDelay = `${i * 0.03}s`;
|
||
tr.innerHTML = `
|
||
<td>${escapeHtml(b.external_id) || '—'}</td>
|
||
<td>${escapeHtml(b.name)}</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>${escapeHtml(b.comments) || '<span style="color:var(--text-muted);">—</span>'}</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
|
||
const card = document.createElement('div');
|
||
card.className = 'booking-card';
|
||
card.style.animationDelay = `${i * 0.03}s`;
|
||
let html = `
|
||
<div class="booking-card-header">
|
||
<div>
|
||
<div style="font-weight:600;color:var(--text-primary);">${escapeHtml(b.name)}</div>
|
||
<div style="font-size:0.75rem;color:var(--text-muted);">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.checkin_date)} – ${escapeHtml(b.checkout_date)}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
if (b.comments) {
|
||
html += `<div class="booking-card-comment">${escapeHtml(b.comments)}</div>`;
|
||
}
|
||
card.innerHTML = html;
|
||
bookingCards.appendChild(card);
|
||
});
|
||
} catch (err) {
|
||
showToast('Ошибка загрузки', 'error');
|
||
clientInfoEl.innerHTML = '';
|
||
tbody.innerHTML = '';
|
||
bookingCards.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
loadClient();
|
||
// Name editing UI for client
|
||
document.addEventListener('click', function(e) {
|
||
if (e.target && e.target.id === 'startEditName') {
|
||
document.getElementById('editNameRow').style.display = 'block';
|
||
// Pre-fill with current name from display
|
||
const nameEl = document.querySelector('#clientInfo [data-current-name]');
|
||
const currentName = nameEl ? nameEl.textContent.trim() : '';
|
||
document.getElementById('editNameInput').value = currentName;
|
||
}
|
||
});
|
||
|
||
document.getElementById('cancelEditNameBtn')?.addEventListener('click', () => {
|
||
document.getElementById('editNameRow').style.display = 'none';
|
||
});
|
||
|
||
document.getElementById('saveNameBtn')?.addEventListener('click', async (ev) => {
|
||
ev.preventDefault();
|
||
const newName = document.getElementById('editNameInput').value.trim();
|
||
if (newName.length === 0) return;
|
||
try {
|
||
const csrfToken = await getCsrfToken();
|
||
const res = await fetch(`/api/clients/${clientId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
|
||
body: JSON.stringify({ name: newName })
|
||
});
|
||
if (res.ok) {
|
||
document.getElementById('editNameRow').style.display = 'none';
|
||
loadClient();
|
||
} else {
|
||
const err = await res.json();
|
||
showToast(err.error || 'Ошибка обновления', 'error');
|
||
}
|
||
} catch (err) {
|
||
showToast('Ошибка соединения', 'error');
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|