Search
Write a publication
Pull to refresh

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

Reading time28 min
Views603

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

Кадровики у нас ленивые (а может, просто не умеют) постоянно формировать график для сменных дежурных. Я понимаю, что в 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;
}

Надеюсь, кому-то это будет полезно!

Tags:
Hubs:
-2
Comments3

Articles