Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Программная инженерия. Курсовые / Вебтехнологии_Курсовая_Кларк.docx
Скачиваний:
0
Добавлен:
04.01.2026
Размер:
659.73 Кб
Скачать

8. Список литературы

  1. Бирюков, М.А. Лекции по дисциплине «Web-технологии». – СПб.: СПбГУТ, – [Электронный ресурс], [дата обращения – ноябрь 2025 г.].

  2. Oracle Corporation. MySQL 8.0 Reference Manual. — [Электронный ресурс]. — Режим доступа: https://dev.mysql.com/doc/refman/8.0/en/, свободный. — [Дата обращения: ноябрь 2025 г.]

  3. Основы Web - технологий: учеб. пособие / П.Б. Храмцов [и др.]. - М.: Изд-во Интуит.ру “Интернет-Университет Информационных Технологий”, 2013. - 512 с.

  4. Пауэл Томас, А. Справочник программиста / Томас А Пауэл, Д. Уитворт. - М.: АСТ, Мн.: Харвест, 2014. - 384 с.

9. Приложение

Script.js

class MedicalPortal {

constructor() {

this.clinics = {};

this.appointments = [];

this.cart = JSON.parse(localStorage.getItem('medicalCart')) || {};

this.selectedAppointmentId = null;

this.currentDate = new Date();

this.myAppointments = [];

this.init();

}

async init() {

try {

await this.loadData();

this.setupEventListeners();

this.renderClinics();

this.populateClinicSelect();

this.populateServiceSelect();

this.updateCartDisplay();

this.updateCalendar();

await this.loadMyAppointments(); // если пользователь авторизован

} catch (e) {

console.error('Ошибка инициализации:', e);

this.showNotification('Ошибка при загрузке данных портала.', 'error');

}

}

async loadData() {

const [clinicsRes, appointmentsRes] = await Promise.all([

fetch('/api/clinics'),

fetch('/api/appointments'),

]);

const clinicsData = await clinicsRes.json();

const appointmentsData = await appointmentsRes.json();

if (!clinicsData.success || !appointmentsData.success) {

throw new Error('API вернуло ошибку.');

}

this.clinics = clinicsData.clinics || {};

this.appointments = appointmentsData.appointments || [];

}

setupEventListeners() {

// Переключение вкладок

const tabs = document.querySelectorAll('.tab');

const tabContents = document.querySelectorAll('.tab-content');

tabs.forEach(tab => {

tab.addEventListener('click', () => {

const target = tab.dataset.tab;

tabs.forEach(t => t.classList.remove('active'));

tabContents.forEach(c => c.classList.remove('active'));

tab.classList.add('active');

document.getElementById(target).classList.add('active');

if (target === 'my-appointments') {

this.loadMyAppointments();

}

});

});

// Поиск по клиникам и услугам

const searchInput = document.getElementById('clinic-search');

if (searchInput) {

searchInput.addEventListener('input', () => {

const value = searchInput.value.trim().toLowerCase();

this.renderClinics(value);

});

}

// Выбор клиники и врача

const clinicSelect = document.getElementById('clinic-select');

const doctorSelect = document.getElementById('doctor-select');

if (clinicSelect) {

clinicSelect.addEventListener('change', () => {

this.updateDoctorSelect();

this.clearTimeSlots();

this.updateBookButtonState();

});

}

if (doctorSelect) {

doctorSelect.addEventListener('change', () => {

this.renderTimeSlots();

this.updateBookButtonState();

});

}

// Кнопка "Записаться"

const bookBtn = document.getElementById('book-btn');

if (bookBtn) {

bookBtn.addEventListener('click', () => this.bookAppointment());

}

// Кнопки календаря

const prevMonthBtn = document.getElementById('prev-month');

const nextMonthBtn = document.getElementById('next-month');

if (prevMonthBtn) {

prevMonthBtn.addEventListener('click', () => {

this.currentDate.setMonth(this.currentDate.getMonth() - 1);

this.updateCalendar();

});

}

if (nextMonthBtn) {

nextMonthBtn.addEventListener('click', () => {

this.currentDate.setMonth(this.currentDate.getMonth() + 1);

this.updateCalendar();

});

}

// Корзина услуг

const addServiceBtn = document.getElementById('add-service-btn');

const clearCartBtn = document.getElementById('clear-cart-btn');

const addToCartBtn = document.getElementById('add-to-cart-btn');

const checkoutBtn = document.getElementById('checkout-btn');

if (addServiceBtn) {

addServiceBtn.addEventListener('click', () => this.openAddServiceModal());

}

if (clearCartBtn) {

clearCartBtn.addEventListener('click', () => {

if (confirm('Очистить корзину услуг?')) {

this.cart = {};

localStorage.removeItem('medicalCart');

this.updateCartDisplay();

}

});

}

if (addToCartBtn) {

addToCartBtn.addEventListener('click', () => this.addSelectedServiceToCart());

}

if (checkoutBtn) {

checkoutBtn.addEventListener('click', () => this.checkoutCart());

}

// Модальное окно

const modal = document.getElementById('service-modal');

const addModalBtn = document.getElementById('service-add-btn');

const cancelModalBtn = document.getElementById('service-cancel-btn');

if (addModalBtn) {

addModalBtn.addEventListener('click', () => this.addCustomServiceToCart());

}

if (cancelModalBtn) {

cancelModalBtn.addEventListener('click', () => {

if (modal) {

modal.style.display = 'none';

}

});

}

if (modal) {

modal.addEventListener('click', (e) => {

if (e.target === modal) {

modal.style.display = 'none';

}

});

}

}

// ---------- КЛИНИКИ ----------

renderClinics(filter = '') {

const list = document.getElementById('clinics-list');

if (!list) return;

const filterLower = filter.toLowerCase();

const entries = Object.entries(this.clinics);

if (!entries.length) {

list.innerHTML = '<p>Клиники не найдены.</p>';

return;

}

const items = entries

.filter(([id, clinic]) => {

if (!filterLower) return true;

const nameMatch = clinic.name.toLowerCase().includes(filterLower);

const serviceMatch = (clinic.services || []).some(s =>

String(s.name).toLowerCase().includes(filterLower)

);

return nameMatch || serviceMatch;

})

.map(([id, clinic]) => {

const servicesHtml = (clinic.services || [])

.map(s => `<li>${s.name} — ${s.price} ₽</li>`)

.join('');

return `

<div class="clinic-card">

<h3>${clinic.name}</h3>

<p class="clinic-address"><i class="fas fa-location-dot"></i> ${clinic.address}</p>

<p class="clinic-phone"><i class="fas fa-phone"></i> ${clinic.phone}</p>

<p class="clinic-desc">${clinic.description || ''}</p>

<h4>Услуги:</h4>

<ul class="clinic-services">

${servicesHtml || '<li>Услуги не указаны</li>'}

</ul>

</div>

`;

});

list.innerHTML = items.join('');

}

populateClinicSelect() {

const clinicSelect = document.getElementById('clinic-select');

if (!clinicSelect) return;

clinicSelect.innerHTML = '<option value="">-- Выберите клинику --</option>';

Object.values(this.clinics).forEach(clinic => {

const option = document.createElement('option');

option.value = clinic.id;

option.textContent = clinic.name;

clinicSelect.appendChild(option);

});

}

updateDoctorSelect() {

const clinicSelect = document.getElementById('clinic-select');

const doctorSelect = document.getElementById('doctor-select');

if (!clinicSelect || !doctorSelect) return;

const clinicId = clinicSelect.value;

doctorSelect.innerHTML = '<option value="">-- Выберите врача --</option>';

if (!clinicId) return;

const doctors = new Set(

this.appointments

.filter(a => a.clinicId === clinicId)

.map(a => a.doctor)

);

Array.from(doctors).forEach(doc => {

const option = document.createElement('option');

option.value = doc;

option.textContent = doc;

doctorSelect.appendChild(option);

});

}

clearTimeSlots() {

const container = document.getElementById('time-slots');

if (!container) return;

container.innerHTML = '<p>Выберите клинику и врача для отображения доступного времени.</p>';

this.selectedAppointmentId = null;

}

renderTimeSlots() {

const clinicSelect = document.getElementById('clinic-select');

const doctorSelect = document.getElementById('doctor-select');

const container = document.getElementById('time-slots');

if (!clinicSelect || !doctorSelect || !container) return;

const clinicId = clinicSelect.value;

const doctor = doctorSelect.value;

if (!clinicId || !doctor) {

this.clearTimeSlots();

return;

}

const now = new Date();

const slots = this.appointments.filter(a => {

if (a.clinicId !== clinicId || a.doctor !== doctor) return false;

const slotDate = new Date(a.time);

return slotDate >= now; // скрываем прошедшие слоты

});

if (!slots.length) {

container.innerHTML = '<p>Для выбранного врача нет доступных слотов.</p>';

return;

}

// сортируем по времени

slots.sort((a, b) => new Date(a.time) - new Date(b.time));

const buttons = slots.map(slot => {

const takenClass = slot.isTaken ? 'taken' : '';

const disabledAttr = slot.isTaken ? 'disabled' : '';

const label = `${slot.time} — ${slot.cost} ₽${slot.isTaken ? ' (занято)' : ''}`;

return `

<button

class="time-slot ${takenClass}"

data-id="${slot.id}"

${disabledAttr}

>

${label}

</button>

`;

});

container.innerHTML = buttons.join('');

container.querySelectorAll('.time-slot').forEach(btn => {

if (btn.disabled) return;

btn.addEventListener('click', () => {

container.querySelectorAll('.time-slot').forEach(b => b.classList.remove('selected'));

btn.classList.add('selected');

this.selectedAppointmentId = btn.dataset.id;

this.updateBookButtonState();

});

});

this.selectedAppointmentId = null;

this.updateBookButtonState();

}

updateBookButtonState() {

const btn = document.getElementById('book-btn');

if (!btn) return;

btn.disabled = !this.selectedAppointmentId;

}

async bookAppointment() {

if (!this.selectedAppointmentId) {

this.showNotification('Выберите время приёма.', 'error');

return;

}

try {

const response = await fetch('/api/book', {

method: 'POST',

headers: {

'Content-Type': 'application/json',

},

body: JSON.stringify({ id: this.selectedAppointmentId }),

});

const result = await response.json();

if (!response.ok || !result.success) {

this.showNotification(result.message || 'Не удалось записаться.', 'error');

return;

}

this.showNotification(result.message || 'Запись успешно создана.');

await this.loadData();

this.populateClinicSelect();

this.populateServiceSelect();

this.renderTimeSlots();

this.updateCalendar();

await this.loadMyAppointments();

} catch (e) {

console.error('Ошибка записи:', e);

this.showNotification('Произошла ошибка при записи.', 'error');

}

}

// ---------- КАЛЕНДАРЬ ----------

// Вспомогательные функции для календаря (детализация по дню)

_pad2(n) {

return String(n).padStart(2, '0');

}

_formatYMD(year, monthIndex, day) {

// monthIndex: 0..11

return `${year}-${this._pad2(monthIndex + 1)}-${this._pad2(day)}`;

}

_escapeHtml(value) {

return String(value ?? '')

.replaceAll('&', '&')

.replaceAll('<', '<')

.replaceAll('>', '>')

.replaceAll('"', '"')

.replaceAll("'", ''');

}

_ensureCalendarDayDetailsContainer() {

let details = document.getElementById('calendar-day-details');

if (details) return details;

const calendarTab = document.getElementById('calendar');

if (!calendarTab) return null;

details = document.createElement('div');

details.id = 'calendar-day-details';

// Используем существующие стили "карточки" (без изменения дизайна/верстки)

details.className = 'booking-form';

details.style.marginTop = '12px';

details.innerHTML = `

<h3><i class="fas fa-list"></i> Занятость по дню</h3>

<p class="hint">Нажмите на день в календаре выше, чтобы увидеть время и врача.</p>

`;

calendarTab.appendChild(details);

return details;

}

_renderCalendarDayDetails(dateStr) {

const details = this._ensureCalendarDayDetailsContainer();

if (!details) return;

const [y, m, d] = dateStr.split('-').map(x => parseInt(x, 10));

const dateObj = new Date(y, (m || 1) - 1, d || 1);

const niceDate = dateObj.toLocaleDateString('ru-RU', {

year: 'numeric',

month: 'long',

day: 'numeric',

});

const daySlots = this.appointments

.filter(a => (a.time || '').startsWith(dateStr))

.map(a => ({

time: (a.time || '').split(' ')[1] || '',

doctor: a.doctor || '',

clinic: a.clinicName || '',

isTaken: !!a.isTaken,

}))

.sort((a, b) => (a.time || '').localeCompare(b.time || ''));

if (!daySlots.length) {

details.innerHTML = `

<h3><i class="fas fa-list"></i> Занятость по дню</h3>

<p><strong>${this._escapeHtml(niceDate)}</strong></p>

<p class="hint">На эту дату нет слотов.</p>

`;

return;

}

const taken = daySlots.filter(s => s.isTaken);

const free = daySlots.filter(s => !s.isTaken);

const renderItems = (items, takenStyle) => {

if (!items.length) {

return '<p class="hint">Нет.</p>';

}

const cards = items.map(s => {

const line = `${this._escapeHtml(s.time)} — ${this._escapeHtml(s.doctor)}${s.clinic ? ' (' + this._escapeHtml(s.clinic) + ')' : ''}`;

// Используем существующий стиль "time-slot"; для занятых — класс taken

return `<div class="time-slot ${takenStyle ? 'taken' : ''}">${line}</div>`;

});

return `<div class="time-slots">${cards.join('')}</div>`;

};

details.innerHTML = `

<h3><i class="fas fa-list"></i> Занятость по дню</h3>

<p><strong>${this._escapeHtml(niceDate)}</strong></p>

<div class="form-group">

<h4>Занято (${taken.length})</h4>

${renderItems(taken, true)}

</div>

<div class="form-group">

<h4>Свободно (${free.length})</h4>

${renderItems(free, false)}

</div>

`;

}

updateCalendar() {

const calendarBody = document.getElementById('calendar-body');

const monthPicker = document.getElementById('month-picker');

if (!calendarBody || !monthPicker) return;

// Обновляем/создаём панель детализации (чтобы сразу было понятно, что делать)

this._ensureCalendarDayDetailsContainer();

const year = this.currentDate.getFullYear();

const month = this.currentDate.getMonth();

const firstDay = new Date(year, month, 1);

const lastDay = new Date(year, month + 1, 0);

const monthNames = [

'Январь', 'Февраль', 'Март', 'Апрель',

'Май', 'Июнь', 'Июль', 'Август',

'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь',

];

monthPicker.textContent = `${monthNames[month]} ${year}`;

const startWeekDay = (firstDay.getDay() + 6) % 7; // Пн=0

const daysInMonth = lastDay.getDate();

calendarBody.innerHTML = '';

// Группируем слоты по дате, чтобы показывать счетчики в ячейке

const slotsByDate = {};

this.appointments.forEach(a => {

const dateOnly = (a.time || '').split(' ')[0]; // YYYY-MM-DD

if (!dateOnly) return;

if (!slotsByDate[dateOnly]) {

slotsByDate[dateOnly] = { total: 0, taken: 0 };

}

slotsByDate[dateOnly].total += 1;

if (a.isTaken) {

slotsByDate[dateOnly].taken += 1;

}

});

// Пустые ячейки в начале месяца

for (let i = 0; i < startWeekDay; i++) {

const emptyCell = document.createElement('div');

emptyCell.className = 'calendar-day empty';

calendarBody.appendChild(emptyCell);

}

// Дни месяца

for (let day = 1; day <= daysInMonth; day++) {

const cell = document.createElement('div');

cell.className = 'calendar-day';

const dateStr = this._formatYMD(year, month, day);

const info = slotsByDate[dateStr];

const today = new Date();

const isToday =

year === today.getFullYear() &&

month === today.getMonth() &&

day === today.getDate();

if (isToday) {

cell.classList.add('today');

}

if (info) {

if (info.taken > 0) {

cell.classList.add('busy');

} else if (info.total > 0) {

cell.classList.add('free');

}

}

const countText = info

? `${info.taken}/${info.total} записей`

: 'Нет записей';

cell.innerHTML = `

<div class="day-number">${day}</div>

<div class="day-info">${countText}</div>

`;

// Клик по дню — показываем детализацию (время + врач)

cell.addEventListener('click', () => {

this._renderCalendarDayDetails(dateStr);

});

calendarBody.appendChild(cell);

}

}

// ---------- МОИ ЗАПИСИ ----------

async loadMyAppointments() {

try {

const res = await fetch('/api/my_appointments');

if (!res.ok) {

// 403 — не авторизован, тихо выходим

return;

}

const data = await res.json();

if (!data.success) return;

this.myAppointments = data.appointments || [];

this.renderMyAppointments();

} catch (e) {

console.error('Ошибка загрузки моих записей:', e);

}

}

renderMyAppointments() {

const container = document.getElementById('my-appointments-list');

if (!container) return;

if (!this.myAppointments.length) {

container.innerHTML = '<p>У вас пока нет записей.</p>';

return;

}

const items = this.myAppointments.map(appt => {

const dt = new Date(appt.appointment_time);

const dateStr = dt.toLocaleDateString('ru-RU', {

year: 'numeric',

month: 'long',

day: 'numeric',

});

const timeStr = dt.toLocaleTimeString('ru-RU', {

hour: '2-digit',

minute: '2-digit',

});

const createdStr = appt.created_at

? new Date(appt.created_at).toLocaleString('ru-RU')

: '';

return `

<div class="my-appointment-card">

<div class="my-appointment-main">

<strong>${appt.clinic_name}</strong>

<span class="my-appointment-doctor">${appt.doctor}</span>

</div>

<div class="my-appointment-meta">

<span><i class="fas fa-calendar-day"></i> ${dateStr}</span>

<span><i class="fas fa-clock"></i> ${timeStr}</span>

<span><i class="fas fa-ruble-sign"></i> ${appt.cost} ₽</span>

</div>

<div class="my-appointment-created">

Создано: ${createdStr}

</div>

</div>

`;

});

container.innerHTML = items.join('');

}

// --------- КОРЗИНА ----------

populateServiceSelect() {

const select = document.getElementById('service-select');

if (!select) return;

select.innerHTML = '<option value="">-- Выберите услугу --</option>';

Object.values(this.clinics).forEach(clinic => {

(clinic.services || []).forEach(service => {

const value = JSON.stringify({

clinic: clinic.name,

name: service.name,

price: service.price,

});

const option = document.createElement('option');

option.value = value;

option.textContent = `${clinic.name} — ${service.name} (${service.price} ₽)`;

select.appendChild(option);

});

});

}

addSelectedServiceToCart() {

const select = document.getElementById('service-select');

if (!select) return;

const value = select.value;

if (!value) {

this.showNotification('Выберите услугу для добавления в корзину.', 'error');

return;

}

const data = JSON.parse(value);

const key = `${data.clinic}::${data.name}::${data.price}`;

if (!this.cart[key]) {

this.cart[key] = {

clinic: data.clinic,

name: data.name,

price: data.price,

count: 0,

};

}

this.cart[key].count += 1;

this.persistCart();

this.updateCartDisplay();

this.showNotification('Услуга добавлена в корзину.');

}

openAddServiceModal() {

const modal = document.getElementById('service-modal');

if (!modal) return;

document.getElementById('service-name').value = '';

document.getElementById('service-price').value = '';

document.getElementById('service-clinic').value = '';

modal.style.display = 'block';

}

addCustomServiceToCart() {

const nameInput = document.getElementById('service-name');

const priceInput = document.getElementById('service-price');

const clinicInput = document.getElementById('service-clinic');

const name = nameInput.value.trim();

const price = parseInt(priceInput.value, 10);

const clinic = clinicInput.value.trim() || 'Не указана';

if (!name || !price || price <= 0) {

this.showNotification('Введите корректные название и стоимость услуги.', 'error');

return;

}

const key = `${clinic}::${name}::${price}`;

if (!this.cart[key]) {

this.cart[key] = {

clinic,

name,

price,

count: 0,

};

}

this.cart[key].count += 1;

this.persistCart();

this.updateCartDisplay();

const modal = document.getElementById('service-modal');

if (modal) {

modal.style.display = 'none';

}

this.showNotification('Произвольная услуга добавлена в корзину.');

}

async checkoutCart() {

const entries = Object.values(this.cart);

if (!entries.length) {

this.showNotification('Корзина пуста.', 'error');

return;

}

let totalPrice = 0;

entries.forEach(item => {

totalPrice += item.price * item.count;

});

try {

const res = await fetch('/api/cart/order', {

method: 'POST',

headers: { 'Content-Type': 'application/json' },

body: JSON.stringify({

items: entries,

total_price: totalPrice,

}),

});

const data = await res.json();

if (!res.ok || !data.success) {

this.showNotification(data.message || 'Не удалось оформить заказ.', 'error');

return;

}

this.showNotification(data.message || 'Заказ успешно оформлен.');

this.cart = {};

this.persistCart();

this.updateCartDisplay();

} catch (e) {

console.error('Ошибка оформления заказа:', e);

this.showNotification('Ошибка при оформлении заказа.', 'error');

}

}

persistCart() {

localStorage.setItem('medicalCart', JSON.stringify(this.cart));

}

updateCartDisplay() {

const container = document.getElementById('cart-items');

const totalCountEl = document.getElementById('cart-total-count');

const totalEl = document.getElementById('cart-total');

if (!container || !totalCountEl || !totalEl) return;

const entries = Object.values(this.cart);

if (!entries.length) {

container.innerHTML = '<p>Корзина пуста.</p>';

totalCountEl.textContent = '0';

totalEl.textContent = '0';

return;

}

let totalCount = 0;

let totalPrice = 0;

const rows = entries.map(item => {

totalCount += item.count;

totalPrice += item.price * item.count;

const key = `${item.clinic}::${item.name}::${item.price}`;

return `

<div class="cart-item">

<div class="cart-item-main">

<strong>${item.name}</strong>

<span class="cart-item-clinic">${item.clinic}</span>

</div>

<div class="cart-item-meta">

<span>${item.price} ₽ × ${item.count}</span>

<button class="btn btn-small btn-danger" data-key="${key}">

<i class="fas fa-times"></i>

</button>

</div>

</div>

`;

});

container.innerHTML = rows.join('');

totalCountEl.textContent = String(totalCount);

totalEl.textContent = String(totalPrice);

container.querySelectorAll('button[data-key]').forEach(btn => {

btn.addEventListener('click', () => {

const key = btn.dataset.key;

delete this.cart[key];

this.persistCart();

this.updateCartDisplay();

});

});

}

// ---------- УВЕДОМЛЕНИЯ ----------

showNotification(message, type = 'success') {

const notification = document.getElementById('notification');

if (!notification) {

alert(message);

return;

}

notification.textContent = message;

notification.className = 'notification ' + (type === 'error' ? 'error' : 'success');

notification.style.display = 'block';

clearTimeout(this._notificationTimeout);

this._notificationTimeout = setTimeout(() => {

notification.style.display = 'none';

}, 3000);

}

}

// Инициализация

document.addEventListener('DOMContentLoaded', () => {

window.medicalPortal = new MedicalPortal();

});