Мини система web управления графиком смен сотрудников

от автора

Может быть, кому-то пригодится.

Кадровики у нас ленивые (а может, просто не умеют) постоянно формировать график для сменных дежурных. Я понимаю, что в Excel такой табель заполняется за 5 минут, но мне захотелось сделать это проще.

Я выбрал XAMPP — просто и быстро. Думаю, его установка не составит труда.

В моём случае каталог для проекта назвал schedule.

Набор скриптов и шаблонов

HTML-шаблоны

Сделал два шаблона:

  1. Для работы с графиком

  2. Для печатной формы

Первый шаблон (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']);     } } ?>

Скрипты выполняют следующие операции:

  1. Добавление сотрудников в систему

  2. Генерацию рабочих графиков

  3. Установку статусов сотрудников на заданный период

Особенности работы со статусами:
Функция 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">&times;</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 # Страница печати

ссылки для работы

http://localhost/schedule/

http://localhost/schedule/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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *