commit 08.01
This commit is contained in:
@@ -10,7 +10,6 @@ use Illuminate\Support\Str;
|
||||
|
||||
class BookingsController extends Controller
|
||||
{
|
||||
// POST api/bookings - создание брони (ТОЛЬКО клиенты)
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
@@ -25,7 +24,6 @@ class BookingsController extends Controller
|
||||
return response()->json(['error' => 'Авторизация обязательна'], 401);
|
||||
}
|
||||
|
||||
// Проверить активную услугу
|
||||
$service = Services::where('id', $request->service_id)
|
||||
->where('isactive', true)
|
||||
->first();
|
||||
@@ -36,7 +34,6 @@ class BookingsController extends Controller
|
||||
$durationMinutes = $service->durationminutes;
|
||||
$endtime = date('H:i:s', strtotime($request->starttime . " +{$durationMinutes} minutes"));
|
||||
|
||||
// Проверить доступность сотрудника
|
||||
$availability = EmployeeAvailability::where('employee_id', $request->employee_id)
|
||||
->where('date', $request->date)
|
||||
->where('starttime', '<=', $request->starttime)
|
||||
@@ -48,7 +45,6 @@ class BookingsController extends Controller
|
||||
return response()->json(['error' => 'Сотрудник недоступен в это время'], 400);
|
||||
}
|
||||
|
||||
// Проверить уникальность слота
|
||||
$bookingExists = Booking::where('employee_id', $request->employee_id)
|
||||
->where('bookingdate', $request->date)
|
||||
->where('starttime', $request->starttime)
|
||||
@@ -59,7 +55,6 @@ class BookingsController extends Controller
|
||||
return response()->json(['error' => 'Слот уже забронирован'], 400);
|
||||
}
|
||||
|
||||
// Создать бронь
|
||||
$bookingNumber = 'CL-' . date('Y') . '-' . str_pad(Booking::count() + 1, 4, '0', STR_PAD_LEFT);
|
||||
|
||||
$booking = Booking::create([
|
||||
@@ -79,17 +74,14 @@ class BookingsController extends Controller
|
||||
], 201);
|
||||
}
|
||||
|
||||
// POST api/bookings/{id}/cancel - отмена клиентом
|
||||
public function cancel(Request $request, $id)
|
||||
{
|
||||
$booking = Booking::findOrFail($id);
|
||||
|
||||
// Только автор брони может отменить
|
||||
if ($booking->client_id != auth()->id()) {
|
||||
return response()->json(['error' => 'Можете отменить только свою бронь'], 403);
|
||||
}
|
||||
|
||||
// Только confirmed брони
|
||||
if ($booking->status != 'confirmed') {
|
||||
return response()->json(['error' => 'Можно отменить только подтвержденные'], 400);
|
||||
}
|
||||
@@ -105,4 +97,70 @@ class BookingsController extends Controller
|
||||
'booking' => $booking
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function adminCancel(Request $request, $id)
|
||||
{
|
||||
$booking = Booking::findOrFail($id);
|
||||
|
||||
if (!auth()->user()->isEmployeeOrAdmin()) {
|
||||
return response()->json(['error' => 'Доступ только для админов/сотрудников'], 403);
|
||||
}
|
||||
|
||||
$booking->update([
|
||||
'status' => 'cancelled',
|
||||
'cancelledby' => 'admin',
|
||||
'cancelreason' => $request->reason ?? null
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Бронь отменена администратором',
|
||||
'booking' => $booking
|
||||
]);
|
||||
}
|
||||
|
||||
public function clientIndex(Request $request)
|
||||
{
|
||||
$clientId = auth()->id();
|
||||
|
||||
$query = Booking::where('client_id', $clientId);
|
||||
|
||||
if ($request->date) {
|
||||
$query->where('bookingdate', $request->date);
|
||||
}
|
||||
if ($request->status) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$bookings = $query->with(['service', 'employee'])
|
||||
->orderBy('bookingdate', 'desc')
|
||||
->orderBy('starttime', 'asc')
|
||||
->get();
|
||||
|
||||
return response()->json($bookings);
|
||||
}
|
||||
|
||||
public function adminIndex(Request $request)
|
||||
{
|
||||
if (!auth()->user()->isEmployeeOrAdmin()) {
|
||||
return response()->json(['error' => 'Доступ запрещен'], 403);
|
||||
}
|
||||
|
||||
$query = Booking::with(['service', 'client', 'employee']);
|
||||
|
||||
if ($request->date) {
|
||||
$query->where('bookingdate', $request->date);
|
||||
}
|
||||
if ($request->status) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
if ($request->employee_id) {
|
||||
$query->where('employee_id', $request->employee_id);
|
||||
}
|
||||
|
||||
$bookings = $query->orderBy('bookingdate', 'desc')
|
||||
->orderBy('starttime', 'asc')
|
||||
->get();
|
||||
|
||||
return response()->json($bookings);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
// бронирование ĸлиентов
|
||||
// бронирование клиентов
|
||||
class Booking extends Model {
|
||||
use HasFactory;
|
||||
|
||||
@@ -20,4 +20,20 @@ class Booking extends Model {
|
||||
'cancelledby',
|
||||
'cancelreason'
|
||||
];
|
||||
|
||||
// списки броней
|
||||
public function service()
|
||||
{
|
||||
return $this->belongsTo(Services::class);
|
||||
}
|
||||
|
||||
public function client()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'client_id');
|
||||
}
|
||||
|
||||
public function employee()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'employee_id');
|
||||
}
|
||||
}
|
||||
|
||||
1788
composer-installer.php
Normal file
1788
composer-installer.php
Normal file
File diff suppressed because it is too large
Load Diff
1027
composer.lock
generated
1027
composer.lock
generated
File diff suppressed because it is too large
Load Diff
413
public/admin-bookings.html
Normal file
413
public/admin-bookings.html
Normal file
@@ -0,0 +1,413 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Все брони - КлинСервис Админка</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: Arial, sans-serif; background: #f8f9fa; }
|
||||
|
||||
/* Хедер */
|
||||
header {
|
||||
background: #2c3e50; color: white; padding: 20px 50px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1); position: sticky; top: 0;
|
||||
}
|
||||
.header-content {
|
||||
max-width: 1400px; margin: 0 auto;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.logo { font-size: 24px; font-weight: bold; }
|
||||
.header-nav { display: flex; gap: 20px; }
|
||||
.btn {
|
||||
padding: 12px 25px; border: none; border-radius: 25px;
|
||||
cursor: pointer; font-size: 16px; text-decoration: none;
|
||||
display: inline-block; transition: all 0.3s; color: white;
|
||||
}
|
||||
.btn-primary { background: #667eea; }
|
||||
.btn-primary:hover { background: #5a67d8; }
|
||||
.btn-secondary { background: #6c757d; }
|
||||
.btn-secondary:hover { background: #5a6268; }
|
||||
|
||||
/* Контейнер */
|
||||
.container { max-width: 1400px; margin: 40px auto; padding: 0 20px; }
|
||||
h1 { text-align: center; color: #333; margin-bottom: 40px; font-size: 32px; }
|
||||
|
||||
/* Фильтры */
|
||||
.filters {
|
||||
background: white; padding: 30px; border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1); margin-bottom: 30px;
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
@media (max-width: 768px) { .filters { grid-template-columns: 1fr; } }
|
||||
|
||||
.filter-group label { display: block; margin-bottom: 8px; font-weight: bold; color: #555; }
|
||||
.filter-group input, .filter-group select {
|
||||
width: 100%; padding: 12px; border: 2px solid #e9ecef;
|
||||
border-radius: 10px; font-size: 16px;
|
||||
}
|
||||
.filter-group input:focus, .filter-group select:focus { border-color: #667eea; outline: none; }
|
||||
|
||||
.filter-btn {
|
||||
padding: 15px 30px; background: #667eea; color: white;
|
||||
border: none; border-radius: 10px; font-size: 16px;
|
||||
cursor: pointer; grid-column: span 2;
|
||||
}
|
||||
.filter-btn:hover { background: #5a67d8; }
|
||||
|
||||
/* Таблица */
|
||||
.bookings-table {
|
||||
background: white; border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1); overflow: hidden;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 18px 15px; text-align: left; border-bottom: 1px solid #e9ecef; }
|
||||
th { background: #667eea; color: white; font-weight: bold; position: sticky; top: 0; }
|
||||
tr:hover { background: #f8f9fa; }
|
||||
|
||||
/* Статусы */
|
||||
.status { padding: 6px 12px; border-radius: 15px; font-size: 14px; font-weight: bold; }
|
||||
.status.confirmed { background: #d4edda; color: #155724; }
|
||||
.status.completed { background: #d1ecf1; color: #0c5460; }
|
||||
.status.cancelled { background: #f8d7da; color: #721c24; }
|
||||
|
||||
/* Действия */
|
||||
.action-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.btn-small {
|
||||
padding: 8px 14px; font-size: 13px; border-radius: 12px;
|
||||
text-decoration: none; border: none; cursor: pointer; font-weight: 500;
|
||||
}
|
||||
.btn-complete { background: #28a745; color: white; }
|
||||
.btn-complete:hover { background: #218838; }
|
||||
.btn-cancel-admin { background: #dc3545; color: white; }
|
||||
.btn-cancel-admin:hover { background: #c82333; }
|
||||
|
||||
/* Модальное окно */
|
||||
.modal {
|
||||
display: none; position: fixed; top: 0; left: 0;
|
||||
width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000;
|
||||
}
|
||||
.modal-content {
|
||||
background: white; margin: 10% auto; padding: 40px;
|
||||
border-radius: 20px; max-width: 500px; width: 90%; position: relative;
|
||||
}
|
||||
.close {
|
||||
position: absolute; top: 15px; right: 20px; font-size: 28px;
|
||||
cursor: pointer; color: #999;
|
||||
}
|
||||
.close:hover { color: #333; }
|
||||
.modal textarea {
|
||||
width: 100%; height: 100px; padding: 15px;
|
||||
border: 2px solid #e9ecef; border-radius: 10px;
|
||||
font-family: Arial, sans-serif; resize: vertical; margin-bottom: 20px;
|
||||
}
|
||||
.modal-buttons { display: flex; gap: 15px; }
|
||||
.btn-modal { flex: 1; padding: 15px; border: none; border-radius: 10px; font-size: 16px; cursor: pointer; }
|
||||
.btn-confirm { background: #dc3545; color: white; }
|
||||
.btn-confirm:hover { background: #c82333; }
|
||||
|
||||
.no-bookings { text-align: center; padding: 80px 40px; color: #666; font-size: 18px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
th, td { padding: 12px 8px; font-size: 14px; }
|
||||
.action-buttons { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Хедер -->
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<div class="logo">🧹 КлинСервис - Админка</div>
|
||||
<div class="header-nav">
|
||||
<a href="admin-services.html" class="btn btn-secondary">Услуги</a>
|
||||
<a href="admin-schedule.html" class="btn btn-secondary">Расписание</a>
|
||||
<a href="index.html" class="btn btn-primary">На главную</a>
|
||||
<button class="btn btn-secondary" onclick="logout()">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<h1>📋 Все бронирования</h1>
|
||||
|
||||
<!-- Фильтры -->
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<label>Дата:</label>
|
||||
<input type="date" id="filterDate">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Статус:</label>
|
||||
<select id="filterStatus">
|
||||
<option value="">Все</option>
|
||||
<option value="confirmed">Подтверждена</option>
|
||||
<option value="completed">Выполнена</option>
|
||||
<option value="cancelled">Отменена</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Сотрудник:</label>
|
||||
<select id="filterEmployee">
|
||||
<option value="">Все сотрудники</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Клиент (email):</label>
|
||||
<input type="email" id="filterClient" placeholder="ivan@example.com">
|
||||
</div>
|
||||
<button class="filter-btn" onclick="applyFilters()">🔍 Применить фильтры</button>
|
||||
<button class="filter-btn" onclick="clearFilters()" style="background: #6c757d;">Очистить</button>
|
||||
</div>
|
||||
|
||||
<!-- Таблица броней -->
|
||||
<div class="bookings-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Код брони</th>
|
||||
<th>Клиент (email)</th>
|
||||
<th>Услуга</th>
|
||||
<th>Сотрудник</th>
|
||||
<th>Дата и время</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bookingsTable">
|
||||
<tr>
|
||||
<td colspan="7" class="no-bookings">Загрузка броней...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно отмены -->
|
||||
<div id="cancelModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeCancelModal()">×</span>
|
||||
<h3>Отменить бронь администратором?</h3>
|
||||
<p id="cancelBookingInfo"></p>
|
||||
<p>Укажите причину отмены (необязательно):</p>
|
||||
<textarea id="cancelReason" placeholder="Сотрудник заболел, клиент не вышел на связь..."></textarea>
|
||||
<div class="modal-buttons">
|
||||
<button class="btn-modal" onclick="closeCancelModal()">Отмена</button>
|
||||
<button class="btn-modal btn-confirm" id="confirmCancelBtn" onclick="adminCancelBooking()">Отменить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let token = localStorage.getItem('token');
|
||||
let allBookings = [];
|
||||
let filteredBookings = [];
|
||||
let selectedBookingId = null;
|
||||
|
||||
// Проверка авторизации
|
||||
if (!token) {
|
||||
window.location.href = 'register-login.html';
|
||||
}
|
||||
|
||||
// Загрузка при старте
|
||||
window.onload = async function() {
|
||||
await loadBookings();
|
||||
await loadEmployeesForFilter();
|
||||
};
|
||||
|
||||
// Загрузить все брони
|
||||
async function loadBookings() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/bookings', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
allBookings = await response.json();
|
||||
filteredBookings = [...allBookings];
|
||||
renderBookings();
|
||||
} else {
|
||||
alert('Ошибка доступа');
|
||||
window.location.href = 'register-login.html';
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('bookingsTable').innerHTML =
|
||||
'<tr><td colspan="7" class="no-bookings">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузить сотрудников для фильтра
|
||||
async function loadEmployeesForFilter() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/users?role=employee', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const employees = await response.json();
|
||||
const select = document.getElementById('filterEmployee');
|
||||
|
||||
for (let employee of employees.filter(e => e.role === 'employee')) {
|
||||
select.innerHTML += `<option value="${employee.id}">${employee.name}</option>`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки сотрудников');
|
||||
}
|
||||
}
|
||||
|
||||
// Применить фильтры
|
||||
function applyFilters() {
|
||||
const date = document.getElementById('filterDate').value;
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const employeeId = document.getElementById('filterEmployee').value;
|
||||
const clientEmail = document.getElementById('filterClient').value.toLowerCase();
|
||||
|
||||
filteredBookings = allBookings.filter(booking => {
|
||||
let match = true;
|
||||
|
||||
if (date && booking.bookingdate !== date) match = false;
|
||||
if (status && booking.status !== status) match = false;
|
||||
if (employeeId && booking.employee_id != employeeId) match = false;
|
||||
if (clientEmail && (!booking.client || !booking.client.email.toLowerCase().includes(clientEmail))) match = false;
|
||||
|
||||
return match;
|
||||
});
|
||||
|
||||
renderBookings();
|
||||
}
|
||||
|
||||
// Очистить фильтры
|
||||
function clearFilters() {
|
||||
document.getElementById('filterDate').value = '';
|
||||
document.getElementById('filterStatus').value = '';
|
||||
document.getElementById('filterEmployee').value = '';
|
||||
document.getElementById('filterClient').value = '';
|
||||
filteredBookings = [...allBookings];
|
||||
renderBookings();
|
||||
}
|
||||
|
||||
// Отобразить брони
|
||||
function renderBookings() {
|
||||
const tbody = document.getElementById('bookingsTable');
|
||||
|
||||
if (filteredBookings.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="no-bookings">Нет броней по фильтрам</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
for (let booking of filteredBookings) {
|
||||
const row = document.createElement('tr');
|
||||
const statusClass = booking.status;
|
||||
|
||||
row.innerHTML = `
|
||||
<td><strong>${booking.bookingnumber}</strong></td>
|
||||
<td>${booking.client ? booking.client.email : 'Неизвестно'}</td>
|
||||
<td>${booking.service ? booking.service.name : 'Удалена'}</td>
|
||||
<td>${booking.employee ? booking.employee.name : 'Не назначен'}</td>
|
||||
<td>${booking.bookingdate}<br>${booking.starttime.slice(0,5)}–${booking.endtime.slice(0,5)}</td>
|
||||
<td><span class="status ${statusClass}">${booking.status}</span></td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
${booking.status !== 'completed' ?
|
||||
`<button class="btn-small btn-cancel-admin" onclick="showCancelModal(${booking.id}, '${booking.bookingnumber}')">
|
||||
❌ Отменить
|
||||
</button>` : ''}
|
||||
${booking.status === 'confirmed' ?
|
||||
`<button class="btn-small btn-complete" onclick="markAsCompleted(${booking.id})">
|
||||
✅ Выполнено
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Показать модальное окно отмены
|
||||
function showCancelModal(bookingId, bookingNumber) {
|
||||
selectedBookingId = bookingId;
|
||||
document.getElementById('cancelBookingInfo').innerHTML =
|
||||
`Бронь <strong>${bookingNumber}</strong>`;
|
||||
document.getElementById('cancelReason').value = '';
|
||||
document.getElementById('cancelModal').style.display = 'block';
|
||||
}
|
||||
|
||||
// Закрыть модальное окно
|
||||
function closeCancelModal() {
|
||||
document.getElementById('cancelModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Админская отмена
|
||||
async function adminCancelBooking() {
|
||||
const reason = document.getElementById('cancelReason').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/bookings/${selectedBookingId}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ reason: reason || null })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
alert('✅ Бронь отменена администратором');
|
||||
closeCancelModal();
|
||||
loadBookings();
|
||||
} else {
|
||||
alert('Ошибка: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка сети');
|
||||
}
|
||||
}
|
||||
|
||||
// Пометить как выполнено
|
||||
async function markAsCompleted(bookingId) {
|
||||
if (confirm('Пометить бронь как выполненную?')) {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/bookings/${bookingId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ status: 'completed' })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('✅ Бронь отмечена как выполненная');
|
||||
loadBookings();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
|
||||
// Enter для фильтров
|
||||
document.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') applyFilters();
|
||||
});
|
||||
|
||||
// Клик вне модального окна
|
||||
window.onclick = function(event) {
|
||||
const modal = document.getElementById('cancelModal');
|
||||
if (event.target === modal) closeCancelModal();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
430
public/admin-schedule.html
Normal file
430
public/admin-schedule.html
Normal file
@@ -0,0 +1,430 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Расписание сотрудников - КлинСервис Админка</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: Arial, sans-serif; background: #f8f9fa; }
|
||||
|
||||
/* Хедер */
|
||||
header {
|
||||
background: #2c3e50; color: white; padding: 20px 50px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1); position: sticky; top: 0;
|
||||
}
|
||||
.header-content {
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.logo { font-size: 24px; font-weight: bold; }
|
||||
.header-nav { display: flex; gap: 20px; }
|
||||
.btn {
|
||||
padding: 12px 25px; border: none; border-radius: 25px;
|
||||
cursor: pointer; font-size: 16px; text-decoration: none;
|
||||
display: inline-block; transition: all 0.3s; color: white;
|
||||
}
|
||||
.btn-primary { background: #667eea; }
|
||||
.btn-primary:hover { background: #5a67d8; }
|
||||
.btn-secondary { background: #6c757d; }
|
||||
.btn-secondary:hover { background: #5a6268; }
|
||||
|
||||
/* Контейнер */
|
||||
.container { max-width: 1200px; margin: 40px auto; padding: 0 20px; }
|
||||
h1 { text-align: center; color: #333; margin-bottom: 40px; font-size: 32px; }
|
||||
|
||||
/* Выбор сотрудника */
|
||||
.employee-select {
|
||||
background: white; padding: 30px; border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1); margin-bottom: 30px;
|
||||
display: flex; gap: 20px; align-items: center; flex-wrap: wrap;
|
||||
}
|
||||
@media (max-width: 768px) { .employee-select { flex-direction: column; text-align: center; } }
|
||||
|
||||
label { font-weight: bold; color: #555; font-size: 18px; }
|
||||
select {
|
||||
padding: 15px 20px; border: 2px solid #e9ecef;
|
||||
border-radius: 10px; font-size: 16px; min-width: 300px;
|
||||
}
|
||||
|
||||
/* Календарь */
|
||||
.calendar-section {
|
||||
background: white; padding: 40px; border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1); margin-bottom: 30px;
|
||||
}
|
||||
.calendar-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 20px; flex-wrap: wrap; gap: 10px;
|
||||
}
|
||||
.calendar-nav-btn {
|
||||
padding: 12px 20px; background: #667eea; color: white;
|
||||
border: none; border-radius: 10px; cursor: pointer; font-size: 16px;
|
||||
}
|
||||
.calendar-nav-btn:hover { background: #5a67d8; }
|
||||
.month-year { font-size: 24px; font-weight: bold; color: #333; }
|
||||
|
||||
.calendar-grid {
|
||||
display: grid; grid-template-columns: repeat(7, 1fr);
|
||||
gap: 8px; text-align: center;
|
||||
}
|
||||
.calendar-day {
|
||||
padding: 20px 10px; border: 2px solid #e9ecef;
|
||||
border-radius: 12px; cursor: pointer; transition: all 0.3s;
|
||||
font-weight: 500; min-height: 80px; display: flex; flex-direction: column;
|
||||
}
|
||||
.calendar-day:hover { border-color: #667eea; background: #f8f9ff; }
|
||||
.calendar-day.selected { border-color: #667eea; background: #e3f2fd; }
|
||||
.calendar-day.other-month { color: #ccc; }
|
||||
.weekdays { font-weight: bold; color: #555; padding: 15px 0; font-size: 16px; }
|
||||
|
||||
/* Интервалы времени */
|
||||
.time-intervals {
|
||||
background: white; padding: 40px; border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
}
|
||||
.interval-form {
|
||||
display: grid; grid-template-columns: 1fr 1fr auto;
|
||||
gap: 20px; align-items: end; margin-bottom: 20px;
|
||||
}
|
||||
@media (max-width: 768px) { .interval-form { grid-template-columns: 1fr; } }
|
||||
|
||||
input[type="time"] { padding: 15px; border: 2px solid #e9ecef; border-radius: 10px; }
|
||||
input[type="time"]:focus { border-color: #667eea; outline: none; }
|
||||
.interval-item {
|
||||
background: #f8f9fa; padding: 20px; border-radius: 15px;
|
||||
border-left: 4px solid #667eea; margin-bottom: 15px;
|
||||
}
|
||||
.delete-interval {
|
||||
background: #dc3545; color: white; border: none;
|
||||
border-radius: 50%; width: 35px; height: 35px;
|
||||
cursor: pointer; font-size: 18px;
|
||||
}
|
||||
.delete-interval:hover { background: #c82333; }
|
||||
|
||||
.no-intervals { text-align: center; color: #666; padding: 40px; font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Хедер -->
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<div class="logo">🧹 КлинСервис - Админка</div>
|
||||
<div class="header-nav">
|
||||
<a href="admin-services.html" class="btn btn-secondary">Услуги</a>
|
||||
<a href="admin-bookings.html" class="btn btn-secondary">Брони</a>
|
||||
<a href="index.html" class="btn btn-primary">На главную</a>
|
||||
<button class="btn btn-secondary" onclick="logout()">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<h1>📅 Расписание сотрудников</h1>
|
||||
|
||||
<!-- Выбор сотрудника -->
|
||||
<div class="employee-select">
|
||||
<div>
|
||||
<label>Выберите сотрудника:</label>
|
||||
<select id="employeeSelect" onchange="loadEmployeeSchedule()">
|
||||
<option value="">— Выберите сотрудника —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="employeeInfo" style="display:none;">
|
||||
<strong id="selectedEmployeeName"></strong>
|
||||
<span style="color: #666; margin-left: 10px;">(ID: <span id="selectedEmployeeId"></span>)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Календарь -->
|
||||
<div class="calendar-section" id="calendarSection" style="display:none;">
|
||||
<div class="calendar-header">
|
||||
<button class="calendar-nav-btn" onclick="prevMonth()">← Пред. месяц</button>
|
||||
<div class="month-year" id="monthYear"></div>
|
||||
<button class="calendar-nav-btn" onclick="nextMonth()">След. месяц →</button>
|
||||
</div>
|
||||
<div class="calendar-grid">
|
||||
<div class="weekdays">Пн</div><div class="weekdays">Вт</div><div class="weekdays">Ср</div>
|
||||
<div class="weekdays">Чт</div><div class="weekdays">Пт</div><div class="weekdays">Сб</div><div class="weekdays">Вс</div>
|
||||
<div id="calendarDays"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Интервалы времени для выбранной даты -->
|
||||
<div class="time-intervals" id="timeIntervalsSection" style="display:none;">
|
||||
<h3 id="selectedDateTitle">Интервалы времени</h3>
|
||||
|
||||
<!-- Форма добавления интервала -->
|
||||
<form onsubmit="addInterval(event)" class="interval-form">
|
||||
<input type="time" id="startTime" required>
|
||||
<input type="time" id="endTime" required>
|
||||
<label>
|
||||
<input type="checkbox" id="isUnavailable"> Недоступен
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary">➕ Добавить интервал</button>
|
||||
</form>
|
||||
|
||||
<!-- Список интервалов -->
|
||||
<div id="intervalsList">
|
||||
<div class="no-intervals">Выберите дату в календаре</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let token = localStorage.getItem('token');
|
||||
let selectedEmployeeId = null;
|
||||
let selectedDate = null;
|
||||
let currentMonth = new Date().getMonth();
|
||||
let currentYear = new Date().getFullYear();
|
||||
let employees = [];
|
||||
let intervals = [];
|
||||
|
||||
// Проверка авторизации
|
||||
if (!token) {
|
||||
window.location.href = 'register-login.html';
|
||||
}
|
||||
|
||||
// Загрузка сотрудников при старте
|
||||
window.onload = async function() {
|
||||
await loadEmployees();
|
||||
};
|
||||
|
||||
// Загрузить список сотрудников
|
||||
async function loadEmployees() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/users?role=employee', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const allUsers = await response.json();
|
||||
employees = allUsers.filter(user => user.role === 'employee' || user.role === 'admin');
|
||||
|
||||
const select = document.getElementById('employeeSelect');
|
||||
select.innerHTML = '<option value="">— Выберите сотрудника —</option>';
|
||||
|
||||
for (let employee of employees) {
|
||||
select.innerHTML += `<option value="${employee.id}">${employee.name}</option>`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка загрузки сотрудников');
|
||||
}
|
||||
}
|
||||
|
||||
// При смене сотрудника
|
||||
function loadEmployeeSchedule() {
|
||||
const select = document.getElementById('employeeSelect');
|
||||
selectedEmployeeId = select.value;
|
||||
|
||||
if (selectedEmployeeId) {
|
||||
document.getElementById('employeeInfo').style.display = 'block';
|
||||
document.getElementById('selectedEmployeeId').textContent = selectedEmployeeId;
|
||||
document.getElementById('selectedEmployeeName').textContent = select.options[select.selectedIndex].text;
|
||||
document.getElementById('calendarSection').style.display = 'block';
|
||||
renderCalendar();
|
||||
} else {
|
||||
document.getElementById('calendarSection').style.display = 'none';
|
||||
document.getElementById('timeIntervalsSection').style.display = 'none';
|
||||
document.getElementById('employeeInfo').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Простой календарь
|
||||
function renderCalendar() {
|
||||
const calendarDays = document.getElementById('calendarDays');
|
||||
const monthYear = document.getElementById('monthYear');
|
||||
|
||||
monthYear.textContent = new Date(currentYear, currentMonth).toLocaleDateString('ru', {
|
||||
year: 'numeric', month: 'long'
|
||||
});
|
||||
|
||||
calendarDays.innerHTML = '';
|
||||
const firstDay = new Date(currentYear, currentMonth, 1).getDay();
|
||||
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
|
||||
|
||||
let dayCounter = 1 - firstDay + 1;
|
||||
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const day = document.createElement('div');
|
||||
day.className = 'calendar-day';
|
||||
|
||||
if (dayCounter < 1) {
|
||||
day.textContent = new Date(currentYear, currentMonth, 0).getDate() + dayCounter;
|
||||
day.classList.add('other-month');
|
||||
dayCounter++;
|
||||
} else if (dayCounter <= daysInMonth) {
|
||||
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(dayCounter).padStart(2, '0')}`;
|
||||
day.textContent = dayCounter;
|
||||
day.onclick = () => selectDate(dateStr);
|
||||
dayCounter++;
|
||||
} else {
|
||||
day.textContent = dayCounter - daysInMonth;
|
||||
day.classList.add('other-month');
|
||||
dayCounter++;
|
||||
}
|
||||
|
||||
calendarDays.appendChild(day);
|
||||
}
|
||||
}
|
||||
|
||||
function selectDate(date) {
|
||||
selectedDate = date;
|
||||
document.querySelectorAll('.calendar-day').forEach(day => day.classList.remove('selected'));
|
||||
event.target.classList.add('selected');
|
||||
|
||||
document.getElementById('selectedDateTitle').textContent = `Интервалы на ${new Date(date).toLocaleDateString('ru')}`;
|
||||
loadIntervals();
|
||||
}
|
||||
|
||||
function prevMonth() {
|
||||
currentMonth--;
|
||||
if (currentMonth < 0) {
|
||||
currentMonth = 11;
|
||||
currentYear--;
|
||||
}
|
||||
renderCalendar();
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
currentMonth++;
|
||||
if (currentMonth > 11) {
|
||||
currentMonth = 0;
|
||||
currentYear++;
|
||||
}
|
||||
renderCalendar();
|
||||
}
|
||||
|
||||
// Загрузить интервалы для даты
|
||||
async function loadIntervals() {
|
||||
try {
|
||||
document.getElementById('timeIntervalsSection').style.display = 'block';
|
||||
const response = await fetch(`/api/admin/availabilities?employee_id=${selectedEmployeeId}&date=${selectedDate}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
intervals = await response.json();
|
||||
renderIntervals();
|
||||
} catch (error) {
|
||||
intervals = [];
|
||||
renderIntervals();
|
||||
}
|
||||
}
|
||||
|
||||
// Отобразить интервалы
|
||||
function renderIntervals() {
|
||||
const container = document.getElementById('intervalsList');
|
||||
|
||||
if (intervals.length === 0) {
|
||||
container.innerHTML = '<div class="no-intervals">Нет интервалов на эту дату</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
for (let interval of intervals) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'interval-item';
|
||||
item.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<strong>${interval.starttime.slice(0,5)} - ${interval.endtime.slice(0,5)}</strong>
|
||||
<div>
|
||||
<label style="margin-right: 15px;">
|
||||
<input type="checkbox" ${interval.isavailable ? '' : 'checked'} onchange="updateInterval(${interval.id}, this.checked)">
|
||||
Недоступен
|
||||
</label>
|
||||
<button class="delete-interval" onclick="deleteInterval(${interval.id})" title="Удалить">×</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Добавить интервал
|
||||
async function addInterval(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const startTime = document.getElementById('startTime').value;
|
||||
const endTime = document.getElementById('endTime').value;
|
||||
const isUnavailable = document.getElementById('isUnavailable').checked;
|
||||
|
||||
if (!startTime || !endTime) {
|
||||
alert('Заполните время начала и окончания');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/availabilities', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
employee_id: selectedEmployeeId,
|
||||
date: selectedDate,
|
||||
starttime: startTime + ':00',
|
||||
endtime: endTime + ':00',
|
||||
isavailable: !isUnavailable
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
document.getElementById('startTime').value = '';
|
||||
document.getElementById('endTime').value = '';
|
||||
document.getElementById('isUnavailable').checked = false;
|
||||
loadIntervals();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка добавления интервала');
|
||||
}
|
||||
}
|
||||
|
||||
// Обновить доступность
|
||||
async function updateInterval(id, isAvailable) {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/availabilities/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ isavailable: isAvailable })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
alert('Ошибка обновления');
|
||||
loadIntervals(); // Вернуть как было
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка сети');
|
||||
loadIntervals();
|
||||
}
|
||||
}
|
||||
|
||||
// Удалить интервал
|
||||
async function deleteInterval(id) {
|
||||
if (confirm('Удалить интервал?')) {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/availabilities/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadIntervals();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка удаления');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
355
public/admin-services.html
Normal file
355
public/admin-services.html
Normal file
@@ -0,0 +1,355 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Админка: Услуги - КлинСервис</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: Arial, sans-serif; background: #f8f9fa; }
|
||||
|
||||
/* Хедер */
|
||||
header {
|
||||
background: #2c3e50; color: white; padding: 20px 50px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1); position: sticky; top: 0;
|
||||
}
|
||||
.header-content {
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.logo { font-size: 24px; font-weight: bold; }
|
||||
.header-nav { display: flex; gap: 20px; }
|
||||
.btn {
|
||||
padding: 12px 25px; border: none; border-radius: 25px;
|
||||
cursor: pointer; font-size: 16px; text-decoration: none;
|
||||
display: inline-block; transition: all 0.3s; color: white;
|
||||
}
|
||||
.btn-primary { background: #667eea; }
|
||||
.btn-primary:hover { background: #5a67d8; }
|
||||
.btn-secondary { background: #6c757d; }
|
||||
.btn-secondary:hover { background: #5a6268; }
|
||||
|
||||
/* Контейнер */
|
||||
.container { max-width: 1200px; margin: 40px auto; padding: 0 20px; }
|
||||
h1 { text-align: center; color: #333; margin-bottom: 40px; font-size: 32px; }
|
||||
|
||||
/* Форма добавления */
|
||||
.add-service-form {
|
||||
background: white; padding: 40px; border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1); margin-bottom: 40px;
|
||||
}
|
||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; }
|
||||
label { display: block; margin-bottom: 8px; font-weight: bold; color: #555; }
|
||||
input, textarea {
|
||||
width: 100%; padding: 15px; border: 2px solid #e9ecef;
|
||||
border-radius: 10px; font-size: 16px; transition: border-color 0.3s;
|
||||
}
|
||||
input:focus, textarea:focus { outline: none; border-color: #667eea; }
|
||||
textarea { resize: vertical; min-height: 100px; }
|
||||
|
||||
/* Таблица услуг */
|
||||
.services-table {
|
||||
background: white; border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1); overflow: hidden;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 20px; text-align: left; border-bottom: 1px solid #e9ecef; }
|
||||
th { background: #667eea; color: white; font-weight: bold; }
|
||||
tr:hover { background: #f8f9fa; }
|
||||
|
||||
/* Статус */
|
||||
.status-badge { padding: 6px 12px; border-radius: 15px; font-size: 14px; font-weight: bold; }
|
||||
.status-active { background: #d4edda; color: #155724; }
|
||||
.status-inactive { background: #f8d7da; color: #721c24; }
|
||||
|
||||
/* Кнопки действий */
|
||||
.action-buttons { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.btn-small {
|
||||
padding: 8px 16px; font-size: 14px; border-radius: 15px;
|
||||
text-decoration: none; border: none; cursor: pointer;
|
||||
}
|
||||
.btn-edit { background: #ffc107; color: #212529; }
|
||||
.btn-edit:hover { background: #e0a800; }
|
||||
.btn-toggle { background: #17a2b8; color: white; }
|
||||
.btn-toggle:hover { background: #138496; }
|
||||
.btn-delete { background: #dc3545; color: white; }
|
||||
.btn-delete:hover { background: #c82333; }
|
||||
|
||||
/* Модальное окно */
|
||||
.modal {
|
||||
display: none; position: fixed; top: 0; left: 0;
|
||||
width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000;
|
||||
}
|
||||
.modal-content {
|
||||
background: white; margin: 5% auto; padding: 40px;
|
||||
border-radius: 20px; max-width: 600px; width: 90%; position: relative;
|
||||
}
|
||||
.close {
|
||||
position: absolute; top: 15px; right: 20px; font-size: 28px;
|
||||
cursor: pointer; color: #999;
|
||||
}
|
||||
.close:hover { color: #333; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.action-buttons { flex-direction: column; }
|
||||
th, td { padding: 15px 10px; font-size: 14px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Хедер -->
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<div class="logo">🧹 КлинСервис - Админка</div>
|
||||
<div class="header-nav">
|
||||
<a href="admin-bookings.html" class="btn btn-secondary">Брони</a>
|
||||
<a href="index.html" class="btn btn-primary">На главную</a>
|
||||
<button class="btn btn-secondary" onclick="logout()">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<h1>⚙️ Управление услугами</h1>
|
||||
|
||||
<!-- Форма добавления услуги -->
|
||||
<div class="add-service-form">
|
||||
<h3 style="margin-bottom: 25px; color: #333;">➕ Добавить новую услугу</h3>
|
||||
<form id="addServiceForm" onsubmit="addService(event)" class="form-grid">
|
||||
<div>
|
||||
<label>Название *</label>
|
||||
<input type="text" id="serviceName" required placeholder="Генеральная уборка">
|
||||
</div>
|
||||
<div>
|
||||
<label>Цена (₽) *</label>
|
||||
<input type="number" id="servicePrice" required min="0" placeholder="5000">
|
||||
</div>
|
||||
<div>
|
||||
<label>Длительность (минуты) *</label>
|
||||
<input type="number" id="serviceDuration" required min="30" placeholder="120">
|
||||
</div>
|
||||
<div style="grid-column: span 2;">
|
||||
<label>Описание</label>
|
||||
<textarea id="serviceDescription" placeholder="Полная уборка с мойкой окон..."></textarea>
|
||||
</div>
|
||||
<div style="grid-column: span 2;">
|
||||
<button type="submit" class="btn btn-primary" style="width: 200px; padding: 15px 30px;">
|
||||
➕ Создать услугу
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Таблица услуг -->
|
||||
<div class="services-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Описание</th>
|
||||
<th>Длительность</th>
|
||||
<th>Цена</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="servicesTable">
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; padding: 40px; color: #666;">
|
||||
Загрузка услуг...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно удаления -->
|
||||
<div id="deleteModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeModal()">×</span>
|
||||
<h3>Удалить услугу?</h3>
|
||||
<p>Услуга <strong id="deleteServiceName"></strong> будет удалена навсегда.</p>
|
||||
<div style="display: flex; gap: 15px; margin-top: 30px;">
|
||||
<button class="btn btn-secondary" onclick="closeModal()" style="flex: 1;">Отмена</button>
|
||||
<button class="btn btn-delete" onclick="confirmDelete()" style="flex: 1;">Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let token = localStorage.getItem('token');
|
||||
let services = [];
|
||||
let selectedServiceId = null;
|
||||
|
||||
// Проверка авторизации и роли
|
||||
if (!token) {
|
||||
window.location.href = 'register-login.html';
|
||||
}
|
||||
|
||||
// Загрузка услуг при загрузке страницы
|
||||
window.onload = loadServices;
|
||||
|
||||
// Загрузить услуги из API
|
||||
async function loadServices() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/services', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
services = await response.json();
|
||||
renderServices();
|
||||
} else {
|
||||
alert('Ошибка доступа или загрузки услуг');
|
||||
window.location.href = 'register-login.html';
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка сети');
|
||||
}
|
||||
}
|
||||
|
||||
// Отобразить услуги в таблице
|
||||
function renderServices() {
|
||||
const tbody = document.getElementById('servicesTable');
|
||||
|
||||
if (services.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align: center; padding: 40px; color: #666;">Нет услуг</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
// Простой цикл для каждой услуги
|
||||
for (let service of services) {
|
||||
const row = document.createElement('tr');
|
||||
const statusClass = service.isactive ? 'active' : 'inactive';
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${service.id}</td>
|
||||
<td><strong>${service.name}</strong></td>
|
||||
<td>${service.description || '—'}</td>
|
||||
<td>${service.durationminutes} мин</td>
|
||||
<td>${service.price}₽</td>
|
||||
<td><span class="status-badge status-${statusClass}">${service.isactive ? 'Активна' : 'Неактивна'}</span></td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="btn-small btn-edit" onclick="editService(${service.id})">
|
||||
✏️ Редактировать
|
||||
</button>
|
||||
<button class="btn-small btn-toggle" onclick="toggleService(${service.id}, ${service.isactive})">
|
||||
${service.isactive ? '❌ Деактивировать' : '✅ Активировать'}
|
||||
</button>
|
||||
<button class="btn-small btn-delete" onclick="showDeleteModal(${service.id}, '${service.name}')">
|
||||
🗑️ Удалить
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Добавить новую услугу
|
||||
async function addService(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = {
|
||||
name: document.getElementById('serviceName').value,
|
||||
description: document.getElementById('serviceDescription').value,
|
||||
durationminutes: parseInt(document.getElementById('serviceDuration').value),
|
||||
price: parseInt(document.getElementById('servicePrice').value),
|
||||
isactive: true
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/services', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('✅ Услуга создана!');
|
||||
document.getElementById('addServiceForm').reset();
|
||||
loadServices(); // Перезагрузить таблицу
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert('Ошибка: ' + error.error || error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка сети');
|
||||
}
|
||||
}
|
||||
|
||||
// Переключить статус (активна/неактивна)
|
||||
async function toggleService(id, isActive) {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/services/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ isactive: !isActive })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert(`✅ Услуга ${!isActive ? 'активирована' : 'деактивирована'}`);
|
||||
loadServices();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка сети');
|
||||
}
|
||||
}
|
||||
|
||||
// Показать модальное окно удаления
|
||||
function showDeleteModal(id, name) {
|
||||
selectedServiceId = id;
|
||||
document.getElementById('deleteServiceName').textContent = name;
|
||||
document.getElementById('deleteModal').style.display = 'block';
|
||||
}
|
||||
|
||||
// Удалить услугу
|
||||
async function confirmDelete() {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/services/${selectedServiceId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('✅ Услуга удалена');
|
||||
closeModal();
|
||||
loadServices();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка сети');
|
||||
}
|
||||
}
|
||||
|
||||
// Закрыть модальное окно
|
||||
function closeModal() {
|
||||
document.getElementById('deleteModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Выход
|
||||
function logout() {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
|
||||
// Закрытие модального окна по клику вне области
|
||||
window.onclick = function(event) {
|
||||
const modal = document.getElementById('deleteModal');
|
||||
if (event.target === modal) closeModal();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
155
public/booking-confirm.html
Normal file
155
public/booking-confirm.html
Normal file
@@ -0,0 +1,155 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Бронь создана - КлинСервис</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 60px 40px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
.success-icon {
|
||||
font-size: 80px;
|
||||
color: #28a745;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #28a745;
|
||||
font-size: 32px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.booking-number {
|
||||
background: #d4edda;
|
||||
padding: 20px;
|
||||
border-radius: 15px;
|
||||
margin: 30px 0;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #155724;
|
||||
border: 3px solid #28a745;
|
||||
}
|
||||
.booking-details {
|
||||
background: #f8f9fa;
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
margin: 20px 0;
|
||||
text-align: left;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
.detail-row:last-child { border-bottom: none; margin-bottom: 0; }
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
margin: 10px 0;
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.btn-primary { background: #667eea; color: white; }
|
||||
.btn-primary:hover { background: #5a67d8; transform: translateY(-2px); }
|
||||
.btn-secondary { background: #6c757d; color: white; }
|
||||
.btn-secondary:hover { background: #5a6268; }
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Хедер -->
|
||||
<div class="header">
|
||||
<a href="index.html" style="font-size: 24px; font-weight: bold; color: #667eea;">← Главная</a>
|
||||
<a href="my-bookings.html" class="btn btn-secondary" style="width: auto; padding: 10px 20px;">Мои брони</a>
|
||||
</div>
|
||||
|
||||
<!-- Успешное подтверждение -->
|
||||
<div class="success-icon">✅</div>
|
||||
<h1>Бронь успешно создана!</h1>
|
||||
<p style="color: #666; margin-bottom: 30px;">Сохраните номер брони — он понадобится для отмены или связи</p>
|
||||
|
||||
<div class="booking-number" id="bookingNumber">CL-2025-0042</div>
|
||||
|
||||
<div class="booking-details" id="bookingDetails">
|
||||
<div class="detail-row">
|
||||
<span>🧹 Услуга:</span>
|
||||
<span>Генеральная уборка</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>👨💼 Сотрудник:</span>
|
||||
<span>Иван Петров</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>📅 Дата:</span>
|
||||
<span>15 июня 2025</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>🕒 Время:</span>
|
||||
<span>10:00–12:00</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>💰 Стоимость:</span>
|
||||
<span>5000 ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="services.html" class="btn btn-primary">📋 Забронировать еще</a>
|
||||
<a href="my-bookings.html" class="btn btn-secondary">📋 Мои брони</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Получить данные брони из localStorage (передается из services.html)
|
||||
const bookingData = JSON.parse(localStorage.getItem('lastBooking') || '{}');
|
||||
|
||||
if (bookingData.booking) {
|
||||
const booking = bookingData.booking;
|
||||
document.getElementById('bookingNumber').textContent = booking.bookingnumber;
|
||||
|
||||
// Заполнить детали (простые циклы для поиска)
|
||||
const services = JSON.parse(localStorage.getItem('services') || '[]');
|
||||
const service = services.find(s => s.id == booking.service_id);
|
||||
|
||||
document.querySelector('#bookingDetails .detail-row:nth-child(1) span:last-child').textContent = service ? service.name : 'Услуга';
|
||||
document.querySelector('#bookingDetails .detail-row:nth-child(4) span:last-child').textContent =
|
||||
`${booking.starttime.slice(0,5)}–${booking.endtime.slice(0,5)}`;
|
||||
document.querySelector('#bookingDetails .detail-row:nth-child(5) span:last-child').textContent =
|
||||
`${booking.service.price || '???'} ₽`;
|
||||
}
|
||||
|
||||
// Очистка localStorage через 30 секунд
|
||||
setTimeout(() => {
|
||||
localStorage.removeItem('lastBooking');
|
||||
}, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
174
public/index.html
Normal file
174
public/index.html
Normal file
@@ -0,0 +1,174 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>КлинСервис - Профессиональная уборка</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Arial', sans-serif; line-height: 1.6; color: #333; }
|
||||
|
||||
/* Хедер */
|
||||
header {
|
||||
background: white;
|
||||
padding: 20px 50px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.logo { font-size: 28px; font-weight: bold; color: #667eea; }
|
||||
.auth-buttons { display: flex; gap: 15px; }
|
||||
.btn {
|
||||
padding: 12px 25px;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.btn-primary { background: #667eea; color: white; }
|
||||
.btn-primary:hover { background: #5a67d8; transform: translateY(-2px); }
|
||||
.btn-secondary { background: transparent; color: #667eea; border: 2px solid #667eea; }
|
||||
.btn-secondary:hover { background: #667eea; color: white; }
|
||||
|
||||
/* Hero секция */
|
||||
.hero {
|
||||
background: linear-gradient(rgba(102,126,234,0.9), rgba(118,75,162,0.9)),
|
||||
url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 600"><rect fill="%23f8f9fa" width="1200" height="600"/></svg>');
|
||||
padding: 100px 50px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
.hero-content { max-width: 800px; margin: 0 auto; }
|
||||
.hero h1 { font-size: 48px; margin-bottom: 20px; }
|
||||
.hero p { font-size: 20px; margin-bottom: 40px; opacity: 0.95; }
|
||||
.hero-buttons { display: flex; gap: 20px; justify-content: center; flex-wrap: wrap; }
|
||||
|
||||
/* Секция услуг */
|
||||
.services { padding: 80px 50px; max-width: 1200px; margin: 0 auto; }
|
||||
.section-title { text-align: center; font-size: 36px; margin-bottom: 60px; color: #333; }
|
||||
.services-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 40px; }
|
||||
.service-card {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
.service-card:hover { transform: translateY(-10px); box-shadow: 0 30px 60px rgba(0,0,0,0.15); }
|
||||
.service-icon { font-size: 64px; margin-bottom: 20px; }
|
||||
.service-card h3 { font-size: 24px; margin-bottom: 15px; color: #333; }
|
||||
.service-price { font-size: 28px; font-weight: bold; color: #667eea; margin: 20px 0; }
|
||||
|
||||
/* Футер */
|
||||
footer {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 40px 50px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.footer-content { max-width: 1200px; margin: 0 auto; }
|
||||
.social-links { display: flex; justify-content: center; gap: 20px; margin: 20px 0; }
|
||||
.social-links a { color: white; font-size: 24px; transition: color 0.3s; }
|
||||
.social-links a:hover { color: #667eea; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
header, .services { padding-left: 20px; padding-right: 20px; }
|
||||
.hero h1 { font-size: 36px; }
|
||||
.hero-buttons { flex-direction: column; align-items: center; }
|
||||
.services-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Хедер -->
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<div class="logo">🧹 КлинСервис</div>
|
||||
<div class="auth-buttons">
|
||||
<a href="register-login.html" class="btn btn-secondary">Войти</a>
|
||||
<a href="register-login.html" class="btn btn-primary">Регистрация</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Hero секция -->
|
||||
<section class="hero">
|
||||
<div class="hero-content">
|
||||
<h1>Профессиональная уборка</h1>
|
||||
<p>Чистота и порядок в вашем доме за 2 часа. Быстро. Качественно. Надежно.</p>
|
||||
<div class="hero-buttons">
|
||||
<a href="services.html" class="btn btn-primary" style="padding: 18px 40px; font-size: 18px;">Заказать уборку</a>
|
||||
<a href="#services" class="btn btn-secondary" style="padding: 18px 40px; font-size: 18px;">Наши услуги</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Услуги -->
|
||||
<section class="services" id="services">
|
||||
<h2 class="section-title">Наши услуги</h2>
|
||||
<div class="services-grid">
|
||||
<div class="service-card">
|
||||
<div class="service-icon">🧹</div>
|
||||
<h3>Поддерживающая уборка</h3>
|
||||
<div class="service-price">от 2500 ₽</div>
|
||||
<p>Быстрая уборка помещений. Пыль, полы, сантехника. Идеально для ежедневной чистоты.</p>
|
||||
</div>
|
||||
<div class="service-card">
|
||||
<div class="service-icon">✨</div>
|
||||
<h3>Генеральная уборка</h3>
|
||||
<div class="service-price">от 5000 ₽</div>
|
||||
<p>Полная уборка с мойкой окон, шкафов, техники. Как после ремонта.</p>
|
||||
</div>
|
||||
<div class="service-card">
|
||||
<div class="service-icon">🪑</div>
|
||||
<h3>Химчистка мебели</h3>
|
||||
<div class="service-price">от 3500 ₽</div>
|
||||
<p>Профессиональная чистка диванов, кресел, ковров. Высокое давление + химия.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Футер -->
|
||||
<footer>
|
||||
<div class="footer-content">
|
||||
<h3>🧹 КлинСервис</h3>
|
||||
<p>Профессиональная уборка для вашего комфорта</p>
|
||||
<div class="social-links">
|
||||
<a href="#">📱 Telegram</a>
|
||||
<a href="#">📧 WhatsApp</a>
|
||||
<a href="#">📞 +7 (999) 123-45-67</a>
|
||||
</div>
|
||||
<p style="margin-top: 30px; opacity: 0.8;">© 2026 КлинСервис. Все права защищены.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Простая прокрутка к секции услуг
|
||||
document.querySelector('.hero-buttons .btn-secondary').onclick = function(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('services').scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
// Проверка авторизации
|
||||
if (localStorage.getItem('token')) {
|
||||
// Пользователь авторизован - можно добавить кнопку "Личный кабинет"
|
||||
console.log('Пользователь авторизован');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
291
public/my-bookings.html
Normal file
291
public/my-bookings.html
Normal file
@@ -0,0 +1,291 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Мои брони - КлинСервис</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: Arial, sans-serif; background: #f8f9fa; }
|
||||
|
||||
/* Хедер */
|
||||
header {
|
||||
background: white; padding: 20px 50px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
}
|
||||
.header-content {
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.logo { font-size: 24px; font-weight: bold; color: #667eea; }
|
||||
.header-nav { display: flex; gap: 20px; align-items: center; }
|
||||
.btn {
|
||||
padding: 12px 25px; border: none; border-radius: 25px;
|
||||
cursor: pointer; font-size: 16px; text-decoration: none;
|
||||
display: inline-block; transition: all 0.3s;
|
||||
}
|
||||
.btn-primary { background: #667eea; color: white; }
|
||||
.btn-primary:hover { background: #5a67d8; }
|
||||
.btn-secondary { background: transparent; color: #667eea; border: 2px solid #667eea; }
|
||||
.btn-secondary:hover { background: #667eea; color: white; }
|
||||
|
||||
/* Контейнер */
|
||||
.container { max-width: 1200px; margin: 40px auto; padding: 0 20px; }
|
||||
h1 { text-align: center; color: #333; margin-bottom: 40px; font-size: 32px; }
|
||||
|
||||
/* Таблица броней */
|
||||
.bookings-table {
|
||||
background: white; border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td {
|
||||
padding: 20px; text-align: left; border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
th {
|
||||
background: #667eea; color: white; font-weight: bold;
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
tr:hover { background: #f8f9fa; }
|
||||
|
||||
/* Статусы */
|
||||
.status { padding: 8px 16px; border-radius: 20px; font-size: 14px; font-weight: bold; }
|
||||
.status.confirmed { background: #d4edda; color: #155724; }
|
||||
.status.completed { background: #d1ecf1; color: #0c5460; }
|
||||
.status.cancelled { background: #f8d7da; color: #721c24; }
|
||||
|
||||
/* Кнопки действий */
|
||||
.action-buttons { display: flex; gap: 10px; align-items: center; }
|
||||
.btn-small {
|
||||
padding: 8px 16px; font-size: 14px; border-radius: 15px;
|
||||
text-decoration: none; display: inline-block;
|
||||
}
|
||||
.btn-cancel { background: #dc3545; color: white; }
|
||||
.btn-cancel:hover { background: #c82333; }
|
||||
.btn-cancel:disabled { background: #ccc; cursor: not-allowed; }
|
||||
|
||||
/* Модальное окно отмены */
|
||||
.modal {
|
||||
display: none; position: fixed; top: 0; left: 0;
|
||||
width: 100%; height: 100%; background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-content {
|
||||
background: white; margin: 10% auto; padding: 40px;
|
||||
border-radius: 20px; max-width: 500px; width: 90%;
|
||||
position: relative;
|
||||
}
|
||||
.close {
|
||||
position: absolute; top: 15px; right: 20px;
|
||||
font-size: 28px; cursor: pointer; color: #999;
|
||||
}
|
||||
.close:hover { color: #333; }
|
||||
.modal textarea {
|
||||
width: 100%; height: 100px; padding: 15px;
|
||||
border: 2px solid #e9ecef; border-radius: 10px;
|
||||
font-family: Arial, sans-serif; resize: vertical;
|
||||
}
|
||||
.modal-buttons { display: flex; gap: 15px; margin-top: 30px; }
|
||||
.btn-modal { flex: 1; padding: 15px; border: none; border-radius: 10px;
|
||||
font-size: 16px; font-weight: bold; cursor: pointer; }
|
||||
.confirm-cancel { background: #dc3545; color: white; }
|
||||
.confirm-cancel:hover { background: #c82333; }
|
||||
|
||||
.no-bookings {
|
||||
text-align: center; padding: 80px 40px; color: #666;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container, header { padding-left: 20px; padding-right: 20px; }
|
||||
.action-buttons { flex-direction: column; }
|
||||
th, td { padding: 15px 10px; font-size: 14px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Хедер -->
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<div class="logo">🧹 КлинСервис</div>
|
||||
<div class="header-nav">
|
||||
<a href="index.html" class="btn btn-secondary">Главная</a>
|
||||
<a href="services.html" class="btn btn-primary">Новая бронь</a>
|
||||
<button class="btn btn-secondary" onclick="logout()">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<h1>📋 Мои бронирования</h1>
|
||||
|
||||
<!-- Таблица броней -->
|
||||
<div class="bookings-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>№ Брони</th>
|
||||
<th>Дата и время</th>
|
||||
<th>Услуга</th>
|
||||
<th>Сотрудник</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bookingsTable">
|
||||
<tr>
|
||||
<td colspan="6" class="no-bookings">Загрузка броней...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно отмены -->
|
||||
<div id="cancelModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeModal()">×</span>
|
||||
<h3>Отменить бронь?</h3>
|
||||
<p>Укажите причину отмены (необязательно):</p>
|
||||
<textarea id="cancelReason" placeholder="Например: 'Перенесли отпуск', 'Заболел'"></textarea>
|
||||
<div class="modal-buttons">
|
||||
<button class="btn-modal" onclick="closeModal()">Отмена</button>
|
||||
<button class="btn-modal confirm-cancel" id="confirmCancelBtn" onclick="confirmCancel()">Отменить бронь</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let token = localStorage.getItem('token');
|
||||
let bookings = [];
|
||||
let selectedBookingId = null;
|
||||
|
||||
// Проверка авторизации
|
||||
if (!token) {
|
||||
window.location.href = 'register-login.html';
|
||||
}
|
||||
|
||||
// Загрузка броней при загрузке страницы
|
||||
window.onload = loadBookings;
|
||||
|
||||
// Загрузить список своих броней
|
||||
async function loadBookings() {
|
||||
try {
|
||||
const response = await fetch('/api/bookings', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
bookings = await response.json();
|
||||
renderBookings();
|
||||
} else {
|
||||
alert('Ошибка загрузки броней');
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('bookingsTable').innerHTML =
|
||||
'<tr><td colspan="6" class="no-bookings">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// Отобразить брони в таблице
|
||||
function renderBookings() {
|
||||
const tbody = document.getElementById('bookingsTable');
|
||||
|
||||
if (bookings.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="no-bookings">У вас нет броней</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
// Простой цикл для каждой брони
|
||||
for (let booking of bookings) {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
// Статус badge
|
||||
let statusClass = '';
|
||||
if (booking.status === 'confirmed') statusClass = 'confirmed';
|
||||
else if (booking.status === 'completed') statusClass = 'completed';
|
||||
else if (booking.status === 'cancelled') statusClass = 'cancelled';
|
||||
|
||||
const canCancel = booking.status === 'confirmed';
|
||||
|
||||
row.innerHTML = `
|
||||
<td><strong>${booking.bookingnumber}</strong></td>
|
||||
<td>${booking.bookingdate} ${booking.starttime.slice(0,5)}–${booking.endtime.slice(0,5)}</td>
|
||||
<td>${booking.service ? booking.service.name : 'Удалена'}</td>
|
||||
<td>${booking.employee ? booking.employee.name : 'Не назначен'}</td>
|
||||
<td><span class="status ${statusClass}">${booking.status}</span></td>
|
||||
<td>
|
||||
${canCancel ?
|
||||
`<button class="btn-small btn-cancel" onclick="showCancelModal(${booking.id})">
|
||||
Отменить
|
||||
</button>` :
|
||||
'—'
|
||||
}
|
||||
</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Показать модальное окно отмены
|
||||
function showCancelModal(bookingId) {
|
||||
selectedBookingId = bookingId;
|
||||
document.getElementById('cancelModal').style.display = 'block';
|
||||
document.getElementById('cancelReason').value = '';
|
||||
}
|
||||
|
||||
// Закрыть модальное окно
|
||||
function closeModal() {
|
||||
document.getElementById('cancelModal').style.display = 'none';
|
||||
selectedBookingId = null;
|
||||
}
|
||||
|
||||
// Подтвердить отмену
|
||||
async function confirmCancel() {
|
||||
const reason = document.getElementById('cancelReason').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/bookings/${selectedBookingId}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ reason: reason || null })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
alert('✅ Бронь отменена!');
|
||||
closeModal();
|
||||
loadBookings(); // Перезагрузить список
|
||||
} else {
|
||||
alert('Ошибка: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка сети');
|
||||
}
|
||||
}
|
||||
|
||||
// Выход
|
||||
function logout() {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
|
||||
// Закрытие модального окна по клику вне его
|
||||
window.onclick = function(event) {
|
||||
const modal = document.getElementById('cancelModal');
|
||||
if (event.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
218
public/services.html
Normal file
218
public/services.html
Normal file
@@ -0,0 +1,218 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Выбор услуги - КлинСервис</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: Arial, sans-serif; background: #f8f9fa; }
|
||||
|
||||
/* Хедер */
|
||||
header {
|
||||
background: white; padding: 20px 50px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
}
|
||||
.header-content {
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.logo { font-size: 24px; font-weight: bold; color: #667eea; }
|
||||
.header-nav { display: flex; gap: 20px; align-items: center; }
|
||||
.btn {
|
||||
padding: 10px 20px; border: none; border-radius: 25px;
|
||||
cursor: pointer; font-size: 14px; text-decoration: none;
|
||||
display: inline-block; transition: all 0.3s;
|
||||
}
|
||||
.btn-primary { background: #667eea; color: white; }
|
||||
.btn-primary:hover { background: #5a67d8; }
|
||||
.btn-secondary { background: transparent; color: #667eea; border: 2px solid #667eea; }
|
||||
.btn-secondary:hover { background: #667eea; color: white; }
|
||||
|
||||
/* Контейнер */
|
||||
.container { max-width: 1000px; margin: 40px auto; padding: 0 20px; }
|
||||
h1 { text-align: center; color: #333; margin-bottom: 40px; font-size: 32px; }
|
||||
|
||||
/* Форма выбора */
|
||||
.selection-form {
|
||||
background: white; padding: 40px; border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1); margin-bottom: 40px;
|
||||
}
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; margin-bottom: 30px; }
|
||||
@media (max-width: 768px) { .form-row { grid-template-columns: 1fr; } }
|
||||
|
||||
label { display: block; margin-bottom: 10px; font-weight: bold; color: #555; }
|
||||
select, input {
|
||||
width: 100%; padding: 15px; border: 2px solid #e9ecef;
|
||||
border-radius: 10px; font-size: 16px; transition: border-color 0.3s;
|
||||
}
|
||||
select:focus, input:focus { outline: none; border-color: #667eea; }
|
||||
|
||||
/* Простой календарь */
|
||||
.calendar-section {
|
||||
background: white; padding: 40px; border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
}
|
||||
.calendar-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 20px; flex-wrap: wrap; gap: 10px;
|
||||
}
|
||||
.calendar-nav-btn {
|
||||
padding: 10px 20px; background: #667eea; color: white;
|
||||
border: none; border-radius: 10px; cursor: pointer;
|
||||
}
|
||||
.calendar-nav-btn:hover { background: #5a67d8; }
|
||||
.calendar-nav-btn:disabled { background: #ccc; cursor: not-allowed; }
|
||||
.month-year { font-size: 24px; font-weight: bold; color: #333; }
|
||||
|
||||
.calendar-grid {
|
||||
display: grid; grid-template-columns: repeat(7, 1fr);
|
||||
gap: 5px; text-align: center;
|
||||
}
|
||||
.calendar-day {
|
||||
padding: 15px; border: 1px solid #e9ecef;
|
||||
border-radius: 10px; cursor: pointer; transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
}
|
||||
.calendar-day:hover { background: #e3f2fd; border-color: #667eea; }
|
||||
.calendar-day.other-month { color: #ccc; }
|
||||
.calendar-day.selected {
|
||||
background: #667eea; color: white; box-shadow: 0 5px 15px rgba(102,126,234,0.4);
|
||||
}
|
||||
.calendar-day.disabled {
|
||||
background: #f8f9fa; color: #ccc; cursor: not-allowed;
|
||||
}
|
||||
.weekdays { font-weight: bold; color: #555; padding: 10px 0; }
|
||||
|
||||
/* Слоты */
|
||||
.slots-section {
|
||||
background: white; padding: 40px; border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1); margin-top: 30px;
|
||||
}
|
||||
.slots-grid { display: flex; flex-wrap: wrap; gap: 15px; }
|
||||
.slot {
|
||||
padding: 15px 25px; background: #f8f9fa;
|
||||
border: 2px solid transparent; border-radius: 25px;
|
||||
cursor: pointer; transition: all 0.3s; font-weight: 500;
|
||||
}
|
||||
.slot:hover { background: #e3f2fd; border-color: #667eea; }
|
||||
.slot.selected { background: #667eea; color: white; border-color: #5a67d8; }
|
||||
.slot.booked { background: #f8d7da; color: #721c24; cursor: not-allowed; }
|
||||
|
||||
.no-slots { text-align: center; color: #666; padding: 40px; font-style: italic; }
|
||||
|
||||
/* Кнопка забронировать */
|
||||
.book-btn {
|
||||
width: 100%; padding: 20px; background: #28a745;
|
||||
color: white; border: none; border-radius: 15px;
|
||||
font-size: 20px; font-weight: bold; cursor: pointer;
|
||||
margin-top: 30px; transition: all 0.3s;
|
||||
}
|
||||
.book-btn:hover:not(:disabled) { background: #218838; transform: translateY(-2px); }
|
||||
.book-btn:disabled { background: #ccc; cursor: not-allowed; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Хедер -->
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<div class="logo">🧹 КлинСервис</div>
|
||||
<div class="header-nav">
|
||||
<a href="index.html" class="btn btn-secondary">Главная</a>
|
||||
<a href="my-bookings.html" class="btn btn-secondary" id="profileBtn" style="display:none;">Мои брони</a>
|
||||
<button class="btn btn-primary" id="logoutBtn" style="display:none;" onclick="logout()">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<h1>📅 Выберите дату и время</h1>
|
||||
|
||||
<!-- Выбор услуги -->
|
||||
<div class="selection-form">
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label>Услуга *</label>
|
||||
<select id="serviceSelect">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Длительность</label>
|
||||
<input type="text" id="duration" readonly placeholder="Выберите услугу">
|
||||
</div>
|
||||
</div>
|
||||
<button class="book-btn" id="bookBtn" onclick="bookService()" disabled>
|
||||
Выбрать время →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Календарь -->
|
||||
<div class="calendar-section">
|
||||
<div class="calendar-header">
|
||||
<button class="calendar-nav-btn" onclick="prevMonth()">← Месяц</button>
|
||||
<div class="month-year" id="monthYear"></div>
|
||||
<button class="calendar-nav-btn" onclick="nextMonth()">Месяц →</button>
|
||||
</div>
|
||||
<div class="calendar-grid">
|
||||
<div class="weekdays">Пн</div><div class="weekdays">Вт</div><div class="weekdays">Ср</div>
|
||||
<div class="weekdays">Чт</div><div class="weekdays">Пт</div><div class="weekdays">Сб</div><div class="weekdays">Вс</div>
|
||||
<div id="calendarDays"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Слоты времени -->
|
||||
<div class="slots-section" id="slotsSection" style="display:none;">
|
||||
<h3>Доступные слоты</h3>
|
||||
<div class="slots-grid" id="slotsGrid"></div>
|
||||
<button class="book-btn" id="confirmBookingBtn" onclick="confirmBooking()" style="display:none;">
|
||||
✅ Забронировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let services = [];
|
||||
let selectedServiceId = null;
|
||||
let selectedDate = null;
|
||||
let currentMonth = new Date().getMonth();
|
||||
let currentYear = new Date().getFullYear();
|
||||
let availableSlots = [];
|
||||
let token = localStorage.getItem('token');
|
||||
|
||||
// Проверка авторизации
|
||||
if (!token) {
|
||||
window.location.href = 'register-login.html';
|
||||
throw new Error('Нужна авторизация');
|
||||
}
|
||||
updateHeader();
|
||||
|
||||
// 1. Загрузить услуги при загрузке страницы
|
||||
window.onload = async function() {
|
||||
await loadServices();
|
||||
initCalendar();
|
||||
};
|
||||
|
||||
// Загрузка услуг из API (только isactive=true)
|
||||
async function loadServices() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/services', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
services = data.filter(service => service.isactive);
|
||||
const select = document.getElementById('serviceSelect');
|
||||
|
||||
select.innerHTML = '<option value="">Выберите услугу</option>';
|
||||
for (let service of services) {
|
||||
select.innerHTML += `<option value="${service.id}" data-duration="${service.durationminutes}">${service.name} (${service.durationminutes} мин) - ${service.price}₽</option>`;
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка загрузки услуг');
|
||||
}
|
||||
}
|
||||
|
||||
// Изменение услуги
|
||||
//document.getElementById('serviceSelect').onchange = function()//
|
||||
@@ -25,8 +25,11 @@ Route::get('/availability', [AvailabilitiesController::class, 'publicAvailabilit
|
||||
|
||||
// КЛИЕНТСКИЕ РОУТЫ БРОНИРОВАНИЙ (auth:sanctum)
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::post('/bookings', [BookingsController::class, 'store']); // Пункт 9
|
||||
Route::post('/bookings/{id}/cancel', [BookingsController::class, 'cancel']); // Пункт 10
|
||||
Route::post('/bookings', [BookingsController::class, 'store']);
|
||||
Route::post('/bookings/{id}/cancel', [BookingsController::class, 'cancel']);
|
||||
Route::post('/bookings/{id}/cancel', [BookingsController::class, 'adminCancel']);
|
||||
Route::get('/bookings', [BookingsController::class, 'clientIndex']);
|
||||
Route::get('/bookings', [BookingsController::class, 'adminIndex']);
|
||||
});
|
||||
|
||||
// АДМИН РОУТЫ - ТОЛЬКО employee/admin (role:employee)
|
||||
|
||||
@@ -3,5 +3,37 @@
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('welcome');
|
||||
return file_get_contents(public_path('index.html'));
|
||||
});
|
||||
|
||||
Route::get('/index.html', function () {
|
||||
return file_get_contents(public_path('index.html'));
|
||||
});
|
||||
|
||||
Route::get('/services.html', function () {
|
||||
return file_get_contents(public_path('services.html'));
|
||||
});
|
||||
|
||||
Route::get('/my-bookings.html', function () {
|
||||
return file_get_contents(public_path('my-bookings.html'));
|
||||
});
|
||||
|
||||
Route::get('/admin-bookings.html', function () {
|
||||
return file_get_contents(public_path('admin-bookings.html'));
|
||||
});
|
||||
|
||||
Route::get('/admin-services.html', function () {
|
||||
return file_get_contents(public_path('admin-services.html'));
|
||||
});
|
||||
|
||||
Route::get('/admin-schedule.html', function () {
|
||||
return file_get_contents(public_path('admin-schedule.html'));
|
||||
});
|
||||
|
||||
Route::get('/booking-confirm.html', function () {
|
||||
return file_get_contents(public_path('booking-confirm.html'));
|
||||
});
|
||||
|
||||
Route::get('/register-login.html', function () {
|
||||
return file_get_contents(public_path('register-login.html'));
|
||||
});
|
||||
Reference in New Issue
Block a user