CRUD avec PDO PostgreSQL
code complet du CRUD convertis avec PDO pour PostgreSQL :
1. Configuration Database (config/database.php)
<?php
declare(strict_types=1);
// Configuration PostgreSQL
const DB_HOST = "localhost";
const DB_PORT = "5432";
const DB_NAME = "shop_db";
const DB_USER = "postgres";
const DB_PASS = "postgres";
/**
* Établit une connexion PDO à PostgreSQL
*
* @return PDO Connexion PDO
* @throws PDOException En cas d'erreur de connexion
*/
function db_connect(): PDO {
static $pdo = null;
if ($pdo !== null) {
return $pdo;
}
try {
$dsn = sprintf(
"pgsql:host=%s;port=%s;dbname=%s",
DB_HOST,
DB_PORT,
DB_NAME
);
$pdo = new PDO($dsn, DB_USER, DB_PASS);
// Configuration PDO
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
// Encodage UTF-8
$pdo->exec("SET NAMES utf8");
$pdo->exec("SET search_path TO public");
return $pdo;
} catch (PDOException $e) {
error_log("Erreur de connexion PostgreSQL: " . $e->getMessage());
throw new PDOException("Impossible de se connecter à la base de données");
}
}
/**
* Ferme la connexion PDO
*/
function db_close(): void {
global $pdo;
$pdo = null;
}
2. Fonctions Utilitaires (includes/functions.php)
<?php
declare(strict_types=1);
require_once __DIR__ . '/../config/database.php';
/**
* Échappe les caractères HTML pour éviter XSS
*/
function h(string $text): string {
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}
/**
* Redirige vers une URL
*/
function redirect(string $url): void {
header("Location: $url");
exit;
}
/**
* Définit un message flash en session
*/
function set_flash(string $type, string $message): void {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
}
/**
* Récupère et supprime le message flash
*/
function get_flash(): ?array {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (isset($_SESSION['flash'])) {
$flash = $_SESSION['flash'];
unset($_SESSION['flash']);
return $flash;
}
return null;
}
/**
* Affiche le message flash en HTML
*/
function display_flash(): void {
$flash = get_flash();
if ($flash) {
$class = $flash['type'] === 'success' ? 'alert-success' : 'alert-danger';
echo "<div class='alert {$class}'>" . h($flash['message']) . "</div>";
}
}
/**
* Valide un entier positif
*/
function validate_positive_int(mixed $value): ?int {
$filtered = filter_var($value, FILTER_VALIDATE_INT);
return ($filtered !== false && $filtered > 0) ? $filtered : null;
}
/**
* Valide un prix (nombre décimal positif)
*/
function validate_price(mixed $value): ?float {
$filtered = filter_var($value, FILTER_VALIDATE_FLOAT);
return ($filtered !== false && $filtered >= 0) ? $filtered : null;
}
/**
* Valide une chaîne non vide
*/
function validate_string(mixed $value, int $max_length = 255): ?string {
if (!is_string($value)) {
return null;
}
$trimmed = trim($value);
if ($trimmed === '' || mb_strlen($trimmed) > $max_length) {
return null;
}
return $trimmed;
}
/**
* Formate un prix pour l'affichage
*/
function format_price(float $price): string {
return number_format($price, 2, ',', ' ') . ' €';
}
/**
* Formate une date PostgreSQL pour l'affichage
*/
function format_date(string $date): string {
$timestamp = strtotime($date);
return date('d/m/Y H:i', $timestamp);
}
/**
* Pagination : calcule le nombre de pages
*/
function calculate_pages(int $total_items, int $items_per_page): int {
return (int) ceil($total_items / $items_per_page);
}
/**
* Pagination : calcule l'offset SQL
*/
function calculate_offset(int $page, int $items_per_page): int {
return ($page - 1) * $items_per_page;
}
3. Requêtes SQL avec PDO (includes/queries.php)
<?php
declare(strict_types=1);
require_once __DIR__ . '/functions.php';
// ==================== CATEGORIES ====================
/**
* Récupère toutes les catégories avec pagination
*/
function get_all_categories(PDO $pdo, int $limit = 20, int $offset = 0): array {
try {
$query = "
SELECT id, name, created_at, updated_at
FROM categories
ORDER BY name ASC
LIMIT :limit OFFSET :offset
";
$stmt = $pdo->prepare($query);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
} catch (PDOException $e) {
error_log("Erreur requête categories: " . $e->getMessage());
return [];
}
}
/**
* Compte le nombre total de catégories
*/
function count_categories(PDO $pdo): int {
try {
$stmt = $pdo->query("SELECT COUNT(*) as count FROM categories");
$result = $stmt->fetch();
return (int) $result['count'];
} catch (PDOException $e) {
error_log("Erreur count categories: " . $e->getMessage());
return 0;
}
}
/**
* Récupère une catégorie par son ID
*/
function get_category_by_id(PDO $pdo, int $id): ?array {
try {
$stmt = $pdo->prepare("SELECT * FROM categories WHERE id = :id");
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$result = $stmt->fetch();
return $result ?: null;
} catch (PDOException $e) {
error_log("Erreur get_category_by_id: " . $e->getMessage());
return null;
}
}
/**
* Crée une nouvelle catégorie
*/
function create_category(PDO $pdo, string $name): int|false {
try {
$stmt = $pdo->prepare("
INSERT INTO categories (name)
VALUES (:name)
RETURNING id
");
$stmt->bindValue(':name', $name);
$stmt->execute();
$result = $stmt->fetch();
return $result ? (int) $result['id'] : false;
} catch (PDOException $e) {
error_log("Erreur create_category: " . $e->getMessage());
return false;
}
}
/**
* Met à jour une catégorie
*/
function update_category(PDO $pdo, int $id, string $name): bool {
try {
$stmt = $pdo->prepare("
UPDATE categories
SET name = :name
WHERE id = :id
");
$stmt->bindValue(':name', $name);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
return $stmt->execute();
} catch (PDOException $e) {
error_log("Erreur update_category: " . $e->getMessage());
return false;
}
}
/**
* Supprime une catégorie (seulement si aucun produit associé)
*/
function delete_category(PDO $pdo, int $id): bool {
try {
// Vérifier s'il y a des produits
$check_stmt = $pdo->prepare("SELECT COUNT(*) as count FROM products WHERE category_id = :id");
$check_stmt->bindValue(':id', $id, PDO::PARAM_INT);
$check_stmt->execute();
$row = $check_stmt->fetch();
if ((int) $row['count'] > 0) {
return false; // Impossible de supprimer, il y a des produits
}
$stmt = $pdo->prepare("DELETE FROM categories WHERE id = :id");
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
return $stmt->execute();
} catch (PDOException $e) {
error_log("Erreur delete_category: " . $e->getMessage());
return false;
}
}
// ==================== PRODUCTS ====================
/**
* Récupère tous les produits avec leurs catégories (JOINTURE OPTIMISÉE)
*/
function get_all_products(PDO $pdo, ?int $category_id = null, int $limit = 20, int $offset = 0): array {
try {
if ($category_id !== null) {
$query = "
SELECT
p.id, p.name, p.description, p.price, p.quantity,
p.created_at, p.updated_at, p.category_id,
c.name AS category_name
FROM products p
INNER JOIN categories c ON p.category_id = c.id
WHERE p.category_id = :category_id
ORDER BY p.name ASC
LIMIT :limit OFFSET :offset
";
$stmt = $pdo->prepare($query);
$stmt->bindValue(':category_id', $category_id, PDO::PARAM_INT);
} else {
$query = "
SELECT
p.id, p.name, p.description, p.price, p.quantity,
p.created_at, p.updated_at, p.category_id,
c.name AS category_name
FROM products p
INNER JOIN categories c ON p.category_id = c.id
ORDER BY p.name ASC
LIMIT :limit OFFSET :offset
";
$stmt = $pdo->prepare($query);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
} catch (PDOException $e) {
error_log("Erreur get_all_products: " . $e->getMessage());
return [];
}
}
/**
* Compte le nombre total de produits
*/
function count_products(PDO $pdo, ?int $category_id = null): int {
try {
if ($category_id !== null) {
$stmt = $pdo->prepare("SELECT COUNT(*) as count FROM products WHERE category_id = :category_id");
$stmt->bindValue(':category_id', $category_id, PDO::PARAM_INT);
} else {
$stmt = $pdo->query("SELECT COUNT(*) as count FROM products");
}
$stmt->execute();
$result = $stmt->fetch();
return (int) $result['count'];
} catch (PDOException $e) {
error_log("Erreur count_products: " . $e->getMessage());
return 0;
}
}
/**
* Récupère un produit par son ID avec sa catégorie
*/
function get_product_by_id(PDO $pdo, int $id): ?array {
try {
$query = "
SELECT
p.*, c.name AS category_name
FROM products p
INNER JOIN categories c ON p.category_id = c.id
WHERE p.id = :id
";
$stmt = $pdo->prepare($query);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$result = $stmt->fetch();
return $result ?: null;
} catch (PDOException $e) {
error_log("Erreur get_product_by_id: " . $e->getMessage());
return null;
}
}
/**
* Crée un nouveau produit
*/
function create_product(
PDO $pdo,
string $name,
?string $description,
float $price,
int $quantity,
int $category_id
): int|false {
try {
$stmt = $pdo->prepare("
INSERT INTO products (name, description, price, quantity, category_id)
VALUES (:name, :description, :price, :quantity, :category_id)
RETURNING id
");
$stmt->bindValue(':name', $name);
$stmt->bindValue(':description', $description);
$stmt->bindValue(':price', $price);
$stmt->bindValue(':quantity', $quantity, PDO::PARAM_INT);
$stmt->bindValue(':category_id', $category_id, PDO::PARAM_INT);
$stmt->execute();
$result = $stmt->fetch();
return $result ? (int) $result['id'] : false;
} catch (PDOException $e) {
error_log("Erreur create_product: " . $e->getMessage());
return false;
}
}
/**
* Met à jour un produit
*/
function update_product(
PDO $pdo,
int $id,
string $name,
?string $description,
float $price,
int $quantity,
int $category_id
): bool {
try {
$stmt = $pdo->prepare("
UPDATE products
SET name = :name,
description = :description,
price = :price,
quantity = :quantity,
category_id = :category_id
WHERE id = :id
");
$stmt->bindValue(':name', $name);
$stmt->bindValue(':description', $description);
$stmt->bindValue(':price', $price);
$stmt->bindValue(':quantity', $quantity, PDO::PARAM_INT);
$stmt->bindValue(':category_id', $category_id, PDO::PARAM_INT);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
return $stmt->execute();
} catch (PDOException $e) {
error_log("Erreur update_product: " . $e->getMessage());
return false;
}
}
/**
* Supprime un produit
*/
function delete_product(PDO $pdo, int $id): bool {
try {
$stmt = $pdo->prepare("DELETE FROM products WHERE id = :id");
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
return $stmt->execute();
} catch (PDOException $e) {
error_log("Erreur delete_product: " . $e->getMessage());
return false;
}
}
/**
* Recherche de produits par nom (OPTIMISÉ avec ILIKE)
*/
function search_products(PDO $pdo, string $search_term, int $limit = 20, int $offset = 0): array {
try {
$query = "
SELECT
p.id, p.name, p.description, p.price, p.quantity,
p.created_at, p.updated_at, p.category_id,
c.name AS category_name
FROM products p
INNER JOIN categories c ON p.category_id = c.id
WHERE p.name ILIKE :search OR p.description ILIKE :search
ORDER BY p.name ASC
LIMIT :limit OFFSET :offset
";
$stmt = $pdo->prepare($query);
$search_pattern = '%' . $search_term . '%';
$stmt->bindValue(':search', $search_pattern);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
} catch (PDOException $e) {
error_log("Erreur search_products: " . $e->getMessage());
return [];
}
}
/**
* Met à jour la quantité d'un produit (pour gestion de stock)
*/
function update_product_quantity(PDO $pdo, int $id, int $quantity_change): bool {
try {
$pdo->beginTransaction();
// Récupérer la quantité actuelle avec verrou
$select_stmt = $pdo->prepare("
SELECT quantity FROM products WHERE id = :id FOR UPDATE
");
$select_stmt->bindValue(':id', $id, PDO::PARAM_INT);
$select_stmt->execute();
$row = $select_stmt->fetch();
if (!$row) {
$pdo->rollBack();
return false;
}
$new_quantity = $row['quantity'] + $quantity_change;
// Vérifier que la quantité ne devient pas négative
if ($new_quantity < 0) {
$pdo->rollBack();
return false;
}
// Mettre à jour
$update_stmt = $pdo->prepare("
UPDATE products SET quantity = :quantity WHERE id = :id
");
$update_stmt->bindValue(':quantity', $new_quantity, PDO::PARAM_INT);
$update_stmt->bindValue(':id', $id, PDO::PARAM_INT);
if (!$update_stmt->execute()) {
$pdo->rollBack();
return false;
}
$pdo->commit();
return true;
} catch (PDOException $e) {
error_log("Erreur update_product_quantity: " . $e->getMessage());
$pdo->rollBack();
return false;
}
}
/**
* Récupère les statistiques par catégorie (Vue optimisée)
*/
function get_category_statistics(PDO $pdo): array {
try {
$stmt = $pdo->query("SELECT * FROM product_stats ORDER BY category_name");
return $stmt->fetchAll();
} catch (PDOException $e) {
error_log("Erreur get_category_statistics: " . $e->getMessage());
return [];
}
}
4. Page d’Accueil (public/index.php)
<?php
declare(strict_types=1);
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/queries.php';
session_start();
try {
$pdo = db_connect();
} catch (PDOException $e) {
die("Erreur de connexion à la base de données");
}
// Récupérer les statistiques globales
$total_categories = count_categories($pdo);
$total_products = count_products($pdo);
$category_stats = get_category_statistics($pdo);
// Calculer la valeur totale du stock
$total_stock_value = 0;
$total_items = 0;
foreach ($category_stats as $stat) {
$total_stock_value += $stat['total_quantity'] * $stat['avg_price'];
$total_items += $stat['total_quantity'];
}
// Récupérer les produits récents
$recent_products = get_all_products($pdo, null, 5, 0);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tableau de Bord - Gestion de Stock</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; }
.container { max-width: 1400px; margin: 0 auto; }
header { text-align: center; color: white; margin-bottom: 40px; }
header h1 { font-size: 48px; margin-bottom: 10px; text-shadow: 2px 2px 4px rgba(0,0,0,0.2); }
header p { font-size: 18px; opacity: 0.9; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; margin-bottom: 40px; }
.stat-card { background: white; padding: 30px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); text-align: center; transition: transform 0.3s; }
.stat-card:hover { transform: translateY(-5px); }
.stat-card .icon { font-size: 48px; margin-bottom: 15px; }
.stat-card .value { font-size: 36px; font-weight: bold; color: #333; margin-bottom: 10px; }
.stat-card .label { font-size: 14px; color: #666; text-transform: uppercase; letter-spacing: 1px; }
.nav-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-bottom: 40px; }
.nav-card { background: white; padding: 40px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); text-align: center; transition: all 0.3s; text-decoration: none; display: block; }
.nav-card:hover { transform: translateY(-5px); box-shadow: 0 8px 25px rgba(0,0,0,0.15); }
.nav-card .icon { font-size: 64px; margin-bottom: 20px; }
.nav-card h2 { color: #333; margin-bottom: 10px; font-size: 24px; }
.nav-card p { color: #666; font-size: 14px; }
.content-section { background: white; padding: 30px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); margin-bottom: 20px; }
.content-section h2 { color: #333; margin-bottom: 20px; border-bottom: 2px solid #667eea; padding-bottom: 10px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
th { background: #f8f9fa; font-weight: 600; }
tr:hover { background: #f8f9fa; }
.badge { display: inline-block; padding: 4px 8px; border-radius: 3px; font-size: 12px; font-weight: 600; }
.badge-primary { background: #007bff; color: white; }
.price { font-weight: bold; color: #28a745; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>🛒 Gestion de Stock</h1>
<p>Système de gestion PostgreSQL avec PHP 8.4 et PDO</p>
</header>
<?php display_flash(); ?>
<!-- Statistiques globales -->
<div class="stats-grid">
<div class="stat-card">
<div class="icon">📦</div>
<div class="value"><?= number_format($total_items) ?></div>
<div class="label">Articles en Stock</div>
</div>
<div class="stat-card">
<div class="icon">📁</div>
<div class="value"><?= $total_categories ?></div>
<div class="label">Catégories</div>
</div>
<div class="stat-card">
<div class="icon">🏷️</div>
<div class="value"><?= $total_products ?></div>
<div class="label">Produits</div>
</div>
<div class="stat-card">
<div class="icon">💰</div>
<div class="value"><?= format_price($total_stock_value) ?></div>
<div class="label">Valeur du Stock</div>
</div>
</div>
<!-- Navigation principale -->
<div class="nav-grid">
<a href="categories/index.php" class="nav-card">
<div class="icon">📁</div>
<h2>Gérer les Catégories</h2>
<p>Organiser et classer vos produits</p>
</a>
<a href="products/index.php" class="nav-card">
<div class="icon">🏷️</div>
<h2>Gérer les Produits</h2>
<p>Ajouter, modifier et supprimer des produits</p>
</a>
</div>
<!-- Statistiques par catégorie -->
<div class="content-section">
<h2>📊 Statistiques par Catégorie</h2>
<table>
<thead>
<tr>
<th>Catégorie</th>
<th>Produits</th>
<th>Stock Total</th>
<th>Prix Moyen</th>
<th>Prix Min</th>
<th>Prix Max</th>
</tr>
</thead>
<tbody>
<?php if (empty($category_stats)): ?>
<tr>
<td colspan="6" style="text-align: center; color: #666;">Aucune donnée disponible</td>
</tr>
<?php else: ?>
<?php foreach ($category_stats as $stat): ?>
<tr>
<td><strong><?= h($stat['category_name']) ?></strong></td>
<td><?= h($stat['product_count']) ?></td>
<td><?= number_format($stat['total_quantity']) ?></td>
<td class="price"><?= format_price($stat['avg_price']) ?></td>
<td class="price"><?= format_price($stat['min_price']) ?></td>
<td class="price"><?= format_price($stat['max_price']) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Produits récents -->
<div class="content-section">
<h2>🆕 Produits Récents</h2>
<table>
<thead>
<tr>
<th>Nom</th>
<th>Catégorie</th>
<th>Prix</th>
<th>Stock</th>
<th>Ajouté le</th>
</tr>
</thead>
<tbody>
<?php if (empty($recent_products)): ?>
<tr>
<td colspan="5" style="text-align: center; color: #666;">Aucun produit</td>
</tr>
<?php else: ?>
<?php foreach ($recent_products as $product): ?>
<tr>
<td><strong><?= h($product['name']) ?></strong></td>
<td><span class="badge badge-primary"><?= h($product['category_name']) ?></span></td>
<td class="price"><?= format_price($product['price']) ?></td>
<td><?= h($product['quantity']) ?></td>
<td><?= format_date($product['created_at']) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</body>
</html>
5. Catégories - Index (public/categories/index.php)
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/functions.php';
require_once __DIR__ . '/../../includes/queries.php';
session_start();
try {
$pdo = db_connect();
} catch (PDOException $e) {
die("Erreur de connexion à la base de données");
}
// Pagination
$page = validate_positive_int($_GET['page'] ?? 1) ?? 1;
$items_per_page = 10;
$offset = calculate_offset($page, $items_per_page);
// Récupérer les catégories
$categories = get_all_categories($pdo, $items_per_page, $offset);
$total_categories = count_categories($pdo);
$total_pages = calculate_pages($total_categories, $items_per_page);
// Récupérer les statistiques
$stats = get_category_statistics($pdo);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gestion des Catégories</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #333; margin-bottom: 30px; }
.nav { margin-bottom: 20px; }
.nav a { color: #007bff; text-decoration: none; margin-right: 20px; }
.nav a:hover { text-decoration: underline; }
.btn { display: inline-block; padding: 10px 20px; background: #007bff; color: white; text-decoration: none; border-radius: 5px; border: none; cursor: pointer; }
.btn:hover { background: #0056b3; }
.btn-danger { background: #dc3545; }
.btn-danger:hover { background: #c82333; }
.btn-success { background: #28a745; }
.btn-success:hover { background: #218838; }
.alert { padding: 15px; margin-bottom: 20px; border-radius: 5px; }
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.alert-danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
th { background: #f8f9fa; font-weight: 600; }
tr:hover { background: #f8f9fa; }
.actions a { margin-right: 10px; }
.pagination { margin-top: 20px; text-align: center; }
.pagination a, .pagination span { display: inline-block; padding: 8px 12px; margin: 0 5px; border: 1px solid #ddd; border-radius: 3px; text-decoration: none; color: #007bff; }
.pagination span { background: #007bff; color: white; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 30px; }
.stat-card { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 8px; }
.stat-card h3 { font-size: 14px; margin-bottom: 10px; opacity: 0.9; }
.stat-card .value { font-size: 28px; font-weight: bold; }
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a href="../index.php">Accueil</a>
<a href="index.php">Catégories</a>
<a href="../products/index.php">Produits</a>
</div>
<h1>Gestion des Catégories</h1>
<?php display_flash(); ?>
<!-- Statistiques -->
<div class="stats">
<div class="stat-card">
<h3>Total Catégories</h3>
<div class="value"><?= $total_categories ?></div>
</div>
</div>
<a href="create.php" class="btn btn-success">+ Nouvelle Catégorie</a>
<table>
<thead>
<tr>
<th>ID</th>
<th>Nom</th>
<th>Produits</th>
<th>Créée le</th>
<th>Modifiée le</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($categories)): ?>
<tr>
<td colspan="6" style="text-align: center; color: #666;">Aucune catégorie trouvée</td>
</tr>
<?php else: ?>
<?php foreach ($categories as $category): ?>
<?php
$category_stats = null;
foreach ($stats as $stat) {
if ($stat['category_id'] == $category['id']) {
$category_stats = $stat;
break;
}
}
?>
<tr>
<td><?= h($category['id']) ?></td>
<td><strong><?= h($category['name']) ?></strong></td>
<td>
<?php if ($category_stats): ?>
<?= h($category_stats['product_count']) ?> produit(s)
<?php else: ?>
0 produit
<?php endif; ?>
</td>
<td><?= format_date($category['created_at']) ?></td>
<td><?= format_date($category['updated_at']) ?></td>
<td class="actions">
<a href="edit.php?id=<?= $category['id'] ?>" class="btn">Modifier</a>
<a href="delete.php?id=<?= $category['id'] ?>"
class="btn btn-danger"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette catégorie ?')">
Supprimer
</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Pagination -->
<?php if ($total_pages > 1): ?>
<div class="pagination">
<?php if ($page > 1): ?>
<a href="?page=<?= $page - 1 ?>">« Précédent</a>
<?php endif; ?>
<?php for ($i = 1; $i <= $total_pages; $i++): ?>
<?php if ($i == $page): ?>
<span><?= $i ?></span>
<?php else: ?>
<a href="?page=<?= $i ?>"><?= $i ?></a>
<?php endif; ?>
<?php endfor; ?>
<?php if ($page < $total_pages): ?>
<a href="?page=<?= $page + 1 ?>">Suivant »</a>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</body>
</html>
6. Catégories - Create (public/categories/create.php)
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/functions.php';
require_once __DIR__ . '/../../includes/queries.php';
session_start();
try {
$pdo = db_connect();
} catch (PDOException $e) {
die("Erreur de connexion à la base de données");
}
$errors = [];
$name = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = validate_string($_POST['name'] ?? '', 100);
if ($name === null) {
$errors[] = "Le nom de la catégorie est requis (max 100 caractères)";
}
if (empty($errors)) {
$category_id = create_category($pdo, $name);
if ($category_id) {
set_flash('success', "Catégorie créée avec succès (ID: {$category_id})");
redirect('index.php');
} else {
$errors[] = "Erreur lors de la création de la catégorie";
}
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nouvelle Catégorie</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #333; margin-bottom: 30px; }
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 5px; font-weight: 600; color: #555; }
input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
input:focus { outline: none; border-color: #007bff; }
.btn { display: inline-block; padding: 10px 20px; background: #007bff; color: white; text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 14px; }
.btn:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; }
.btn-secondary:hover { background: #5a6268; }
.alert-danger { background: #f8d7da; color: #721c24; padding: 15px; border-radius: 5px; margin-bottom: 20px; border: 1px solid #f5c6cb; }
.back-link { display: inline-block; margin-bottom: 20px; color: #007bff; text-decoration: none; }
.back-link:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<a href="index.php" class="back-link">← Retour à la liste</a>
<h1>Nouvelle Catégorie</h1>
<?php if (!empty($errors)): ?>
<div class="alert-danger">
<ul>
<?php foreach ($errors as $error): ?>
<li><?= h($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="POST" action="">
<div class="form-group">
<label for="name">Nom de la catégorie *</label>
<input type="text"
id="name"
name="name"
value="<?= h($name) ?>"
required
maxlength="100"
placeholder="Ex: Électronique">
</div>
<button type="submit" class="btn">Créer la Catégorie</button>
<a href="index.php" class="btn btn-secondary">Annuler</a>
</form>
</div>
</body>
</html>
7. Catégories - Edit (public/categories/edit.php)
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/functions.php';
require_once __DIR__ . '/../../includes/queries.php';
session_start();
try {
$pdo = db_connect();
} catch (PDOException $e) {
die("Erreur de connexion à la base de données");
}
$id = validate_positive_int($_GET['id'] ?? 0);
if (!$id) {
set_flash('danger', "ID de catégorie invalide");
redirect('index.php');
}
$category = get_category_by_id($pdo, $id);
if (!$category) {
set_flash('danger', "Catégorie introuvable");
redirect('index.php');
}
$errors = [];
$name = $category['name'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = validate_string($_POST['name'] ?? '', 100);
if ($name === null) {
$errors[] = "Le nom de la catégorie est requis (max 100 caractères)";
}
if (empty($errors)) {
$success = update_category($pdo, $id, $name);
if ($success) {
set_flash('success', "Catégorie modifiée avec succès");
redirect('index.php');
} else {
$errors[] = "Erreur lors de la modification de la catégorie";
}
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Modifier Catégorie</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #333; margin-bottom: 30px; }
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 5px; font-weight: 600; color: #555; }
input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
input:focus { outline: none; border-color: #007bff; }
.btn { display: inline-block; padding: 10px 20px; background: #007bff; color: white; text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 14px; }
.btn:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; }
.btn-secondary:hover { background: #5a6268; }
.alert-danger { background: #f8d7da; color: #721c24; padding: 15px; border-radius: 5px; margin-bottom: 20px; border: 1px solid #f5c6cb; }
.back-link { display: inline-block; margin-bottom: 20px; color: #007bff; text-decoration: none; }
.back-link:hover { text-decoration: underline; }
.info-box { background: #e7f3ff; padding: 15px; border-left: 4px solid #007bff; margin-bottom: 20px; }
</style>
</head>
<body>
<div class="container">
<a href="index.php" class="back-link">← Retour à la liste</a>
<h1>Modifier Catégorie</h1>
<div class="info-box">
<strong>ID:</strong> <?= h($category['id']) ?><br>
<strong>Créée le:</strong> <?= format_date($category['created_at']) ?><br>
<strong>Modifiée le:</strong> <?= format_date($category['updated_at']) ?>
</div>
<?php if (!empty($errors)): ?>
<div class="alert-danger">
<ul>
<?php foreach ($errors as $error): ?>
<li><?= h($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="POST" action="">
<div class="form-group">
<label for="name">Nom de la catégorie *</label>
<input type="text"
id="name"
name="name"
value="<?= h($name) ?>"
required
maxlength="100">
</div>
<button type="submit" class="btn">Enregistrer les modifications</button>
<a href="index.php" class="btn btn-secondary">Annuler</a>
</form>
</div>
</body>
</html>
8. Catégories - Delete (public/categories/delete.php)
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/functions.php';
require_once __DIR__ . '/../../includes/queries.php';
session_start();
try {
$pdo = db_connect();
} catch (PDOException $e) {
die("Erreur de connexion à la base de données");
}
$id = validate_positive_int($_GET['id'] ?? 0);
if (!$id) {
set_flash('danger', "ID de catégorie invalide");
redirect('index.php');
}
$category = get_category_by_id($pdo, $id);
if (!$category) {
set_flash('danger', "Catégorie introuvable");
redirect('index.php');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$success = delete_category($pdo, $id);
if ($success) {
set_flash('success', "Catégorie supprimée avec succès");
} else {
set_flash('danger', "Impossible de supprimer cette catégorie car elle contient des produits");
}
redirect('index.php');
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Supprimer Catégorie</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #dc3545; margin-bottom: 30px; }
.warning-box { background: #fff3cd; padding: 20px; border-left: 4px solid #ffc107; margin-bottom: 20px; }
.category-info { background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
.btn { display: inline-block; padding: 10px 20px; background: #007bff; color: white; text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 14px; margin-right: 10px; }
.btn-danger { background: #dc3545; }
.btn-danger:hover { background: #c82333; }
.btn-secondary { background: #6c757d; }
.btn-secondary:hover { background: #5a6268; }
.back-link { display: inline-block; margin-bottom: 20px; color: #007bff; text-decoration: none; }
.back-link:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<a href="index.php" class="back-link">← Retour à la liste</a>
<h1>⚠️ Supprimer la Catégorie</h1>
<div class="warning-box">
<strong>Attention !</strong> Cette action est irréversible. Vous êtes sur le point de supprimer la catégorie suivante :
</div>
<div class="category-info">
<p><strong>ID :</strong> <?= h($category['id']) ?></p>
<p><strong>Nom :</strong> <?= h($category['name']) ?></p>
<p><strong>Créée le :</strong> <?= format_date($category['created_at']) ?></p>
</div>
<form method="POST" action="">
<button type="submit" class="btn btn-danger">Confirmer la suppression</button>
<a href="index.php" class="btn btn-secondary">Annuler</a>
</form>
</div>
</body>
</html>
9. Produits - Index (public/products/index.php)
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/functions.php';
require_once __DIR__ . '/../../includes/queries.php';
session_start();
try {
$pdo = db_connect();
} catch (PDOException $e) {
die("Erreur de connexion à la base de données");
}
// Filtres
$category_filter = validate_positive_int($_GET['category'] ?? 0);
$search = validate_string($_GET['search'] ?? '', 100);
// Pagination
$page = validate_positive_int($_GET['page'] ?? 1) ?? 1;
$items_per_page = 10;
$offset = calculate_offset($page, $items_per_page);
// Récupérer les produits
if ($search) {
$products = search_products($pdo, $search, $items_per_page, $offset);
$total_products = count($products);
} else {
$products = get_all_products($pdo, $category_filter ?: null, $items_per_page, $offset);
$total_products = count_products($pdo, $category_filter ?: null);
}
$total_pages = calculate_pages($total_products, $items_per_page);
// Récupérer toutes les catégories pour le filtre
$all_categories = get_all_categories($pdo, 100, 0);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gestion des Produits</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 1400px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #333; margin-bottom: 30px; }
.nav { margin-bottom: 20px; }
.nav a { color: #007bff; text-decoration: none; margin-right: 20px; }
.nav a:hover { text-decoration: underline; }
.filters { display: flex; gap: 15px; margin-bottom: 20px; flex-wrap: wrap; align-items: flex-end; }
.filter-group { flex: 1; min-width: 200px; }
.filter-group label { display: block; margin-bottom: 5px; font-weight: 600; color: #555; }
.filter-group select, .filter-group input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; }
.btn { display: inline-block; padding: 10px 20px; background: #007bff; color: white; text-decoration: none; border-radius: 5px; border: none; cursor: pointer; white-space: nowrap; }
.btn:hover { background: #0056b3; }
.btn-danger { background: #dc3545; }
.btn-danger:hover { background: #c82333; }
.btn-success { background: #28a745; }
.btn-success:hover { background: #218838; }
.btn-secondary { background: #6c757d; }
.btn-secondary:hover { background: #5a6268; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.alert { padding: 15px; margin-bottom: 20px; border-radius: 5px; }
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.alert-danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
th { background: #f8f9fa; font-weight: 600; }
tr:hover { background: #f8f9fa; }
.actions { white-space: nowrap; }
.actions a { margin-right: 5px; }
.pagination { margin-top: 20px; text-align: center; }
.pagination a, .pagination span { display: inline-block; padding: 8px 12px; margin: 0 5px; border: 1px solid #ddd; border-radius: 3px; text-decoration: none; color: #007bff; }
.pagination span { background: #007bff; color: white; }
.badge { display: inline-block; padding: 4px 8px; border-radius: 3px; font-size: 12px; font-weight: 600; }
.badge-primary { background: #007bff; color: white; }
.badge-warning { background: #ffc107; color: #000; }
.badge-success { background: #28a745; color: white; }
.price { font-weight: bold; color: #28a745; }
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a href="../index.php">Accueil</a>
<a href="../categories/index.php">Catégories</a>
<a href="index.php">Produits</a>
</div>
<h1>Gestion des Produits</h1>
<?php display_flash(); ?>
<!-- Filtres et recherche -->
<form method="GET" class="filters">
<div class="filter-group">
<label for="category">Catégorie</label>
<select name="category" id="category">
<option value="">Toutes les catégories</option>
<?php foreach ($all_categories as $cat): ?>
<option value="<?= $cat['id'] ?>" <?= $category_filter == $cat['id'] ? 'selected' : '' ?>>
<?= h($cat['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="filter-group">
<label for="search">Rechercher</label>
<input type="text"
name="search"
id="search"
placeholder="Nom ou description..."
value="<?= h($search) ?>">
</div>
<button type="submit" class="btn">Filtrer</button>
<a href="index.php" class="btn btn-secondary">Réinitialiser</a>
</form>
<a href="create.php" class="btn btn-success">+ Nouveau Produit</a>
<table>
<thead>
<tr>
<th>ID</th>
<th>Nom</th>
<th>Catégorie</th>
<th>Prix</th>
<th>Stock</th>
<th>Créé le</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($products)): ?>
<tr>
<td colspan="7" style="text-align: center; color: #666;">Aucun produit trouvé</td>
</tr>
<?php else: ?>
<?php foreach ($products as $product): ?>
<tr>
<td><?= h($product['id']) ?></td>
<td>
<strong><?= h($product['name']) ?></strong>
<?php if ($product['description']): ?>
<br><small style="color: #666;"><?= h(mb_substr($product['description'], 0, 50)) ?>...</small>
<?php endif; ?>
</td>
<td>
<span class="badge badge-primary"><?= h($product['category_name']) ?></span>
</td>
<td class="price"><?= format_price($product['price']) ?></td>
<td>
<?php if ($product['quantity'] <= 5): ?>
<span class="badge badge-warning"><?= h($product['quantity']) ?> ⚠️</span>
<?php else: ?>
<span class="badge badge-success"><?= h($product['quantity']) ?></span>
<?php endif; ?>
</td>
<td><?= format_date($product['created_at']) ?></td>
<td class="actions">
<a href="edit.php?id=<?= $product['id'] ?>" class="btn btn-sm">Modifier</a>
<a href="delete.php?id=<?= $product['id'] ?>"
class="btn btn-sm btn-danger"
onclick="return confirm('Supprimer ce produit ?')">
Supprimer
</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Pagination -->
<?php if ($total_pages > 1): ?>
<div class="pagination">
<?php
$query_params = [];
if ($category_filter) $query_params['category'] = $category_filter;
if ($search) $query_params['search'] = $search;
$base_url = '?' . http_build_query($query_params);
?>
<?php if ($page > 1): ?>
<a href="<?= $base_url ?>&page=<?= $page - 1 ?>">« Précédent</a>
<?php endif; ?>
<?php for ($i = 1; $i <= $total_pages; $i++): ?>
<?php if ($i == $page): ?>
<span><?= $i ?></span>
<?php else: ?>
<a href="<?= $base_url ?>&page=<?= $i ?>"><?= $i ?></a>
<?php endif; ?>
<?php endfor; ?>
<?php if ($page < $total_pages): ?>
<a href="<?= $base_url ?>&page=<?= $page + 1 ?>">Suivant »</a>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</body>
</html>
10. Produits - Create (public/products/create.php)
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/functions.php';
require_once __DIR__ . '/../../includes/queries.php';
session_start();
try {
$pdo = db_connect();
} catch (PDOException $e) {
die("Erreur de connexion à la base de données");
}
$errors = [];
$name = '';
$description = '';
$price = '';
$quantity = '';
$category_id = '';
// Récupérer toutes les catégories
$categories = get_all_categories($pdo, 100, 0);
if (empty($categories)) {
set_flash('danger', "Vous devez d'abord créer au moins une catégorie");
redirect('../categories/create.php');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validation
$name = validate_string($_POST['name'] ?? '', 200);
$description = trim($_POST['description'] ?? '');
$description = $description === '' ? null : $description;
$price = validate_price($_POST['price'] ?? 0);
$quantity = validate_positive_int($_POST['quantity'] ?? 0);
if ($quantity === null) $quantity = 0;
$category_id = validate_positive_int($_POST['category_id'] ?? 0);
if ($name === null) {
$errors[] = "Le nom du produit est requis (max 200 caractères)";
}
if ($price === null) {
$errors[] = "Le prix doit être un nombre positif ou zéro";
}
if ($quantity < 0) {
$errors[] = "La quantité ne peut pas être négative";
}
if (!$category_id) {
$errors[] = "Vous devez sélectionner une catégorie";
}
// Si pas d'erreurs, créer le produit
if (empty($errors)) {
$product_id = create_product($pdo, $name, $description, $price, $quantity, $category_id);
if ($product_id) {
set_flash('success', "Produit créé avec succès (ID: {$product_id})");
redirect('index.php');
} else {
$errors[] = "Erreur lors de la création du produit";
}
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nouveau Produit</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 800px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #333; margin-bottom: 30px; }
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 5px; font-weight: 600; color: #555; }
input, textarea, select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; font-family: inherit; }
input:focus, textarea:focus, select:focus { outline: none; border-color: #007bff; }
textarea { min-height: 100px; resize: vertical; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.btn { display: inline-block; padding: 10px 20px; background: #007bff; color: white; text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 14px; }
.btn:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; }
.btn-secondary:hover { background: #5a6268; }
.alert-danger { background: #f8d7da; color: #721c24; padding: 15px; border-radius: 5px; margin-bottom: 20px; border: 1px solid #f5c6cb; }
.back-link { display: inline-block; margin-bottom: 20px; color: #007bff; text-decoration: none; }
.back-link:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<a href="index.php" class="back-link">← Retour à la liste</a>
<h1>Nouveau Produit</h1>
<?php if (!empty($errors)): ?>
<div class="alert-danger">
<ul>
<?php foreach ($errors as $error): ?>
<li><?= h($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="POST" action="">
<div class="form-group">
<label for="name">Nom du produit *</label>
<input type="text"
id="name"
name="name"
value="<?= h($name) ?>"
required
maxlength="200"
placeholder="Ex: Ordinateur portable Dell XPS 13">
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description"
name="description"
placeholder="Description détaillée du produit..."><?= h($description ?? '') ?></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="price">Prix (€) *</label>
<input type="number"
id="price"
name="price"
value="<?= h($price) ?>"
required
step="0.01"
min="0"
placeholder="0.00">
</div>
<div class="form-group">
<label for="quantity">Quantité en stock *</label>
<input type="number"
id="quantity"
name="quantity"
value="<?= h($quantity) ?>"
required
min="0"
placeholder="0">
</div>
</div>
<div class="form-group">
<label for="category_id">Catégorie *</label>
<select id="category_id" name="category_id" required>
<option value="">-- Sélectionnez une catégorie --</option>
<?php foreach ($categories as $cat): ?>
<option value="<?= $cat['id'] ?>" <?= $category_id == $cat['id'] ? 'selected' : '' ?>>
<?= h($cat['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn">Créer le Produit</button>
<a href="index.php" class="btn btn-secondary">Annuler</a>
</form>
</div>
</body>
</html>
11. Produits - Edit (public/products/edit.php)
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/functions.php';
require_once __DIR__ . '/../../includes/queries.php';
session_start();
try {
$pdo = db_connect();
} catch (PDOException $e) {
die("Erreur de connexion à la base de données");
}
// Récupérer l'ID
$id = validate_positive_int($_GET['id'] ?? 0);
if (!$id) {
set_flash('danger', "ID de produit invalide");
redirect('index.php');
}
// Récupérer le produit
$product = get_product_by_id($pdo, $id);
if (!$product) {
set_flash('danger', "Produit introuvable");
redirect('index.php');
}
$errors = [];
$name = $product['name'];
$description = $product['description'] ?? '';
$price = $product['price'];
$quantity = $product['quantity'];
$category_id = $product['category_id'];
// Récupérer toutes les catégories
$categories = get_all_categories($pdo, 100, 0);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validation
$name = validate_string($_POST['name'] ?? '', 200);
$description = trim($_POST['description'] ?? '');
$description = $description === '' ? null : $description;
$price = validate_price($_POST['price'] ?? 0);
$quantity = validate_positive_int($_POST['quantity'] ?? 0);
if ($quantity === null) $quantity = 0;
$category_id = validate_positive_int($_POST['category_id'] ?? 0);
if ($name === null) {
$errors[] = "Le nom du produit est requis (max 200 caractères)";
}
if ($price === null) {
$errors[] = "Le prix doit être un nombre positif ou zéro";
}
if ($quantity < 0) {
$errors[] = "La quantité ne peut pas être négative";
}
if (!$category_id) {
$errors[] = "Vous devez sélectionner une catégorie";
}
// Si pas d'erreurs, mettre à jour
if (empty($errors)) {
$success = update_product($pdo, $id, $name, $description, $price, $quantity, $category_id);
if ($success) {
set_flash('success', "Produit modifié avec succès");
redirect('index.php');
} else {
$errors[] = "Erreur lors de la modification du produit";
}
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Modifier Produit</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 800px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #333; margin-bottom: 30px; }
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 5px; font-weight: 600; color: #555; }
input, textarea, select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; font-family: inherit; }
input:focus, textarea:focus, select:focus { outline: none; border-color: #007bff; }
textarea { min-height: 100px; resize: vertical; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.btn { display: inline-block; padding: 10px 20px; background: #007bff; color: white; text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 14px; }
.btn:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; }
.btn-secondary:hover { background: #5a6268; }
.alert-danger { background: #f8d7da; color: #721c24; padding: 15px; border-radius: 5px; margin-bottom: 20px; border: 1px solid #f5c6cb; }
.back-link { display: inline-block; margin-bottom: 20px; color: #007bff; text-decoration: none; }
.back-link:hover { text-decoration: underline; }
.info-box { background: #e7f3ff; padding: 15px; border-left: 4px solid #007bff; margin-bottom: 20px; }
</style>
</head>
<body>
<div class="container">
<a href="index.php" class="back-link">← Retour à la liste</a>
<h1>Modifier Produit</h1>
<div class="info-box">
<strong>ID:</strong> <?= h($product['id']) ?><br>
<strong>Catégorie actuelle:</strong> <?= h($product['category_name']) ?><br>
<strong>Créé le:</strong> <?= format_date($product['created_at']) ?><br>
<strong>Modifié le:</strong> <?= format_date($product['updated_at']) ?>
</div>
<?php if (!empty($errors)): ?>
<div class="alert-danger">
<ul>
<?php foreach ($errors as $error): ?>
<li><?= h($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="POST" action="">
<div class="form-group">
<label for="name">Nom du produit *</label>
<input type="text"
id="name"
name="name"
value="<?= h($name) ?>"
required
maxlength="200">
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description"><?= h($description) ?></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="price">Prix (€) *</label>
<input type="number"
id="price"
name="price"
value="<?= h($price) ?>"
required
step="0.01"
min="0">
</div>
<div class="form-group">
<label for="quantity">Quantité en stock *</label>
<input type="number"
id="quantity"
name="quantity"
value="<?= h($quantity) ?>"
required
min="0">
</div>
</div>
<div class="form-group">
<label for="category_id">Catégorie *</label>
<select id="category_id" name="category_id" required>
<option value="">-- Sélectionnez une catégorie --</option>
<?php foreach ($categories as $cat): ?>
<option value="<?= $cat['id'] ?>" <?= $category_id == $cat['id'] ? 'selected' : '' ?>>
<?= h($cat['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn">Enregistrer les modifications</button>
<a href="index.php" class="btn btn-secondary">Annuler</a>
</form>
</div>
</body>
</html>
12. Produits - Delete (public/products/delete.php)
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/functions.php';
require_once __DIR__ . '/../../includes/queries.php';
session_start();
try {
$pdo = db_connect();
} catch (PDOException $e) {
die("Erreur de connexion à la base de données");
}
// Récupérer l'ID
$id = validate_positive_int($_GET['id'] ?? 0);
if (!$id) {
set_flash('danger', "ID de produit invalide");
redirect('index.php');
}
// Récupérer le produit
$product = get_product_by_id($pdo, $id);
if (!$product) {
set_flash('danger', "Produit introuvable");
redirect('index.php');
}
// Vérifier si la méthode est POST (confirmation)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$success = delete_product($pdo, $id);
if ($success) {
set_flash('success', "Produit supprimé avec succès");
} else {
set_flash('danger', "Erreur lors de la suppression du produit");
}
redirect('index.php');
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Supprimer Produit</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 700px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #dc3545; margin-bottom: 30px; }
.warning-box { background: #fff3cd; padding: 20px; border-left: 4px solid #ffc107; margin-bottom: 20px; }
.product-info { background: #f8f9fa; padding: 20px; border-radius: 5px; margin-bottom: 20px; }
.product-info p { margin-bottom: 10px; }
.product-info .price { font-size: 24px; font-weight: bold; color: #28a745; }
.btn { display: inline-block; padding: 10px 20px; background: #007bff; color: white; text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 14px; margin-right: 10px; }
.btn-danger { background: #dc3545; }
.btn-danger:hover { background: #c82333; }
.btn-secondary { background: #6c757d; }
.btn-secondary:hover { background: #5a6268; }
.back-link { display: inline-block; margin-bottom: 20px; color: #007bff; text-decoration: none; }
.back-link:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<a href="index.php" class="back-link">← Retour à la liste</a>
<h1>⚠️ Supprimer le Produit</h1>
<div class="warning-box">
<strong>Attention !</strong> Cette action est irréversible. Vous êtes sur le point de supprimer le produit suivant :
</div>
<div class="product-info">
<p><strong>ID :</strong> <?= h($product['id']) ?></p>
<p><strong>Nom :</strong> <?= h($product['name']) ?></p>
<p><strong>Catégorie :</strong> <?= h($product['category_name']) ?></p>
<p><strong>Description :</strong> <?= h($product['description'] ?: 'Aucune') ?></p>
<p class="price"><?= format_price($product['price']) ?></p>
<p><strong>Stock :</strong> <?= h($product['quantity']) ?> unité(s)</p>
<p><strong>Créé le :</strong> <?= format_date($product['created_at']) ?></p>
</div>
<form method="POST" action="">
<button type="submit" class="btn btn-danger">Confirmer la suppression</button>
<a href="index.php" class="btn btn-secondary">Annuler</a>
</form>
</div>
</body>
</html>
📊 Résumé des Changements PDO
✅ Avantages de PDO vs pg_*
| Aspect | pg_* | PDO |
|---|---|---|
| Sécurité | pg_query_params() |
Requêtes préparées natives |
| Portabilité | PostgreSQL uniquement | MySQL, SQLite, Oracle, etc. |
| Gestion d’erreurs | Manuelle | Exceptions PDOException |
| Performance | Bonne | Excellente avec prepared statements |
| Fetch modes | pg_fetch_assoc() |
PDO::FETCH_ASSOC |
| Transactions | pg_query() |
beginTransaction() / commit() |
| Binding | :1, :2 |
:name ou ? |
🔐 Sécurité PDO
// ❌ Avant (Injection SQL possible)
$result = pg_query($conn, "SELECT * FROM products WHERE id = " . $_GET['id']);
// ✅ Après (Sécurisé)
$stmt = $pdo->prepare("SELECT * FROM products WHERE id = :id");
$stmt->bindValue(':id', $_GET['id'], PDO::PARAM_INT);
$stmt->execute();
🎯 Points clés PDO
- Connexion statique : Une seule instance PDO en mémoire
- Exceptions : Gestion d’erreurs via try/catch
- Binding :
:nameou?pour les paramètres - Fetch modes :
PDO::FETCH_ASSOCpar défaut - Transactions :
beginTransaction(),commit(),rollBack()
🚀 Utilisation
Structure complète :
project/
├── config/
│ └── database.php ← Connexion PDO
├── includes/
│ ├── functions.php ← Fonctions utilitaires
│ └── queries.php ← Requêtes PDO
├── public/
│ ├── index.php ← Dashboard
│ ├── categories/
│ │ ├── index.php
│ │ ├── create.php
│ │ ├── edit.php
│ │ └── delete.php
│ └── products/
│ ├── index.php
│ ├── create.php
│ ├── edit.php
│ └── delete.php
└── sql/
└── schema.sql ← Schéma PostgreSQL
Démarrage :
- Créer la BD : Exécuter
sql/schema.sql - Configurer : Éditer
config/database.php - Accéder :
http://localhost/project/public/index.php
✨ Améliorations apportées
✅ PDO au lieu de pg_*
✅ Type hints avec declare(strict_types=1)
✅ Exceptions PDOException capturées
✅ Binding de paramètres sécurisé
✅ Transactions avec beginTransaction()
✅ Fetch mode uniforme PDO::FETCH_ASSOC
✅ Code plus portable (compatible autres BD)
✅ Gestion d’erreurs robuste
Le CRUD complet avec PDO est maintenant prêt ! 🎉
🐛 Correction de l’Erreur de Type
Le problème vient du fait que $product['price'] est retourné en string par PDO au lieu de float.
Solution 1 : Corriger la fonction format_price() (Recommandé)
Modifiez la fonction dans includes/functions.php :
/**
* Formate un prix pour l'affichage
*/
function format_price(float|string $price): string {
// Convertir en float si c'est une string
$price = (float) $price;
return number_format($price, 2, ',', ' ') . ' €';
}
Solution 2 : Configurer PDO pour les types numériques
Modifiez config/database.php pour que PDO retourne les nombres en tant que nombres :
<?php
declare(strict_types=1);
// Configuration PostgreSQL
const DB_HOST = "localhost";
const DB_PORT = "5432";
const DB_NAME = "shop_db";
const DB_USER = "postgres";
const DB_PASS = "postgres";
/**
* Établit une connexion PDO à PostgreSQL
*/
function db_connect(): PDO {
static $pdo = null;
if ($pdo !== null) {
return $pdo;
}
try {
$dsn = sprintf(
"pgsql:host=%s;port=%s;dbname=%s",
DB_HOST,
DB_PORT,
DB_NAME
);
$pdo = new PDO($dsn, DB_USER, DB_PASS);
// Configuration PDO
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
// ✅ IMPORTANT : Convertir les nombres en types PHP
$pdo->setAttribute(PDO::PGSQL_ATTR_DISABLE_NATIVE_PREPARED_STATEMENT, false);
// Encodage UTF-8
$pdo->exec("SET NAMES utf8");
$pdo->exec("SET search_path TO public");
return $pdo;
} catch (PDOException $e) {
error_log("Erreur de connexion PostgreSQL: " . $e->getMessage());
throw new PDOException("Impossible de se connecter à la base de données");
}
}
/**
* Ferme la connexion PDO
*/
function db_close(): void {
global $pdo;
$pdo = null;
}
Solution 3 : Convertir les types dans les requêtes (Meilleure approche)
Modifiez includes/queries.php pour convertir les types au niveau de la requête :
/**
* Récupère tous les produits avec leurs catégories (JOINTURE OPTIMISÉE)
*/
function get_all_products(PDO $pdo, ?int $category_id = null, int $limit = 20, int $offset = 0): array {
try {
if ($category_id !== null) {
$query = "
SELECT
p.id,
p.name,
p.description,
CAST(p.price AS FLOAT) as price,
p.quantity,
p.created_at,
p.updated_at,
p.category_id,
c.name AS category_name
FROM products p
INNER JOIN categories c ON p.category_id = c.id
WHERE p.category_id = :category_id
ORDER BY p.name ASC
LIMIT :limit OFFSET :offset
";
$stmt = $pdo->prepare($query);
$stmt->bindValue(':category_id', $category_id, PDO::PARAM_INT);
} else {
$query = "
SELECT
p.id,
p.name,
p.description,
CAST(p.price AS FLOAT) as price,
p.quantity,
p.created_at,
p.updated_at,
p.category_id,
c.name AS category_name
FROM products p
INNER JOIN categories c ON p.category_id = c.id
ORDER BY p.name ASC
LIMIT :limit OFFSET :offset
";
$stmt = $pdo->prepare($query);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
} catch (PDOException $e) {
error_log("Erreur get_all_products: " . $e->getMessage());
return [];
}
}
/**
* Récupère un produit par son ID avec sa catégorie
*/
function get_product_by_id(PDO $pdo, int $id): ?array {
try {
$query = "
SELECT
p.id,
p.name,
p.description,
CAST(p.price AS FLOAT) as price,
p.quantity,
p.created_at,
p.updated_at,
p.category_id,
c.name AS category_name
FROM products p
INNER JOIN categories c ON p.category_id = c.id
WHERE p.id = :id
";
$stmt = $pdo->prepare($query);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$result = $stmt->fetch();
return $result ?: null;
} catch (PDOException $e) {
error_log("Erreur get_product_by_id: " . $e->getMessage());
return null;
}
}
/**
* Recherche de produits par nom (OPTIMISÉ avec ILIKE)
*/
function search_products(PDO $pdo, string $search_term, int $limit = 20, int $offset = 0): array {
try {
$query = "
SELECT
p.id,
p.name,
p.description,
CAST(p.price AS FLOAT) as price,
p.quantity,
p.created_at,
p.updated_at,
p.category_id,
c.name AS category_name
FROM products p
INNER JOIN categories c ON p.category_id = c.id
WHERE p.name ILIKE :search OR p.description ILIKE :search
ORDER BY p.name ASC
LIMIT :limit OFFSET :offset
";
$stmt = $pdo->prepare($query);
$search_pattern = '%' . $search_term . '%';
$stmt->bindValue(':search', $search_pattern);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
} catch (PDOException $e) {
error_log("Erreur search_products: " . $e->getMessage());
return [];
}
}
/**
* Récupère les statistiques par catégorie (Vue optimisée)
*/
function get_category_statistics(PDO $pdo): array {
try {
$query = "
SELECT
category_id,
category_name,
product_count,
total_quantity,
CAST(avg_price AS FLOAT) as avg_price,
CAST(min_price AS FLOAT) as min_price,
CAST(max_price AS FLOAT) as max_price
FROM product_stats
ORDER BY category_name
";
$stmt = $pdo->query($query);
return $stmt->fetchAll();
} catch (PDOException $e) {
error_log("Erreur get_category_statistics: " . $e->getMessage());
return [];
}
}
Solution 4 : Créer une classe de conversion (Plus robuste)
Créez un fichier includes/types.php :
<?php
declare(strict_types=1);
/**
* Classe pour gérer les conversions de types
*/
class TypeConverter {
/**
* Convertit un tableau de résultats en types corrects
*/
public static function convertProduct(array $product): array {
return [
'id' => (int) $product['id'],
'name' => (string) $product['name'],
'description' => $product['description'] ? (string) $product['description'] : null,
'price' => (float) $product['price'],
'quantity' => (int) $product['quantity'],
'category_id' => (int) $product['category_id'],
'category_name' => (string) $product['category_name'],
'created_at' => (string) $product['created_at'],
'updated_at' => (string) $product['updated_at'],
];
}
/**
* Convertit un tableau de produits
*/
public static function convertProducts(array $products): array {
return array_map([self::class, 'convertProduct'], $products);
}
/**
* Convertit une catégorie
*/
public static function convertCategory(array $category): array {
return [
'id' => (int) $category['id'],
'name' => (string) $category['name'],
'created_at' => (string) $category['created_at'],
'updated_at' => (string) $category['updated_at'],
];
}
/**
* Convertit les statistiques
*/
public static function convertStats(array $stats): array {
return [
'category_id' => (int) $stats['category_id'],
'category_name' => (string) $stats['category_name'],
'product_count' => (int) $stats['product_count'],
'total_quantity' => (int) $stats['total_quantity'],
'avg_price' => (float) $stats['avg_price'],
'min_price' => (float) $stats['min_price'],
'max_price' => (float) $stats['max_price'],
];
}
}
Puis utilisez-le dans includes/queries.php :
<?php
declare(strict_types=1);
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/types.php';
/**
* Récupère tous les produits avec leurs catégories
*/
function get_all_products(PDO $pdo, ?int $category_id = null, int $limit = 20, int $offset = 0): array {
try {
if ($category_id !== null) {
$query = "
SELECT
p.id, p.name, p.description, p.price, p.quantity,
p.created_at, p.updated_at, p.category_id,
c.name AS category_name
FROM products p
INNER JOIN categories c ON p.category_id = c.id
WHERE p.category_id = :category_id
ORDER BY p.name ASC
LIMIT :limit OFFSET :offset
";
$stmt = $pdo->prepare($query);
$stmt->bindValue(':category_id', $category_id, PDO::PARAM_INT);
} else {
$query = "
SELECT
p.id, p.name, p.description, p.price, p.quantity,
p.created_at, p.updated_at, p.category_id,
c.name AS category_name
FROM products p
INNER JOIN categories c ON p.category_id = c.id
ORDER BY p.name ASC
LIMIT :limit OFFSET :offset
";
$stmt = $pdo->prepare($query);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$products = $stmt->fetchAll();
return array_map([TypeConverter::class, 'convertProduct'], $products);
} catch (PDOException $e) {
error_log("Erreur get_all_products: " . $e->getMessage());
return [];
}
}
✅ Recommandation : Utiliser la Solution 1 (Plus simple)
Modifiez simplement includes/functions.php :
/**
* Formate un prix pour l'affichage
*/
function format_price(float|string $price): string {
$price = (float) $price;
return number_format($price, 2, ',', ' ') . ' €';
}
/**
* Échappe les caractères HTML pour éviter XSS
*/
function h(string|int|float $text): string {
return htmlspecialchars((string) $text, ENT_QUOTES, 'UTF-8');
}
Et décommentez la fonction h() dans votre fichier !
🎯 Résumé
| Solution | Avantages | Inconvénients |
|---|---|---|
1. Modifier format_price() |
✅ Simple, rapide | ⚠️ Conversion à chaque appel |
| 2. Config PDO | ✅ Automatique | ⚠️ Peut affecter d’autres types |
| 3. CAST SQL | ✅ Au niveau DB | ⚠️ À faire dans chaque requête |
| 4. TypeConverter | ✅ Robuste, réutilisable | ⚠️ Plus de code |
Je recommande la Solution 1 + décommenter h() pour votre cas ! 🚀
Prêt à passer à la suite ?
Vous avez terminé cette leçon sur CRUD avec PDO PostgreSQL. Continuez votre apprentissage avec la prochaine leçon ou explorez d'autres formations.