Форма создания/редактирования задачи
PHP:
<?php
// app/Views/tasks/create.php
ob_start();
?>
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h4 class="card-title mb-0"><?= isset($task) ? 'Редактирование задачи' : 'Новая задача' ?></h4>
</div>
<div class="card-body">
<form method="post" action="<?= isset($task) ? "/tasks/{$task->id}/update" : '/tasks/store' ?>">
<?php if (!empty($errors)): ?>
<div class="alert alert-danger">
<ul class="mb-0">
<?php foreach ($errors as $field => $fieldErrors): ?>
<?php foreach ($fieldErrors as $error): ?>
<li><?= $error ?></li>
<?php endforeach; ?>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="mb-3">
<label for="title" class="form-label">Название *</label>
<input type="text" class="form-control <?= isset($errors['title']) ? 'is-invalid' : '' ?>"
id="title" name="title"
value="<?= htmlspecialchars($old['title'] ?? $task->title ?? '') ?>"
required>
<?php if (isset($errors['title'])): ?>
<div class="invalid-feedback">
<?= implode('<br>', $errors['title']) ?>
</div>
<?php endif; ?>
</div>
<div class="mb-3">
<label for="description" class="form-label">Описание *</label>
<textarea class="form-control <?= isset($errors['description']) ? 'is-invalid' : '' ?>"
id="description" name="description" rows="4"
required><?= htmlspecialchars($old['description'] ?? $task->description ?? '') ?></textarea>
<?php if (isset($errors['description'])): ?>
<div class="invalid-feedback">
<?= implode('<br>', $errors['description']) ?>
</div>
<?php endif; ?>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="status" class="form-label">Статус</label>
<select class="form-select" id="status" name="status">
<option value="pending" <?= ($old['status'] ?? $task->status ?? '') == 'pending' ? 'selected' : '' ?>>В ожидании</option>
<option value="in_progress" <?= ($old['status'] ?? $task->status ?? '') == 'in_progress' ? 'selected' : '' ?>>В работе</option>
<option value="completed" <?= ($old['status'] ?? $task->status ?? '') == 'completed' ? 'selected' : '' ?>>Завершено</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label for="priority" class="form-label">Приоритет</label>
<select class="form-select" id="priority" name="priority">
<option value="low" <?= ($old['priority'] ?? $task->priority ?? '') == 'low' ? 'selected' : '' ?>>Низкий</option>
<option value="medium" <?= ($old['priority'] ?? $task->priority ?? '') == 'medium' ? 'selected' : '' ?>>Средний</option>
<option value="high" <?= ($old['priority'] ?? $task->priority ?? '') == 'high' ? 'selected' : '' ?>>Высокий</option>
</select>
</div>
</div>
<div class="mb-3">
<label for="due_date" class="form-label">Срок выполнения</label>
<input type="date" class="form-control" id="due_date" name="due_date"
value="<?= $old['due_date'] ?? $task->due_date ?? '' ?>">
<div class="form-text">Оставьте пустым, если срок не установлен</div>
</div>
<div class="mb-4">
<label class="form-label">Метки</label>
<div class="row">
<?php foreach ($tags as $tag): ?>
<div class="col-md-3 mb-2">
<div class="form-check">
<input class="form-check-input" type="checkbox"
name="tags[]" value="<?= $tag->id ?>"
id="tag-<?= $tag->id ?>"
<?php
if (isset($task)) {
$taskTags = array_map(function($t) { return $t->id; }, $task->tags());
echo in_array($tag->id, $taskTags) ? 'checked' : '';
} elseif (isset($old['tags']) && in_array($tag->id, $old['tags'])) {
echo 'checked';
}
?>>
<label class="form-check-label" for="tag-<?= $tag->id ?>">
<span class="badge" <?= $tag->getColorStyle() ?>>
<?= htmlspecialchars($tag->name) ?>
</span>
</label>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="/tasks" class="btn btn-secondary">Отмена</a>
<button type="submit" class="btn btn-primary">
<?= isset($task) ? 'Обновить задачу' : 'Создать задачу' ?>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<?php
$content = ob_get_clean();
$title = isset($task) ? 'Редактирование задачи' : 'Новая задача';
include __DIR__ . '/../layouts/main.php';
?>
6. Добавление AJAX
JavaScript для интерактивности
JavaScript:
// public/js/app.js
$(document).ready(function() {
// Поиск задач
let searchTimeout;
$('#search-input').on('input', function() {
clearTimeout(searchTimeout);
const query = $(this).val().trim();
if (query.length < 2) {
$('#search-results').hide();
return;
}
searchTimeout = setTimeout(function() {
$.ajax({
url: '/tasks/search',
method: 'GET',
data: { q: query },
success: function(response) {
if (response.tasks.length > 0) {
let html = '<div class="list-group">';
response.tasks.forEach(function(task) {
html += `
<a href="/tasks/${task.id}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">${task.title}</h6>
${task.status_badge}
</div>
<div class="d-flex justify-content-between">
<small>Приоритет: ${task.priority_badge}</small>
<small>Срок: ${task.due_date || '—'}</small>
</div>
${task.is_overdue ? '<small class="text-danger"><i class="bi bi-exclamation-triangle"></i> Просрочено</small>' : ''}
</a>
`;
});
html += '</div>';
$('#search-results-body').html(html);
$('#search-results').show();
} else {
$('#search-results-body').html('<p class="text-muted">Задачи не найдены</p>');
$('#search-results').show();
}
},
error: function() {
$('#search-results-body').html('<p class="text-danger">Ошибка поиска</p>');
$('#search-results').show();
}
});
}, 300);
});
// Изменение статуса задачи
$('.status-select').on('click', function() {
const taskId = $(this).data('task-id');
const currentStatus = $(this).find('.badge').text().trim();
$('#task-id').val(taskId);
// Устанавливаем текущий статус в селект
const statusMap = {
'В ожидании': 'pending',
'В работе': 'in_progress',
'Завершено': 'completed'
};
$('#new-status').val(statusMap[currentStatus] || 'pending');
$('#statusModal').modal('show');
});
$('#save-status').on('click', function() {
const taskId = $('#task-id').val();
const newStatus = $('#new-status').val();
$.ajax({
url: '/tasks/update-status',
method: 'POST',
data: {
task_id: taskId,
status: newStatus
},
success: function(response) {
if (response.success) {
// Обновляем бейдж на странице
$(`.status-select[data-task-id="${taskId}"]`).html(response.badge);
$('#statusModal').modal('hide');
// Показываем уведомление
showToast('Статус обновлен', 'success');
}
},
error: function(xhr) {
showToast('Ошибка при обновлении статуса', 'danger');
}
});
});
// Удаление задачи
$('.delete-task').on('click', function() {
const taskId = $(this).data('id');
const taskTitle = $(this).data('title');
$('#task-title').text(taskTitle);
$('#delete-form').attr('action', `/tasks/${taskId}/destroy`);
$('#deleteModal').modal('show');
});
// Toast уведомления
function showToast(message, type = 'info') {
const toastId = 'toast-' + Date.now();
const toastHtml = `
<div id="${toastId}" class="toast align-items-center text-white bg-${type} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`;
$('#toast-container').append(toastHtml);
const toast = new bootstrap.Toast(document.getElementById(toastId));
toast.show();
// Удаляем toast после скрытия
document.getElementById(toastId).addEventListener('hidden.bs.toast', function() {
this.remove();
});
}
// Создаем контейнер для toast, если его нет
if ($('#toast-container').length === 0) {
$('body').append('<div id="toast-container" class="toast-container position-fixed bottom-0 end-0 p-3"></div>');
}
// Автоматическое скрытие alert через 5 секунд
setTimeout(function() {
$('.alert:not(.alert-permanent)').alert('close');
}, 5000);
});
Стили CSS
CSS:
/* public/css/style.css */
body {
background-color: #f8f9fa;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.navbar-brand {
font-weight: 600;
}
.card {
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,.1);
margin-bottom: 20px;
}
.card-header {
background-color: rgba(0,0,0,.03);
border-bottom: 1px solid rgba(0,0,0,.125);
}
.table th {
border-top: none;
font-weight: 600;
color: #495057;
}
.table-hover tbody tr:hover {
background-color: rgba(0,123,255,.05);
}
.status-select {
cursor: pointer;
transition: opacity 0.2s;
}
.status-select:hover {
opacity: 0.8;
}
.badge {
font-weight: 500;
padding: 5px 10px;
}
.badge.bg-secondary {
background-color: #6c757d !important;
}
.badge.bg-warning {
background-color: #ffc107 !important;
color: #212529;
}
.badge.bg-success {
background-color: #28a745 !important;
}
.badge.bg-info {
background-color: #17a2b8 !important;
}
.badge.bg-danger {
background-color: #dc3545 !important;
}
.badge.bg-primary {
background-color: #007bff !important;
}
.form-check-label .badge {
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
}
.form-check-input:checked + .form-check-label .badge {
opacity: 1;
transform: scale(1.05);
}
.btn-group-sm > .btn {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
/* Анимации */
.fade-in {
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Адаптивность */
@media (max-width: 768px) {
.table-responsive {
font-size: 0.9rem;
}
.btn-group {
flex-wrap: wrap;
}
.btn-group-sm > .btn {
margin-bottom: 2px;
}
}
7. Роутер и точка входа
Роутер
PHP:
<?php
// app/Core/Router.php
namespace App\Core;
class Router
{
private $routes = [];
private $notFoundCallback;
public function add($method, $path, $callback)
{
$this->routes[] = [
'method' => strtoupper($method),
'path' => $path,
'callback' => $callback
];
}
public function get($path, $callback)
{
$this->add('GET', $path, $callback);
}
public function post($path, $callback)
{
$this->add('POST', $path, $callback);
}
public function put($path, $callback)
{
$this->add('PUT', $path, $callback);
}
public function delete($path, $callback)
{
$this->add('DELETE', $path, $callback);
}
public function notFound($callback)
{
$this->notFoundCallback = $callback;
}
public function dispatch()
{
$requestMethod = $_SERVER['REQUEST_METHOD'];
$requestPath = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Убираем базовый путь, если приложение не в корне
$basePath = dirname($_SERVER['SCRIPT_NAME']);
if ($basePath != '/') {
$requestPath = substr($requestPath, strlen($basePath));
}
$requestPath = rtrim($requestPath, '/') ?: '/';
foreach ($this->routes as $route) {
if ($route['method'] !== $requestMethod) {
continue;
}
$pattern = $this->convertToRegex($route['path']);
if (preg_match($pattern, $requestPath, $matches)) {
array_shift($matches); // Убираем полное совпадение
// Преобразуем числовые параметры
$matches = array_map(function($value) {
return is_numeric($value) ? (int) $value : $value;
}, $matches);
// Вызываем callback
if (is_callable($route['callback'])) {
return call_user_func_array($route['callback'], $matches);
} elseif (is_string($route['callback'])) {
return $this->callController($route['callback'], $matches);
}
}
}
// 404 Not Found
if ($this->notFoundCallback) {
call_user_func($this->notFoundCallback);
} else {
http_response_code(404);
echo "404 Not Found";
}
}
private function convertToRegex($path)
{
$pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '(?P<$1>[^/]+)', $path);
return '#^' . $pattern . '$#';
}
private function callController($callback, $params)
{
list($controller, $method) = explode('@', $callback);
$controllerClass = "App\\Controllers\\{$controller}";
if (!class_exists($controllerClass)) {
throw new \Exception("Controller {$controllerClass} not found");
}
$controllerInstance = new $controllerClass();
if (!method_exists($controllerInstance, $method)) {
throw new \Exception("Method {$method} not found in {$controllerClass}");
}
return call_user_func_array([$controllerInstance, $method], $params);
}
}
Точка входа (index.php)
PHP:
<?php
// public/index.php
require_once __DIR__ . '/../vendor/autoload.php';
use App\Core\Router;
use App\Controllers\TaskController;
use App\Controllers\AuthController;
// Включение отладки (только для разработки)
ini_set('display_errors', 1);
error_reporting(E_ALL);
session_start();
$router = new Router();
// Маршруты аутентификации
$router->get('/login', [AuthController::class, 'loginForm']);
$router->post('/login', [AuthController::class, 'login']);
$router->get('/register', [AuthController::class, 'registerForm']);
$router->post('/register', [AuthController::class, 'register']);
$router->get('/logout', [AuthController::class, 'logout']);
// Маршруты задач
$router->get('/', [TaskController::class, 'index']);
$router->get('/tasks', [TaskController::class, 'index']);
$router->get('/tasks/create', [TaskController::class, 'create']);
$router->post('/tasks/store', [TaskController::class, 'store']);
$router->get('/tasks/{id}', [TaskController::class, 'show']);
$router->get('/tasks/{id}/edit', [TaskController::class, 'edit']);
$router->post('/tasks/{id}/update', [TaskController::class, 'update']);
$router->post('/tasks/{id}/destroy', [TaskController::class, 'destroy']);
// AJAX маршруты
$router->post('/tasks/update-status', [TaskController::class, 'updateStatus']);
$router->get('/tasks/search', [TaskController::class, 'search']);
// 404 страница
$router->notFound(function() {
http_response_code(404);
echo "404 - Страница не найдена";
});
$router->dispatch();
Файл .htaccess для Apache
Код:
# public/.htaccess
RewriteEngine On
# Перенаправление всех запросов на index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]
# Защита от доступа к скрытым файлам
<FilesMatch "^\.">
Order allow,deny
Deny from all
</FilesMatch>
# Кэширование статических файлов
<FilesMatch "\.(css|js|jpg|jpeg|png|gif|ico)$">
ExpiresActive On
ExpiresDefault "access plus 1 month"
</FilesMatch>
composer.json для автозагрузки
Код:
{
"name": "yourname/todo-app",
"description": "To-Do List Application",
"type": "project",
"require": {
"php": ">=7.4"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"authors": [
{
"name": "Your Name",
"email": "your.email@example.com"
}
],
"minimum-stability": "stable"
}
Заключение
Мы создали полноценную систему управления задачами на PHP, используя современные подходы:- ООП для структурирования кода
- MVC для разделения ответственности
- PDO для безопасной работы с БД
- AJAX для улучшения UX
- Bootstrap для адаптивного дизайна