343 lines
12 KiB
PHP
343 lines
12 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../db/config.php';
|
|
|
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
session_start();
|
|
}
|
|
|
|
function app_boot(): void
|
|
{
|
|
static $booted = false;
|
|
if ($booted) {
|
|
return;
|
|
}
|
|
|
|
date_default_timezone_set('UTC');
|
|
ensure_schema();
|
|
$booted = true;
|
|
}
|
|
|
|
function ensure_schema(): void
|
|
{
|
|
$pdo = db();
|
|
|
|
$pdo->exec(
|
|
"CREATE TABLE IF NOT EXISTS users (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
first_name VARCHAR(80) NOT NULL,
|
|
last_name VARCHAR(80) NOT NULL,
|
|
email VARCHAR(150) NOT NULL UNIQUE,
|
|
password_hash VARCHAR(255) NOT NULL,
|
|
role ENUM('admin','manager','user') NOT NULL DEFAULT 'manager',
|
|
failed_attempts INT NOT NULL DEFAULT 0,
|
|
locked_until DATETIME NULL,
|
|
reset_token_hash CHAR(64) NULL,
|
|
reset_expires_at DATETIME NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
INDEX idx_users_email (email)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
|
);
|
|
|
|
$pdo->exec(
|
|
"CREATE TABLE IF NOT EXISTS athletes (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
user_id INT NOT NULL,
|
|
first_name VARCHAR(80) NOT NULL,
|
|
last_name VARCHAR(80) NOT NULL,
|
|
sport_name VARCHAR(100) NOT NULL,
|
|
club_name VARCHAR(120) NOT NULL,
|
|
nationality VARCHAR(80) DEFAULT NULL,
|
|
position_name VARCHAR(80) DEFAULT NULL,
|
|
jersey_number INT DEFAULT NULL,
|
|
status ENUM('actif','blesse','suspendu','retraite') NOT NULL DEFAULT 'actif',
|
|
joined_on DATE DEFAULT NULL,
|
|
matches_played INT NOT NULL DEFAULT 0,
|
|
goals_scored INT NOT NULL DEFAULT 0,
|
|
assists_count INT NOT NULL DEFAULT 0,
|
|
awards VARCHAR(255) DEFAULT NULL,
|
|
career_note TEXT DEFAULT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
CONSTRAINT fk_athletes_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
INDEX idx_athletes_sport (sport_name),
|
|
INDEX idx_athletes_club (club_name),
|
|
INDEX idx_athletes_status (status),
|
|
INDEX idx_athletes_name (last_name, first_name)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
|
);
|
|
}
|
|
|
|
function e(?string $value): string
|
|
{
|
|
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
|
}
|
|
|
|
function env_value(string $key, string $fallback = ''): string
|
|
{
|
|
$value = $_SERVER[$key] ?? getenv($key) ?: $fallback;
|
|
return is_string($value) ? $value : $fallback;
|
|
}
|
|
|
|
function app_name(): string
|
|
{
|
|
return env_value('PROJECT_NAME', 'RJLRESAKA');
|
|
}
|
|
|
|
function page_title(string $title): string
|
|
{
|
|
return $title . ' • ' . app_name();
|
|
}
|
|
|
|
function set_flash(string $type, string $message): void
|
|
{
|
|
$_SESSION['flash'][] = ['type' => $type, 'message' => $message];
|
|
}
|
|
|
|
function get_flashes(): array
|
|
{
|
|
$flashes = $_SESSION['flash'] ?? [];
|
|
unset($_SESSION['flash']);
|
|
return is_array($flashes) ? $flashes : [];
|
|
}
|
|
|
|
function current_user(): ?array
|
|
{
|
|
if (empty($_SESSION['user_id'])) {
|
|
return null;
|
|
}
|
|
|
|
static $user = null;
|
|
if ($user !== null) {
|
|
return $user;
|
|
}
|
|
|
|
$stmt = db()->prepare('SELECT id, first_name, last_name, email, role, created_at FROM users WHERE id = :id LIMIT 1');
|
|
$stmt->execute(['id' => (int) $_SESSION['user_id']]);
|
|
$user = $stmt->fetch() ?: null;
|
|
if (!$user) {
|
|
unset($_SESSION['user_id']);
|
|
}
|
|
return $user;
|
|
}
|
|
|
|
function require_login(): void
|
|
{
|
|
if (!current_user()) {
|
|
set_flash('warning', 'Veuillez vous connecter pour accéder à cette section.');
|
|
redirect('login.php');
|
|
}
|
|
}
|
|
|
|
function redirect(string $path): void
|
|
{
|
|
header('Location: ' . $path);
|
|
exit;
|
|
}
|
|
|
|
function csrf_token(): string
|
|
{
|
|
if (empty($_SESSION['csrf_token'])) {
|
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(16));
|
|
}
|
|
return $_SESSION['csrf_token'];
|
|
}
|
|
|
|
function verify_csrf(): void
|
|
{
|
|
$token = $_POST['csrf_token'] ?? '';
|
|
if (!is_string($token) || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
|
|
http_response_code(422);
|
|
exit('Jeton de formulaire invalide.');
|
|
}
|
|
}
|
|
|
|
function request_method(): string
|
|
{
|
|
return strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
|
}
|
|
|
|
function is_post(): bool
|
|
{
|
|
return request_method() === 'POST';
|
|
}
|
|
|
|
function old(string $key, string $fallback = ''): string
|
|
{
|
|
return e($_POST[$key] ?? $fallback);
|
|
}
|
|
|
|
function password_rules_ok(string $password): bool
|
|
{
|
|
return strlen($password) >= 8;
|
|
}
|
|
|
|
function login_user(array $user): void
|
|
{
|
|
session_regenerate_id(true);
|
|
$_SESSION['user_id'] = (int) $user['id'];
|
|
}
|
|
|
|
function logout_user(): void
|
|
{
|
|
$_SESSION = [];
|
|
if (ini_get('session.use_cookies')) {
|
|
$params = session_get_cookie_params();
|
|
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], (bool) $params['secure'], (bool) $params['httponly']);
|
|
}
|
|
session_destroy();
|
|
}
|
|
|
|
function format_datetime(?string $value): string
|
|
{
|
|
if (!$value) {
|
|
return '—';
|
|
}
|
|
return date('d/m/Y H:i', strtotime($value));
|
|
}
|
|
|
|
function format_date(?string $value): string
|
|
{
|
|
if (!$value) {
|
|
return '—';
|
|
}
|
|
return date('d/m/Y', strtotime($value));
|
|
}
|
|
|
|
function stat_badge_class(string $status): string
|
|
{
|
|
return match ($status) {
|
|
'actif' => 'success',
|
|
'blesse' => 'warning',
|
|
'suspendu' => 'danger',
|
|
'retraite' => 'secondary',
|
|
default => 'secondary',
|
|
};
|
|
}
|
|
|
|
function fetch_dashboard_stats(?int $userId = null): array
|
|
{
|
|
$pdo = db();
|
|
$where = $userId ? 'WHERE user_id = :user_id' : '';
|
|
|
|
$stmt = $pdo->prepare("SELECT COUNT(*) AS total, COUNT(DISTINCT sport_name) AS sports, COUNT(DISTINCT club_name) AS clubs FROM athletes $where");
|
|
$stmt->execute($userId ? ['user_id' => $userId] : []);
|
|
$base = $stmt->fetch() ?: ['total' => 0, 'sports' => 0, 'clubs' => 0];
|
|
|
|
$statusStmt = $pdo->prepare("SELECT status, COUNT(*) AS total FROM athletes $where GROUP BY status ORDER BY total DESC");
|
|
$statusStmt->execute($userId ? ['user_id' => $userId] : []);
|
|
|
|
$recentStmt = $pdo->prepare("SELECT id, first_name, last_name, sport_name, club_name, status, created_at FROM athletes $where ORDER BY created_at DESC LIMIT 5");
|
|
$recentStmt->execute($userId ? ['user_id' => $userId] : []);
|
|
|
|
return [
|
|
'total' => (int) ($base['total'] ?? 0),
|
|
'sports' => (int) ($base['sports'] ?? 0),
|
|
'clubs' => (int) ($base['clubs'] ?? 0),
|
|
'status_breakdown' => $statusStmt->fetchAll(),
|
|
'recent' => $recentStmt->fetchAll(),
|
|
];
|
|
}
|
|
|
|
function top_values(string $column, int $limit = 6): array
|
|
{
|
|
$allowed = ['sport_name', 'club_name'];
|
|
if (!in_array($column, $allowed, true)) {
|
|
return [];
|
|
}
|
|
|
|
$stmt = db()->query("SELECT {$column} AS label, COUNT(*) AS total FROM athletes GROUP BY {$column} ORDER BY total DESC, {$column} ASC LIMIT {$limit}");
|
|
return $stmt->fetchAll();
|
|
}
|
|
|
|
function render_header(string $title, array $options = []): void
|
|
{
|
|
$pageDescription = $options['description'] ?? env_value('PROJECT_DESCRIPTION', 'Gestion professionnelle des sportifs, clubs et sports.');
|
|
$projectImageUrl = env_value('PROJECT_IMAGE_URL', '');
|
|
$bodyClass = $options['body_class'] ?? '';
|
|
$user = current_user();
|
|
$flashes = get_flashes();
|
|
$assetVersion = (string) @filemtime(__DIR__ . '/../assets/css/custom.css');
|
|
?>
|
|
<!doctype html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title><?= e(page_title($title)) ?></title>
|
|
<meta name="description" content="<?= e($pageDescription) ?>">
|
|
<meta name="theme-color" content="#111827">
|
|
<meta property="og:title" content="<?= e(page_title($title)) ?>">
|
|
<meta property="og:description" content="<?= e($pageDescription) ?>">
|
|
<?php if ($projectImageUrl): ?>
|
|
<meta property="og:image" content="<?= e($projectImageUrl) ?>">
|
|
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>">
|
|
<?php endif; ?>
|
|
<meta property="twitter:description" content="<?= e($pageDescription) ?>">
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
|
<link rel="stylesheet" href="assets/css/custom.css?v=<?= e($assetVersion) ?>">
|
|
</head>
|
|
<body class="<?= e($bodyClass) ?>">
|
|
<nav class="navbar navbar-expand-lg app-navbar sticky-top border-bottom">
|
|
<div class="container-xxl">
|
|
<a class="navbar-brand fw-semibold" href="index.php">
|
|
<span class="brand-mark">RJL</span>
|
|
<span>RJLRESAKA</span>
|
|
</a>
|
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Basculer la navigation">
|
|
<span class="navbar-toggler-icon"></span>
|
|
</button>
|
|
<div class="collapse navbar-collapse" id="mainNav">
|
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
|
<li class="nav-item"><a class="nav-link" href="index.php">Tableau de bord</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="athletes.php">Sportifs</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="athlete_new.php">Ajouter</a></li>
|
|
</ul>
|
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
|
<?php if ($user): ?>
|
|
<span class="text-secondary small">Connecté : <?= e($user['first_name'] . ' ' . $user['last_name']) ?></span>
|
|
<a class="btn btn-sm btn-outline-secondary" href="change_password.php">Mot de passe</a>
|
|
<a class="btn btn-sm btn-dark" href="logout.php">Déconnexion</a>
|
|
<?php else: ?>
|
|
<a class="btn btn-sm btn-outline-secondary" href="login.php">Connexion</a>
|
|
<a class="btn btn-sm btn-dark" href="register.php">Inscription</a>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<?php if ($flashes): ?>
|
|
<div class="toast-container position-fixed top-0 end-0 p-3">
|
|
<?php foreach ($flashes as $flash): ?>
|
|
<div class="toast align-items-center text-bg-<?= e($flash['type']) ?> border-0 show mb-2" role="alert" aria-live="assertive" aria-atomic="true" data-autohide="true">
|
|
<div class="d-flex">
|
|
<div class="toast-body"><?= e($flash['message']) ?></div>
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Fermer"></button>
|
|
</div>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
<?php
|
|
}
|
|
|
|
function render_footer(): void
|
|
{
|
|
$assetVersion = (string) @filemtime(__DIR__ . '/../assets/js/main.js');
|
|
?>
|
|
<footer class="border-top app-footer py-4 mt-5">
|
|
<div class="container-xxl d-flex flex-column flex-md-row justify-content-between gap-2 text-secondary small">
|
|
<span>RJLRESAKA • MVP initial pour la gestion des sportifs</span>
|
|
<span>PHP <?= e(PHP_VERSION) ?> • <a href="healthz.php" class="link-secondary">Health check</a></span>
|
|
</div>
|
|
</footer>
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
|
<script src="assets/js/main.js?v=<?= e($assetVersion) ?>" defer></script>
|
|
</body>
|
|
</html>
|
|
<?php
|
|
}
|