Может быть, кому-то пригодится.
Кадровики у нас ленивые (а может, просто не умеют) постоянно формировать график для сменных дежурных. Я понимаю, что в Excel такой табель заполняется за 5 минут, но мне захотелось сделать это проще.
Я выбрал XAMPP — просто и быстро. Думаю, его установка не составит труда.
В моём случае каталог для проекта назвал
schedule.
Набор скриптов и шаблонов
HTML-шаблоны
Сделал два шаблона:
-
Для работы с графиком
-
Для печатной формы
Первый шаблон (index.php, лежит в корне проекта):
<?php include 'includes/db.php'; ?> <!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <title>График смен</title> <link rel="stylesheet" href="css/style.css"> </head> <body> <div class="container"> <div class="nav-tabs"> <a class="active" onclick="showTab('schedule')">График смен</a> <a href="view_schedule.php" target="_blank">Версия для печати</a> <a onclick="showTab('employees')">Сотрудники</a> </div> <div id="schedule-tab" class="tab-content active"> <h2>График смен на <span id="current-month"></span></h2> <div class="controls"> <button onclick="addEmployeeRow()">Добавить строку</button> <button onclick="autoGenerateSelectedRow()">Сгенерировать график 2/2</button> </div> <div class="schedule-wrapper"> <table id="schedule-table"> <thead id="schedule-header"> <tr> <th>Сотрудник</th> <!-- Динамические заголовки дат --> </tr> </thead> <tbody id="schedule-body"> <!-- Строки будут добавляться динамически --> </tbody> </table> </div> </div> <div id="employees-tab" class="tab-content"> <h2>Управление сотрудниками</h2> <div class="employee-form"> <input type="text" id="new-fullname" placeholder="ФИО сотрудника"> <button onclick="addEmployee()">Добавить</button> </div> <div class="employee-list"> <select id="employee-list" size="8" multiple></select> <button onclick="removeEmployee()">Удалить выбранных</button> </div> <div class="status-controls"> <h3>Установка статуса</h3> <select id="status-employee"></select> <label>С: <input type="date" id="date-from"></label> <label>По: <input type="date" id="date-to"></label> <button onclick="setStatus('vacation')">Отпуск</button> <button onclick="setStatus('sick_leave')">Больничный</button> </div> </div> </div> <script src="js/main.js"></script> <script src="js/schedule.js"></script> <script src="js/employees.js"></script> </body> </html>
На основной странице два раздела:
Сам график
Управление сотрудниками (добавление, удаление, установка статусов: больничный и отпуск)


Скрипты для работы с сотрудниками и статусами
add_employee.php (добавление сотрудников, лежит в schedule/api):
<?php include '../includes/db.php'; header('Content-Type: application/json'); $data = json_decode(file_get_contents("php://input"), true); $fullname = trim($data['fullname']); if (empty($fullname)) { http_response_code(400); echo json_encode(['error' => 'Fullname is required']); exit; } $stmt = $conn->prepare("INSERT INTO employees (fullname) VALUES (?)"); $stmt->bind_param("s", $fullname); if ($stmt->execute()) { echo json_encode(['success' => true, 'id' => $stmt->insert_id]); } else { http_response_code(500); echo json_encode(['error' => 'Database error']); } ?>
delete_employee.php (удаление сотрудника, лежит в schedule/api):
<?php include '../includes/db.php'; header('Content-Type: application/json'); $data = json_decode(file_get_contents("php://input"), true); $id = (int)$data['id']; $stmt = $conn->prepare("DELETE FROM employees WHERE id = ?"); $stmt->bind_param("i", $id); if ($stmt->execute()) { echo json_encode(['success' => true]); } else { http_response_code(500); echo json_encode(['error' => 'Database error']); } ?>
get_schedule.php (возвращает график смен сотрудников для отображения расписания, включая статусы, лежит в schedule/api):
<?php include '../includes/db.php'; header('Content-Type: application/json'); try { $result = []; // Получаем всех сотрудников $employees = $conn->query("SELECT id, fullname FROM employees ORDER BY fullname"); // Подготавливаем запросы заранее $shiftQuery = $conn->prepare("SELECT shift_date, shift_type FROM shifts WHERE employee_id = ?"); $statusQuery = $conn->prepare("SELECT start_date, end_date, status_type FROM employee_status WHERE employee_id = ?"); while ($emp = $employees->fetch_assoc()) { $emp_id = $emp['id']; $schedule = []; // Объединенные данные смен и статусов // Получаем смены $shiftQuery->bind_param("i", $emp_id); $shiftQuery->execute(); $shifts = $shiftQuery->get_result()->fetch_all(MYSQLI_ASSOC); foreach ($shifts as $shift) { $schedule[$shift['shift_date']] = [ 'type' => $shift['shift_type'], 'is_status' => false ]; } // Получаем статусы $statusQuery->bind_param("i", $emp_id); $statusQuery->execute(); $statuses = $statusQuery->get_result()->fetch_all(MYSQLI_ASSOC); foreach ($statuses as $status) { $start = new DateTime($status['start_date']); $end = new DateTime($status['end_date']); // Перебираем все даты периода статуса (включая последний день) for ($date = $start; $date <= $end; $date->modify('+1 day')) { $dateStr = $date->format('Y-m-d'); $schedule[$dateStr] = [ 'type' => $status['status_type'], 'is_status' => true ]; } } // Формируем результат только с типами (статусы перезаписывают смены) $formattedShifts = []; foreach ($schedule as $date => $item) { $formattedShifts[$date] = $item['type']; } $result[] = [ "id" => $emp_id, "fullname" => $emp['fullname'], "shifts" => $formattedShifts ]; } echo json_encode($result); } catch (Exception $e) { http_response_code(500); echo json_encode(['error' => 'Database error: ' . $e->getMessage()]); } ?>
get_employees.php (отображение списка сотрудников, лежит в schedule/api):
<?php include '../includes/db.php'; header('Content-Type: application/json'); $result = $conn->query("SELECT id, fullname FROM employees ORDER BY fullname"); $employees = []; while ($row = $result->fetch_assoc()) { $employees[] = $row; } echo json_encode($employees); ?>
save_auto_schedule.php (автоматическое сохранение графика после генерации, лежит в schedule/api):
<?php include '../includes/db.php'; header('Content-Type: application/json'); $data = json_decode(file_get_contents("php://input"), true); $employeeId = $data['employee_id']; $shifts = $data['shifts']; try { $conn->begin_transaction(); foreach ($shifts as $shift) { $stmt = $conn->prepare("INSERT INTO shifts (employee_id, shift_date, shift_type) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE shift_type = VALUES(shift_type)"); $stmt->bind_param("iss", $employeeId, $shift['date'], $shift['shiftType']); $stmt->execute(); } $conn->commit(); echo json_encode(['success' => true]); } catch (Exception $e) { $conn->rollback(); http_response_code(500); echo json_encode(['success' => false, 'message' => $e->getMessage()]); } ?>
set_schedule.php (установка и обновление графика для сотрудника, лежит в schedule/api):
<?php include '../includes/db.php'; include '../includes/functions.php'; header('Content-Type: application/json'); $data = json_decode(file_get_contents("php://input"), true); $employeeId = $data['employee_id']; $date = $data['date']; $shiftType = $data['shift_type']; if (!employeeExists($employeeId)) { echo json_encode(['success' => false, 'message' => 'Сотрудник не найден']); exit; } if (!validateDate($date)) { echo json_encode(['success' => false, 'message' => 'Неверный формат даты']); exit; } if (!in_array($shiftType, ['day', 'night', 'off'])) { echo json_encode(['success' => false, 'message' => 'Неверный тип смены']); exit; } $stmt = $conn->prepare("SELECT id FROM shifts WHERE employee_id = ? AND shift_date = ?"); $stmt->bind_param("is", $employeeId, $date); $stmt->execute(); $result = $stmt->get_result(); if ($result->num_rows > 0) { $stmt = $conn->prepare("UPDATE shifts SET shift_type = ? WHERE employee_id = ? AND shift_date = ?"); $stmt->bind_param("sis", $shiftType, $employeeId, $date); } else { $stmt = $conn->prepare("INSERT INTO shifts (employee_id, shift_date, shift_type) VALUES (?, ?, ?)"); $stmt->bind_param("iss", $employeeId, $date, $shiftType); } if ($stmt->execute()) { echo json_encode(['success' => true]); } else { echo json_encode(['success' => false, 'message' => 'Ошибка базы данных']); } ?>
set_status.php (установка статуса для сотрудника за период, лежит в schedule/api):
<?php include '../includes/db.php'; header('Content-Type: application/json'); $data = json_decode(file_get_contents("php://input"), true); $employee_id = (int)$data['employee_id']; $start_date = $data['start_date']; $end_date = $data['end_date']; $status_type = $data['status_type']; if (empty($employee_id) || empty($start_date) || empty($end_date) || empty($status_type)) { http_response_code(400); echo json_encode(['error' => 'All fields are required']); exit; } $stmt = $conn->prepare("DELETE FROM shifts WHERE employee_id = ? AND shift_date BETWEEN ? AND ?"); $stmt->bind_param("iss", $employee_id, $start_date, $end_date); $stmt->execute(); $stmt = $conn->prepare("INSERT INTO employee_status (employee_id, start_date, end_date, status_type) VALUES (?, ?, ?, ?)"); $stmt->bind_param("isss", $employee_id, $start_date, $end_date, $status_type); if ($stmt->execute()) { echo json_encode(['success' => true]); } else { http_response_code(500); echo json_encode(['error' => 'Database error']); } ?>
status.php (взаимодействие с БД, лежит в schedule/api):
<?php include '../includes/db.php'; include '../includes/functions.php'; header('Content-Type: application/json'); $method = $_SERVER['REQUEST_METHOD']; switch ($method) { case 'GET': getEmployeeStatus(); break; case 'POST': setEmployeeStatus(); break; case 'DELETE': removeEmployeeStatus(); break; default: http_response_code(405); echo json_encode(['error' => 'Method not allowed']); } function getEmployeeStatus() { global $conn; $employeeId = isset($_GET['employee_id']) ? (int)$_GET['employee_id'] : null; $date = isset($_GET['date']) ? $_GET['date'] : null; $sql = "SELECT es.*, e.fullname FROM employee_status es JOIN employees e ON es.employee_id = e.id"; $conditions = []; $params = []; $types = ''; if ($employeeId) { $conditions[] = "employee_id = ?"; $params[] = $employeeId; $types .= 'i'; } if ($date) { $conditions[] = "? BETWEEN start_date AND end_date"; $params[] = $date; $types .= 's'; } if (!empty($conditions)) { $sql .= " WHERE " . implode(" AND ", $conditions); } $stmt = $conn->prepare($sql); if (!empty($params)) { $stmt->bind_param($types, ...$params); } $stmt->execute(); $result = $stmt->get_result(); $statuses = []; while ($row = $result->fetch_assoc()) { $statuses[] = $row; } echo json_encode($statuses); } function setEmployeeStatus() { global $conn; $data = json_decode(file_get_contents("php://input"), true); $errors = []; if (empty($data['employee_id'])) { $errors[] = 'Employee ID is required'; } if (empty($data['start_date']) || !validateDate($data['start_date'])) { $errors[] = 'Valid start date is required'; } if (empty($data['end_date']) || !validateDate($data['end_date'])) { $errors[] = 'Valid end date is required'; } if (empty($data['status_type']) || !in_array($data['status_type'], ['vacation', 'sick_leave'])) { $errors[] = 'Valid status type is required (vacation or sick_leave)'; } if (!empty($errors)) { http_response_code(400); echo json_encode(['errors' => $errors]); return; } if (!employeeExists($data['employee_id'])) { http_response_code(404); echo json_encode(['error' => 'Employee not found']); return; } $deleteStmt = $conn->prepare("DELETE FROM shifts WHERE employee_id = ? AND shift_date BETWEEN ? AND ?"); $deleteStmt->bind_param("iss", $data['employee_id'], $data['start_date'], $data['end_date']); $deleteStmt->execute(); $insertStmt = $conn->prepare("INSERT INTO employee_status (employee_id, start_date, end_date, status_type) VALUES (?, ?, ?, ?)"); $insertStmt->bind_param("isss", $data['employee_id'], $data['start_date'], $data['end_date'], $data['status_type']); if ($insertStmt->execute()) { echo json_encode(['success' => true, 'id' => $insertStmt->insert_id]); } else { http_response_code(500); echo json_encode(['error' => 'Database error']); } } function removeEmployeeStatus() { global $conn; $statusId = isset($_GET['id']) ? (int)$_GET['id'] : null; if (!$statusId) { http_response_code(400); echo json_encode(['error' => 'Status ID is required']); return; } $stmt = $conn->prepare("DELETE FROM employee_status WHERE id = ?"); $stmt->bind_param("i", $statusId); if ($stmt->execute()) { echo json_encode(['success' => true]); } else { http_response_code(500); echo json_encode(['error' => 'Database error']); } } ?>
Скрипты выполняют следующие операции:
Добавление сотрудников в систему
Генерацию рабочих графиков
Установку статусов сотрудников на заданный период
Особенности работы со статусами:
Функцияset_statusимеет приоритет над статусами смен в графике. Это означает, что если сотрудник, например, находится в отпуске с 1 по 10 число, система не будет проставлять ему рабочие смены на этот период.
Теперь немного JavaScript:
employees.js (обработка действий пользователя: добавление, удаление, установка статусов, лежит в schedule/js):
function fetchEmployees() { fetch('api/get_employees.php') .then(response => response.json()) .then(data => { const employeeList = document.getElementById('employee-list'); const statusEmployee = document.getElementById('status-employee'); employeeList.innerHTML = ''; statusEmployee.innerHTML = ''; data.forEach(employee => { const option1 = document.createElement('option'); option1.value = employee.id; option1.textContent = employee.fullname; employeeList.appendChild(option1); const option2 = document.createElement('option'); option2.value = employee.id; option2.textContent = employee.fullname; statusEmployee.appendChild(option2); }); }); } function addEmployee() { const fullname = document.getElementById('new-fullname').value.trim(); if (!fullname) { alert('Введите ФИО сотрудника'); return; } fetch('api/add_employee.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fullname: fullname }) }) .then(response => { if (!response.ok) throw new Error('Ошибка сервера'); return response.json(); }) .then(() => { document.getElementById('new-fullname').value = ''; fetchEmployees(); }) .catch(error => { console.error('Error:', error); alert('Ошибка при добавлении сотрудника'); }); } function removeEmployee() { const employeeList = document.getElementById('employee-list'); const selectedOptions = Array.from(employeeList.selectedOptions); if (selectedOptions.length === 0) { alert('Выберите сотрудников для удаления'); return; } if (!confirm(`Удалить ${selectedOptions.length} сотрудников?`)) { return; } const promises = selectedOptions.map(option => { return fetch('api/delete_employee.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: option.value }) }); }); Promise.all(promises) .then(() => fetchEmployees()) .catch(error => { console.error('Error:', error); alert('Ошибка при удалении сотрудников'); }); } function setStatus(statusType) { const employeeId = document.getElementById('status-employee').value; const dateFrom = document.getElementById('date-from').value; const dateTo = document.getElementById('date-to').value; if (!employeeId || !dateFrom || !dateTo) { alert('Заполните все поля'); return; } fetch('api/set_status.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ employee_id: employeeId, start_date: dateFrom, end_date: dateTo, status_type: statusType }) }) .then(response => { if (!response.ok) throw new Error('Ошибка сервера'); return response.json(); }) .then(() => { alert('Статус успешно установлен'); loadSchedule(); }) .catch(error => { console.error('Error:', error); alert('Ошибка при установке статуса'); }); }
main.js (настройка интерфейса и загрузка данных, лежит в schedule/js):
document.addEventListener('DOMContentLoaded', function() { const months = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; const currentDate = new Date(); document.getElementById('current-month').textContent = `${months[currentDate.getMonth()]} ${currentDate.getFullYear()}`; fetchEmployees(); initSchedule(); const today = new Date(); document.getElementById('date-from').valueAsDate = today; document.getElementById('date-to').valueAsDate = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000); }); function showTab(tabId) { document.querySelectorAll('.tab-content').forEach(tab => { tab.classList.remove('active'); }); document.querySelectorAll('.nav-tabs a').forEach(tab => { tab.classList.remove('active'); }); document.getElementById(tabId + '-tab').classList.add('active'); event.target.classList.add('active'); }
schedule.js (основная логика работы с графиком, лежит в schedule/js):
\\Имя файла schedule.js. Отвечает за ВСЕ, фаил лежит в schedule\js function initSchedule() { const headerRow = document.querySelector('#schedule-header tr'); const currentDate = new Date(); const daysInMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0).getDate(); headerRow.innerHTML = '<th>Сотрудник</th>'; for (let day = 1; day <= daysInMonth; day++) { const th = document.createElement('th'); th.textContent = day; headerRow.appendChild(th); } const addTh = document.createElement('th'); addTh.innerHTML = '<button onclick="addEmployeeRow()">+</button>'; headerRow.appendChild(addTh); loadSchedule(); } function addEmployeeRow(employeeId = null) { const tbody = document.getElementById('schedule-body'); const currentDate = new Date(); const daysInMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0).getDate(); const row = document.createElement('tr'); const nameCell = document.createElement('td'); nameCell.className = 'name-cell'; const select = document.createElement('select'); select.innerHTML = '<option value="">Выберите сотрудника</option>'; const employeeList = document.getElementById('employee-list'); if (employeeList) { Array.from(employeeList.options).forEach(option => { select.innerHTML += `<option value="${option.value}">${option.text}</option>`; }); } if (employeeId) { select.value = employeeId; } select.addEventListener('change', function() { updateEmployeeSchedule(this.value); }); nameCell.appendChild(select); row.appendChild(nameCell); for (let day = 1; day <= daysInMonth; day++) { const cell = document.createElement('td'); cell.className = 'shift-cell'; cell.textContent = '-'; cell.onclick = function() { setShiftForCell(this, row.rowIndex - 1, day); }; row.appendChild(cell); } const deleteCell = document.createElement('td'); deleteCell.innerHTML = '<button onclick="removeScheduleRow(this)">×</button>'; row.appendChild(deleteCell); tbody.appendChild(row); return row; } function setShiftForCell(cell, rowIndex, day) { const shiftType = prompt('Выберите тип смены:\nД - День\nН - Ночь\nВ - Выходной')?.toUpperCase(); if (shiftType && ['Д', 'Н', 'В'].includes(shiftType)) { cell.textContent = shiftType; cell.className = `shift-cell ${ shiftType === 'Д' ? 'day-shift' : shiftType === 'Н' ? 'night-shift' : 'day-off' }`; // Если это одна из первых двух ячеек, предлагаем сгенерировать остальное if (day <= 2) { const row = document.querySelector(`#schedule-body tr:nth-child(${rowIndex + 1})`); const firstCell = row.querySelector('td:nth-child(2)'); const secondCell = row.querySelector('td:nth-child(3)'); if (firstCell.textContent !== '-' && secondCell.textContent !== '-' && confirm('Сгенерировать остальные смены на основе первых двух дней?')) { generatePairSchedule(rowIndex, [ firstCell.textContent, secondCell.textContent ]); } } } } // Основная функция парной генерации графика function generatePairSchedule(rowIndex, firstPair) { const row = document.querySelector(`#schedule-body tr:nth-child(${rowIndex + 1})`); if (!row) return; const cells = row.querySelectorAll('.shift-cell'); const daysInMonth = cells.length; // Устанавливаем первую пару if (firstPair && firstPair.length === 2) { cells[0].textContent = firstPair[0]; cells[0].className = `shift-cell ${getShiftClass(firstPair[0])}`; cells[1].textContent = firstPair[1]; cells[1].className = `shift-cell ${getShiftClass(firstPair[1])}`; } // Генерируем остальные дни на основе пар for (let i = 2; i < daysInMonth; i += 2) { const prevPair = [cells[i-2].textContent, cells[i-1].textContent]; const nextPair = getNextPair(prevPair); // Устанавливаем следующую пару cells[i].textContent = nextPair[0]; cells[i].className = `shift-cell ${getShiftClass(nextPair[0])}`; if (i+1 < daysInMonth) { cells[i+1].textContent = nextPair[1]; cells[i+1].className = `shift-cell ${getShiftClass(nextPair[1])}`; } } saveAutoGeneratedSchedule(row); } // Определяет следующую пару смен на основе предыдущей function getNextPair(prevPair) { const [a, b] = prevPair; // Основные правила чередования if (a === 'Д' && b === 'Н') return ['В', 'В']; // ДН → ВВ if (a === 'В' && b === 'В') return ['Д', 'Н']; // ВВ → ДН if (a === 'В' && b === 'Д') return ['Н', 'В']; // ВД → НВ if (a === 'Н' && b === 'В') return ['В', 'Д']; // НВ → ВД // Если паттерн не распознан, возвращаем выходные return ['В', 'В']; } // Возвращает CSS-класс для типа смены function getShiftClass(shift) { return shift === 'Д' ? 'day-shift' : shift === 'Н' ? 'night-shift' : 'day-off'; } // Генерация графика для выбранной строки function autoGenerateSelectedRow() { const selectedRow = document.querySelector('#schedule-body tr.selected'); if (!selectedRow) { alert('Выберите строку с сотрудником'); return; } const firstPair = prompt('Введите первую пару смен (2 символа: Д, Н или В)\nПримеры:\nДН - День-Ночь\nВВ - Выходные\nВД - Выходной-День\nНВ - Ночь-Выходной') ?.toUpperCase() ?.split(''); if (!firstPair || firstPair.length !== 2 || !['Д', 'Н', 'В'].includes(firstPair[0]) || !['Д', 'Н', 'В'].includes(firstPair[1])) { alert('Некорректный ввод. Введите 2 символа (Д, Н или В)'); return; } const rowIndex = Array.from(document.querySelectorAll('#schedule-body tr')).indexOf(selectedRow); generatePairSchedule(rowIndex, firstPair); } // Быстрая генерация по стандартным паттернам function generateWithPattern(pattern) { const patterns = { 'dayNight': ['Д', 'Н'], 'offOff': ['В', 'В'], 'offDay': ['В', 'Д'], 'nightOff': ['Н', 'В'] }; const selectedRow = document.querySelector('#schedule-body tr.selected'); if (!selectedRow) { alert('Выберите строку с сотрудником'); return; } const rowIndex = Array.from(document.querySelectorAll('#schedule-body tr')).indexOf(selectedRow); generatePairSchedule(rowIndex, patterns[pattern]); } // Сохранение сгенерированного графика function saveAutoGeneratedSchedule(row) { const employeeId = row.querySelector('select').value; if (!employeeId) return; const currentDate = new Date(); const year = currentDate.getFullYear(); const month = currentDate.getMonth(); const shifts = []; row.querySelectorAll('.shift-cell').forEach((cell, dayIndex) => { const day = dayIndex + 1; const date = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; let shiftType; if (cell.textContent === 'Д') shiftType = 'day'; else if (cell.textContent === 'Н') shiftType = 'night'; else shiftType = 'off'; shifts.push({ date, shiftType }); }); fetch('api/save_auto_schedule.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ employee_id: employeeId, shifts }) }) .then(response => response.json()) .then(data => { if (!data.success) { console.error('Ошибка сохранения графика'); } }); } function loadSchedule() { fetch('api/get_schedule.php') .then(response => response.json()) .then(data => { data.forEach(employee => { const row = addEmployeeRow(employee.id); Object.keys(employee.shifts).forEach(date => { const day = new Date(date).getDate(); const cell = row.cells[day]; const shiftType = employee.shifts[date]; switch(shiftType) { case 'day': cell.textContent = 'Д'; cell.className = 'shift-cell day-shift'; break; case 'night': cell.textContent = 'Н'; cell.className = 'shift-cell night-shift'; break; case 'off': cell.textContent = 'В'; cell.className = 'shift-cell day-off'; break; } }); }); }); } function saveSchedule() { // Реализация сохранения всего графика alert('Функция сохранения графика будет реализована'); } function removeScheduleRow(button) { if (confirm('Удалить эту строку из графика?')) { button.closest('tr').remove(); } } // Выделение строки при клике document.addEventListener('click', function(e) { if (e.target.closest('#schedule-body tr')) { document.querySelectorAll('#schedule-body tr').forEach(row => { row.classList.remove('selected'); }); e.target.closest('tr').classList.add('selected'); } }); // Быстрые кнопки генерации document.addEventListener('DOMContentLoaded', function() { const quickControls = document.createElement('div'); quickControls.className = 'quick-patterns'; quickControls.innerHTML = ` <button onclick="generateWithPattern('dayNight')">День-Ночь</button> <button onclick="generateWithPattern('offOff')">Выходные</button> <button onclick="generateWithPattern('offDay')">Вых-День</button> <button onclick="generateWithPattern('nightOff')">Ночь-Вых</button> `; document.querySelector('.controls').appendChild(quickControls); }); // Добавляем в конец файла function setupStatusButtons() { document.querySelectorAll('.name-cell').forEach(cell => { const employeeId = cell.closest('tr').querySelector('select')?.value; const employeeName = cell.textContent; if (employeeId) { const statusBtn = document.createElement('button'); statusBtn.textContent = 'Статус'; statusBtn.style.marginLeft = '10px'; statusBtn.onclick = (e) => { e.stopPropagation(); showStatusModal(employeeId, employeeName); }; cell.appendChild(statusBtn); } }); } // Вызываем эту функцию после загрузки графика // В функции loadSchedule() после заполнения данных добавьте: setupStatusButtons();
status.js (работа со статусами, лежит в schedule/js):
\\Имя файла status.js. Выполняет работу со статусами, фаил лежит в schedule\js function showStatusModal(employeeId, employeeName) { const modal = document.createElement('div'); modal.className = 'modal'; modal.innerHTML = ` <div class="modal-content"> <span class="close">×</span> <h3>Установка статуса для ${employeeName}</h3> <div class="form-group"> <label>Тип статуса:</label> <select id="status-type"> <option value="vacation">Отпуск</option> <option value="sick_leave">Больничный</option> </select> </div> <div class="form-group"> <label>С:</label> <input type="date" id="status-start-date"> </div> <div class="form-group"> <label>По:</label> <input type="date" id="status-end-date"> </div> <button onclick="saveStatus(${employeeId})">Сохранить</button> <div id="status-list"></div> </div> `; document.body.appendChild(modal); const today = new Date(); document.getElementById('status-start-date').valueAsDate = today; document.getElementById('status-end-date').valueAsDate = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000); modal.querySelector('.close').onclick = () => modal.remove(); loadEmployeeStatuses(employeeId); } function loadEmployeeStatuses(employeeId) { fetch(`api/status.php?employee_id=${employeeId}`) .then(response => response.json()) .then(statuses => { const statusList = document.getElementById('status-list'); statusList.innerHTML = '<h4>Текущие статусы:</h4>'; if (statuses.length === 0) { statusList.innerHTML += '<p>Нет активных статусов</p>'; return; } statuses.forEach(status => { const statusDiv = document.createElement('div'); statusDiv.className = 'status-item'; statusDiv.innerHTML = ` <p> ${status.status_type === 'vacation' ? 'Отпуск' : 'Больничный'} с ${status.start_date} по ${status.end_date} <button onclick="deleteStatus(${status.id})">Удалить</button> </p> `; statusList.appendChild(statusDiv); }); }); } function saveStatus(employeeId) { const statusType = document.getElementById('status-type').value; const startDate = document.getElementById('status-start-date').value; const endDate = document.getElementById('status-end-date').value; fetch('api/status.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ employee_id: employeeId, start_date: startDate, end_date: endDate, status_type: statusType }) }) .then(response => response.json()) .then(() => { alert('Статус успешно установлен'); loadEmployeeStatuses(employeeId); loadSchedule(); }) .catch(error => { console.error('Error:', error); alert('Ошибка при установке статуса'); }); } function deleteStatus(statusId) { if (!confirm('Удалить этот статус?')) return; fetch(`api/status.php?id=${statusId}`, { method: 'DELETE' }) .then(response => response.json()) .then(() => { alert('Статус удален'); loadSchedule(); }) .catch(error => { console.error('Error:', error); alert('Ошибка при удалении статуса'); }); } function addStatusStyles() { const style = document.createElement('style'); style.textContent = ` .modal { display: block; position: fixed; z-index: 1; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.4); } .modal-content { background-color: #fefefe; margin: 15% auto; padding: 20px; border: 1px solid #888; width: 50%; } .close { color: #aaa; float: right; font-size: 28px; font-weight: bold; cursor: pointer; } .form-group { margin-bottom: 15px; } .status-item { padding: 10px; border-bottom: 1px solid #eee; } `; document.head.appendChild(style); } document.addEventListener('DOMContentLoaded', addStatusStyles);
PHP для работы с БД
db.php (подключение к БД, лежит в schedule/includes):
<?php $host = 'localhost'; $user = 'root'; $pass = ''; $dbname = 'schedule_db'; $conn = new mysqli($host, $user, $pass, $dbname); if ($conn->connect_error) { die('Ошибка подключения: ' . $conn->connect_error); } ?>
functions.php (взаимодействие компонентов, лежит в schedule/includes):
<?php function employeeExists($employeeId) { global $conn; $stmt = $conn->prepare("SELECT id FROM employees WHERE id = ?"); $stmt->bind_param("i", $employeeId); $stmt->execute(); $result = $stmt->get_result(); return $result->num_rows > 0; } function validateDate($date, $format = 'Y-m-d') { $d = DateTime::createFromFormat($format, $date); return $d && $d->format($format) == $date; } function getEmployeeStatuses($employeeId, $date = null) { global $conn; $sql = "SELECT * FROM employee_status WHERE employee_id = ?"; $params = [$employeeId]; $types = "i"; if ($date) { $sql .= " AND ? BETWEEN start_date AND end_date"; $params[] = $date; $types .= "s"; } $stmt = $conn->prepare($sql); $stmt->bind_param($types, ...$params); $stmt->execute(); $result = $stmt->get_result(); $statuses = []; while ($row = $result->fetch_assoc()) { $statuses[] = $row; } return $statuses; } function hasActiveStatus($employeeId, $date) { global $conn; $stmt = $conn->prepare("SELECT id FROM employee_status WHERE employee_id = ? AND ? BETWEEN start_date AND end_date"); $stmt->bind_param("is", $employeeId, $date); $stmt->execute(); $result = $stmt->get_result(); return $result->num_rows > 0; }
employees.php (веб-интерфейс для управления сотрудниками, лежит в schedule):
<?php include 'includes/db.php'; ?> <!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <title>Сотрудники</title> <link rel="stylesheet" href="css/style.css"> </head> <body> <nav> <a href="index.php">График</a> | <a href="employees.php">Сотрудники</a> </nav> <div class="container"> <h2>Управление сотрудниками</h2> <input type="text" id="new-name" placeholder="Введите ФИО"> <button onclick="addEmployee()">Добавить</button> <br><br> <select id="employee-list"></select> <button onclick="deleteEmployee()">Удалить</button> </div> <script src="js/script.js"></script> </body> </html>
Второй HTML-шаблон
Как я писал в начале, у меня два HTML-шаблона. Второй я сделал потому что не получалось нормально реализовать функции больничного и отпуска (как показано на первом скриншоте: у первого сотрудника с 1 по 7 число стоят прочерки, но в «Версии для печати» у него отображается «Отпуск»). Также там выводится общее число отработанных часов за месяц (из расчёта: 1 смена = 11 часов).

view_schedule.php (версия для печати, расчёт времени, лежит в schedule):
<?php include 'includes/db.php'; ?> <!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <title>Просмотр графика смен</title> <link rel="stylesheet" href="css/style.css"> <style> /* Основные стили */ body { font-family: Arial, sans-serif; margin: 0; padding: 0; } .container { width: 100%; padding: 20px; box-sizing: border-box; } /* Шапка документа */ .document-header { text-align: center; margin-bottom: 20px; } .department-name { font-size: 14pt; font-weight: bold; margin-bottom: 5px; } .document-title { font-size: 16pt; font-weight: bold; margin: 15px 0; } .document-date { font-size: 12pt; margin-bottom: 15px; } .approval-block { text-align: right; margin: 30px 0; } /* Таблица с графиком */ .schedule-wrapper { width: 100%; overflow-x: auto; margin-bottom: 30px; } #schedule-table { border-collapse: collapse; width: 100%; font-size: 10pt; } #schedule-table th, #schedule-table td { border: 1px solid #000; padding: 2px; text-align: center; min-width: 22px; } #schedule-table th { background-color: #f2f2f2; font-weight: bold; } .name-cell { min-width: 150px; background-color: #f9f9f9; position: sticky; left: 0; } .hours-cell { font-weight: bold; background-color: #f2f2f2; } /* Подпись */ .signature-block { margin-top: 50px; width: 100%; } .signature-line { border-top: 1px solid #000; width: 200px; display: inline-block; margin: 0 20px; } /* Стили для печати */ @media print { @page { size: A4 landscape; margin: 10mm; } body { padding: 0; margin: 0; font-size: 10pt; } .no-print { display: none; } #schedule-table { width: 100%; font-size: 8pt; } #schedule-table th, #schedule-table td { padding: 3px; } .hours-cell { font-weight: bold; background-color: #f2f2f2 !important; } #employee-signatures div { font-family: monospace; white-space: pre; margin-bottom: 10px; line-height: 1.3; } } /* Цвета смен */ .day-shift { background-color: #d4edda; } .night-shift { background-color: #cce5ff; } .day-off { background-color: #fff3cd; } .vacation { background-color: #ffcccc; } .sick-leave { background-color: #ff9999; } </style> </head> <body> <div class="container"> <!-- Шапка документа --> <div class="document-header"> <div class="department-name">ЗАПИСАТЬ СВОИ ДАННЫЕ</div> <div class="document-date">Дата составления графика: <?= date('d.m.Y') ?></div> <?php $months = [ 1 => 'Январь', 2 => 'Февраль', 3 => 'Март', 4 => 'Апрель', 5 => 'Май', 6 => 'Июнь', 7 => 'Июль', 8 => 'Август', 9 => 'Сентябрь', 10 => 'Октябрь', 11 => 'Ноябрь', 12 => 'Декабрь' ]; $currentMonth = $months[date('n')]; ?> <div class="document-title">График работы на <?= $currentMonth . ' ' . date('Y') ?> г.</div> </div> <div class="approval-block"> УТВЕРЖДАЮ<br> ЗАПИСАТЬ СВОИ ДАННЫЕ (ДОЛЖНОСТЬ, ЕСЛИ НУЖНО)<br> ___________________ (подпись) </div> <!-- Основная таблица с графиком --> <div class="schedule-wrapper"> <table id="schedule-table"> <thead id="schedule-header"> <tr> <th>Сотрудник</th> <!-- Динамические заголовки дат --> </tr> </thead> <tbody id="schedule-body"> <!-- Строки будут добавляться динамически --> </tbody> </table> </div> <!-- Блок подписи --> <div class="signature-block"> <div style="float: left; width: 50%;"> Ответственное лицо:<br> ЗАПИСАТЬ СВОИ ДАННЫЕ (ДОЛЖНОСТЬ)<br> ___________________ (подпись) </div> <div style="float: right; width: 35%; text-align: left;"> С графиком работы ознакомлены:<br> <div id="employee-signatures"></div> </div> <div style="clear: both;"></div> </div> <button class="no-print" onclick="window.print()" style="padding: 10px 20px; margin-top: 20px; cursor: pointer;">Печать</button> </div> <script> document.addEventListener('DOMContentLoaded', function() { loadSchedule(); }); function loadSchedule() { fetch('api/get_schedule.php') .then(response => response.json()) .then(data => { const currentDate = new Date(); const year = currentDate.getFullYear(); const month = currentDate.getMonth(); const daysInMonth = new Date(year, month + 1, 0).getDate(); // Заголовок с датами const headerRow = document.querySelector('#schedule-header tr'); headerRow.innerHTML = '<th>Сотрудник</th>'; for (let day = 1; day <= daysInMonth; day++) { const th = document.createElement('th'); th.textContent = day; headerRow.appendChild(th); } // Добавляем заголовок для часов headerRow.innerHTML += '<th>Часы</th>'; // Заполняем данные сотрудников const tbody = document.getElementById('schedule-body'); const signatures = document.getElementById('employee-signatures'); tbody.innerHTML = ''; data.forEach(employee => { let totalHours = 0; // Добавляем строку в таблицу const row = document.createElement('tr'); // Ячейка с именем const nameCell = document.createElement('td'); nameCell.className = 'name-cell'; nameCell.textContent = employee.fullname; row.appendChild(nameCell); // Ячейки смен for (let day = 1; day <= daysInMonth; day++) { const date = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const shiftType = employee.shifts[date] || ''; const cell = document.createElement('td'); switch(shiftType) { case 'day': cell.textContent = 'Д'; cell.className = 'day-shift'; totalHours += 11; break; case 'night': cell.textContent = 'Н'; cell.className = 'night-shift'; totalHours += 11; break; case 'off': cell.textContent = 'В'; cell.className = 'day-off'; break; case 'vacation': cell.textContent = 'Отп'; cell.className = 'vacation'; break; case 'sick_leave': cell.textContent = 'Б'; cell.className = 'sick-leave'; break; default: cell.textContent = '-'; } row.appendChild(cell); } // Добавляем ячейку с общим количеством часов const hoursCell = document.createElement('td'); hoursCell.textContent = totalHours > 0 ? totalHours : ''; hoursCell.className = 'hours-cell'; row.appendChild(hoursCell); tbody.appendChild(row); // Добавляем подпись сотрудника const signature = document.createElement('div'); signature.style.fontFamily = 'monospace'; signature.style.whiteSpace = 'pre'; // Вычисляем длину ФИО и добавляем подчеркивания const nameLength = employee.fullname.length; const totalLength = 40; // Общая длина строки const underlineLength = totalLength - nameLength - 10; // 10 - место для "(подпись)" signature.innerHTML = ` ${employee.fullname} ${'_'.repeat(underlineLength)} (подпись) `; signatures.appendChild(signature); }); }) .catch(error => { console.error('Ошибка загрузки графика:', error); alert('Не удалось загрузить график'); }); } </script> </body> </html>
view_schedule.js (отображение графика для печати, лежит в schedule/js):
document.addEventListener('DOMContentLoaded', function() { loadSchedule(); }); function loadSchedule() { fetch('api/get_schedule.php') .then(response => response.json()) .then(data => { const currentDate = new Date(); const year = currentDate.getFullYear(); const month = currentDate.getMonth(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const headerRow = document.querySelector('#schedule-header tr'); headerRow.innerHTML = '<th>Сотрудник</th>'; for (let day = 1; day <= daysInMonth; day++) { const th = document.createElement('th'); th.textContent = day; headerRow.appendChild(th); } headerRow.innerHTML += '<th>Часы</th>'; const tbody = document.getElementById('schedule-body'); const signatures = document.getElementById('employee-signatures'); tbody.innerHTML = ''; data.forEach(employee => { let totalHours = 0; const row = document.createElement('tr'); const nameCell = document.createElement('td'); nameCell.className = 'name-cell'; nameCell.textContent = employee.fullname; row.appendChild(nameCell); for (let day = 1; day <= daysInMonth; day++) { const date = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const shiftType = employee.shifts[date] || ''; const cell = document.createElement('td'); switch(shiftType) { case 'day': cell.textContent = 'Д'; cell.className = 'day-shift'; totalHours += 11; break; case 'night': cell.textContent = 'Н'; cell.className = 'night-shift'; totalHours += 11; break; case 'off': cell.textContent = 'В'; cell.className = 'day-off'; break; case 'vacation': cell.textContent = 'Отп'; cell.className = 'vacation'; break; case 'sick_leave': cell.textContent = 'Б'; cell.className = 'sick-leave'; break; default: cell.textContent = '-'; } row.appendChild(cell); } const totalCell = document.createElement('td'); totalCell.textContent = totalHours > 0 ? totalHours : ''; totalCell.className = 'employee-total'; row.appendChild(totalCell); tbody.appendChild(row); const signature = document.createElement('div'); signature.innerHTML = ` ${employee.fullname} ___________________ <span style="font-size: 0.8em;">(подпись)</span><br><br> `; signatures.appendChild(signature); }); }) .catch(error => { console.error('Ошибка загрузки графика:', error); alert('Не удалось загрузить график'); }); }
Итоговая структура проекта
schedule/
├── api/ # Скрипты API для работы с БД
│ ├── add_employee.php
│ ├── delete_employee.php
│ ├── get_employees.php
│ ├── get_schedule.php
│ ├── save_auto_schedule.php
│ ├── set_schedule.php
│ ├── set_status.php
│ └── status.php
├── includes/ # Вспомогательные файлы
│ ├── db.php # Подключение к БД
│ └── functions.php # Общие функции
├── js/ # Клиентские скрипты
│ ├── employees.js # Управление сотрудниками
│ ├── main.js # Инициализация системы
│ ├── schedule.js # Логика графика смен
│ ├── status.js # Управление статусами
│ └── view_schedule.js # Печатная версия графика
├── css/
│ └── style.css # Стили для всех страниц
└── views/ # HTML-шаблоны
├── index.php # Главная страница
├── employees.php # Интерфейс сотрудников
└── view_schedule.php # Страница печатиссылки для работы
Стили (style.css)
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; } .nav-tabs { display: flex; margin-bottom: 20px; border-bottom: 1px solid #ddd; } .nav-tabs a { padding: 10px 15px; cursor: pointer; border: 1px solid transparent; border-radius: 4px 4px 0 0; margin-right: 5px; } .nav-tabs a.active { border-color: #ddd #ddd #fff; background: #fff; color: #555; font-weight: bold; } .tab-content { display: none; } .tab-content.active { display: block; } .schedule-wrapper { overflow-x: auto; } #schedule-table { border-collapse: collapse; width: 100%; } #schedule-table th, #schedule-table td { border: 1px solid #ddd; padding: 8px; text-align: center; } #schedule-table th { background-color: #f2f2f2; } .name-cell { min-width: 150px; background-color: #f9f9f9; } .shift-cell { cursor: pointer; } .shift-cell:hover { background-color: #f0f0f0; } .day-shift { background-color: #d4edda; } .night-shift { background-color: #cce5ff; } .day-off { background-color: #fff3cd; } .controls { margin-bottom: 15px; } .employee-form, .employee-list, .status-controls { margin-bottom: 20px; } #schedule-body tr.selected { background-color: #e6f7ff; outline: 2px solid #1890ff; } .day-shift { background-color: #d4edda; } .night-shift { background-color: #cce5ff; } .day-off { background-color: #fff3cd; } /* Стили для страницы просмотра */ .view-mode { background: #fff; padding: 20px; } .view-mode #schedule-table { width: auto; margin: 0 auto; } .view-mode .name-cell { min-width: 200px; position: sticky; left: 0; background: #f9f9f9; z-index: 1; } .print-button { background: #4CAF50; color: white; border: none; padding: 10px 15px; border-radius: 4px; cursor: pointer; font-size: 16px; } .print-button:hover { background: #45a049; } @media print { body { padding: 0; margin: 0; } .print-button { display: none; } #schedule-table { width: 100%; font-size: 12pt; } } .vacation { background-color: #ffcccc; position: relative; } .sick-leave { background-color: #ff9999; position: relative; }
Надеюсь, кому-то это будет полезно!
ссылка на оригинал статьи https://habr.com/ru/articles/935510/
Добавить комментарий