commit 08.01

This commit is contained in:
Владимир
2026-01-08 12:38:09 +00:00
parent bbe639b604
commit f5c68bf0c7
13 changed files with 4444 additions and 542 deletions

View File

@@ -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);
}
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

1027
composer.lock generated

File diff suppressed because it is too large Load Diff

413
public/admin-bookings.html Normal file
View 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()">&times;</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
View 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
View 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()">&times;</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
View 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:0012: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
View 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
View 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()">&times;</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
View 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()//

View File

@@ -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)

View File

@@ -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'));
});