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


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

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