- •Курсовая работа по дисциплине
- •1. Введение
- •2. Постановка задачи
- •3. Обоснование выбора технологий
- •4. Разработка структуры клиентской части
- •5. Разработка собственных компонентов
- •5.1. Модель представления
- •5.2. Слой управления состоянием
- •5.3. Компоненты пользовательского интерфейса
- •5.4. Модуль взаимодействия с api
- •6. Сценарии пользователя
- •7. Заключение
- •8. Список литературы
- •9. Приложение
8. Список литературы
Бирюков, М.А. Лекции по дисциплине «Web-технологии». – СПб.: СПбГУТ, – [Электронный ресурс], [дата обращения – ноябрь 2025 г.].
Oracle Corporation. MySQL 8.0 Reference Manual. — [Электронный ресурс]. — Режим доступа: https://dev.mysql.com/doc/refman/8.0/en/, свободный. — [Дата обращения: ноябрь 2025 г.]
Основы Web - технологий: учеб. пособие / П.Б. Храмцов [и др.]. - М.: Изд-во Интуит.ру “Интернет-Университет Информационных Технологий”, 2013. - 512 с.
Пауэл Томас, А. Справочник программиста / Томас А Пауэл, Д. Уитворт. - М.: АСТ, Мн.: Харвест, 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();
});
