Création du site
This commit is contained in:
commit
448cb25002
22 changed files with 2888 additions and 0 deletions
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Environnement et secrets
|
||||
.env
|
||||
*.local
|
||||
|
||||
# Dépendances (si composer est ajouté ultérieurement)
|
||||
/vendor/
|
||||
composer.lock
|
||||
|
||||
# Fichiers temporaires système et éditeurs
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.log
|
||||
/sessions/
|
||||
106
README.md
Normal file
106
README.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# 🚗 Voitures API Client - Architecture MVC PHP
|
||||
|
||||
Application web développée en **PHP 8 / HTML5 / CSS3 / JavaScript** suivant le patron de conception **MVC (Modèle - Vue - Contrôleur)**, spécialement adaptée pour consommer l'API REST de gestion de véhicules disponible sur [VoituresApi-API](https://code.erwann-philippe.xyz/wktr/VoituresApi-API).
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Connexion à l'API locale
|
||||
|
||||
- **URL de l'API locale** : `http://localhost:8888`
|
||||
- **Fichier de configuration** : `config/config.php`
|
||||
- **Authentification** : Jeton Bearer (`Authorization: Bearer <token>` et `X-API-KEY: <token>`) stocké en session après connexion via `POST /auth/login`.
|
||||
|
||||
### 🔑 Comptes de démonstration préconfigurés dans l'API :
|
||||
| Rôle | Identifiant | Mot de passe | Accès |
|
||||
|---|---|---|---|
|
||||
| **Admin** | `admin` | `adminpassword` | Accès total à tous les véhicules, entretiens et notes |
|
||||
| **Utilisateur** | `user` | `userpassword` | Gestion de ses propres véhicules, entretiens et notes |
|
||||
|
||||
*(Sur la page de connexion `/login`, deux boutons de raccourci permettent de vous connecter en 1 clic avec ces comptes).*
|
||||
|
||||
---
|
||||
|
||||
## 📁 Architecture du projet MVC
|
||||
|
||||
```text
|
||||
voituresAPI-Front/
|
||||
├── config/
|
||||
│ └── config.php # Base URL (http://localhost:8888), timeouts & constantes
|
||||
│
|
||||
├── public/ # Dossier public exposé par le serveur web
|
||||
│ ├── .htaccess # Redirection Apache vers index.php
|
||||
│ ├── index.php # Front Controller (point d'entrée unique & déclaration des routes)
|
||||
│ └── assets/
|
||||
│ ├── css/
|
||||
│ │ └── style.css # Feuille de style (plaques style FR, onglets, badges, responsive)
|
||||
│ └── js/
|
||||
│ └── main.js # Recherche dynamique sans rechargement, alertes flash, confirmations
|
||||
│
|
||||
├── src/ # Cœur du code MVC
|
||||
│ ├── Core/
|
||||
│ │ ├── Autoloader.php # Autoloader PSR-4 interne
|
||||
│ │ └── Router.php # Routeur HTTP avec regex pour routes dynamiques (/cars/{id}...)
|
||||
│ │
|
||||
│ ├── Controllers/
|
||||
│ │ ├── BaseController.php # Méthodes de rendu, redirections et messages flash
|
||||
│ │ ├── AuthController.php # Connexion, inscription et déconnexion via l'API
|
||||
│ │ └── CarController.php # CRUD véhicules, relevés km, entretiens et notes
|
||||
│ │
|
||||
│ ├── Models/
|
||||
│ │ └── Car.php # Modèle Car correspondant exactement aux données de l'API
|
||||
│ │
|
||||
│ ├── Services/
|
||||
│ │ └── ApiService.php # Client HTTP cURL (GET, POST, PUT, DELETE, Bearer token)
|
||||
│ │
|
||||
│ └── Views/
|
||||
│ ├── layout/
|
||||
│ │ ├── header.php # Navigation, statut connecté/déconnecté, recherche de plaque
|
||||
│ │ └── footer.php # Pied de page
|
||||
│ ├── cars/
|
||||
│ │ ├── index.php # Grille des véhicules, plaques, propriétaires, filtres
|
||||
│ │ ├── show.php # Fiche détaillée avec onglets (km, entretiens, notes)
|
||||
│ │ └── form.php # Formulaire d'ajout / modification
|
||||
│ ├── auth/
|
||||
│ │ ├── login.php # Page de connexion avec raccourcis 1-clic admin/user
|
||||
│ │ └── register.php# Page d'inscription
|
||||
│ └── errors/
|
||||
│ └── 404.php # Page d'erreur 404
|
||||
│
|
||||
├── .gitignore
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛣️ Routes de l'application Front
|
||||
|
||||
| Méthode | Route | Action correspondante | Accès |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/` ou `/cars` | Liste tous les véhicules (`GET /cars`) | Public |
|
||||
| `GET` | `/cars?mine=1` | Liste mes véhicules (`GET /cars/mes-voitures`) | Authentifié |
|
||||
| `GET` | `/cars/{id}` | Fiche détaillée avec km et entretiens (`GET /cars/{id}`) | Public |
|
||||
| `GET` | `/cars/create` | Formulaire d'ajout d'un véhicule | Authentifié |
|
||||
| `POST`| `/cars/create` | Envoi à l'API (`POST /cars`) | Authentifié |
|
||||
| `GET` | `/cars/{id}/edit` | Formulaire d'édition | Propriétaire / Admin |
|
||||
| `POST`| `/cars/{id}/edit` | Mise à jour dans l'API (`PUT /cars/{id}`) | Propriétaire / Admin |
|
||||
| `POST`| `/cars/{id}/delete`| Suppression (`DELETE /cars/{id}`) | Propriétaire / Admin |
|
||||
| `POST`| `/cars/{id}/kilometrage` | Ajout relevé km (`POST /cars/{id}/kilometrage`) | Propriétaire / Admin |
|
||||
| `POST`| `/cars/{id}/maintenance` | Ajout entretien (`POST /cars/{id}/maintenance`) | Propriétaire / Admin |
|
||||
| `POST`| `/cars/{id}/notes` | Ajout note de suivi (`POST /cars/{id}/notes`) | Propriétaire / Admin |
|
||||
| `GET` | `/login` / `POST /login` | Connexion (`POST /auth/login`) | Public |
|
||||
| `GET` | `/register` / `POST /register` | Inscription (`POST /auth/register`) | Public |
|
||||
| `GET` | `/logout` | Déconnexion (`POST /auth/logout`) | Authentifié |
|
||||
|
||||
---
|
||||
|
||||
## 💻 Démarrage du serveur
|
||||
|
||||
Assurez-vous que l'API tourne sur `http://localhost:8888`.
|
||||
|
||||
Lancez le serveur PHP pour le front-end depuis la racine de `voituresAPI-Front` :
|
||||
|
||||
```powershell
|
||||
php -S localhost:8000 -t public
|
||||
```
|
||||
|
||||
Rendez-vous sur votre navigateur à l'adresse : **[http://localhost:8000](http://localhost:8000)**
|
||||
16
config/config.php
Normal file
16
config/config.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
/**
|
||||
* Configuration globale de l'application Front-end
|
||||
*/
|
||||
|
||||
// Nom de l'application
|
||||
define('APP_NAME', 'Voitures API Client');
|
||||
|
||||
// URL de base de l'API REST locale
|
||||
define('API_BASE_URL', getenv('API_BASE_URL') ?: 'http://localhost:8888');
|
||||
|
||||
// Timeout des requêtes HTTP cURL vers l'API (en secondes)
|
||||
define('API_TIMEOUT', 5);
|
||||
|
||||
// Mode débogage
|
||||
define('APP_DEBUG', true);
|
||||
10
public/.htaccess
Normal file
10
public/.htaccess
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# Si le fichier ou le dossier physique demandé existe, on le sert directement (css, js, images...)
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
|
||||
# Sinon on redirige tout vers index.php
|
||||
RewriteRule ^(.*)$ index.php [QSA,L]
|
||||
</IfModule>
|
||||
928
public/assets/css/style.css
Normal file
928
public/assets/css/style.css
Normal file
|
|
@ -0,0 +1,928 @@
|
|||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--secondary: #475569;
|
||||
--secondary-hover: #334155;
|
||||
--danger: #ef4444;
|
||||
--danger-hover: #dc2626;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--bg-main: #f8fafc;
|
||||
--bg-card: #ffffff;
|
||||
--text-main: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--border-color: #e2e8f0;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-main);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.navbar {
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.navbar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 72px;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
text-decoration: none;
|
||||
font-weight: 800;
|
||||
font-size: 1.3rem;
|
||||
color: var(--text-main);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.navbar-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
max-width: 320px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar-search-input {
|
||||
width: 100%;
|
||||
padding: 0.45rem 2.2rem 0.45rem 0.85rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
background: #f1f5f9;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar-search-input:focus {
|
||||
outline: none;
|
||||
background: #fff;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.navbar-search-btn {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--primary);
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.user-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #f1f5f9;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-role-badge {
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-admin {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.badge-user {
|
||||
background-color: #e0e7ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
.nav-link-logout {
|
||||
text-decoration: none;
|
||||
opacity: 0.7;
|
||||
margin-left: 0.25rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.nav-link-logout:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Plaque d'immatriculation style FR */
|
||||
.license-plate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 2px solid #0f172a;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
font-family: 'Plus Jakarta Sans', monospace;
|
||||
font-weight: 800;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.plate-country {
|
||||
background: #003399;
|
||||
color: #ffd700;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.plate-number {
|
||||
padding: 0.2rem 0.65rem;
|
||||
letter-spacing: 0.12em;
|
||||
color: #0f172a;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.license-plate-lg .plate-country {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.license-plate-lg .plate-number {
|
||||
padding: 0.4rem 1.1rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 3.5rem;
|
||||
}
|
||||
|
||||
/* Page Header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.85rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.65rem 1.25rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--secondary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--secondary-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: var(--danger-hover);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-main);
|
||||
}
|
||||
.btn-outline:hover {
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #ecfdf5;
|
||||
color: #065f46;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: #fffbeb;
|
||||
color: #92400e;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background-color: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: #eff6ff;
|
||||
color: #1e40af;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.alert-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
color: inherit;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
/* Filter Bar */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.75rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
padding-left: 2.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
background: #e2e8f0;
|
||||
padding: 3px;
|
||||
border-radius: var(--radius-md);
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
padding: 0.4rem 0.9rem;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
background: #fff;
|
||||
color: var(--primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Cards Grid */
|
||||
.cars-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.car-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.car-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.car-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.car-year {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.car-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.car-stats-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-km {
|
||||
background-color: #eff6ff;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stat-km-lg {
|
||||
background-color: #eff6ff;
|
||||
color: var(--primary);
|
||||
font-size: 1.25rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.stat-date {
|
||||
background-color: #f1f5f9;
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.car-attribute {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.car-owner {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.4rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.car-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Detail Card */
|
||||
.detail-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.detail-hero-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tab-navigation {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
background-color: #f1f5f9;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.75rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* Data Tables */
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.data-table th, .data-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: #f8fafc;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.data-table tr:hover td {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right !important;
|
||||
}
|
||||
|
||||
/* Notes Grid */
|
||||
.notes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.note-card {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.note-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.note-header h4 {
|
||||
font-size: 1rem;
|
||||
color: #92400e;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.note-date {
|
||||
font-size: 0.75rem;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.note-content {
|
||||
font-size: 0.9rem;
|
||||
color: #78350f;
|
||||
flex-grow: 1;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.note-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-card-container {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
.form-help {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.collapsible-form {
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.collapsible-form.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.inline-add-form {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Auth Cards */
|
||||
.auth-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2.5rem;
|
||||
max-width: 460px;
|
||||
width: 100%;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.demo-accounts-box {
|
||||
margin-top: 1.75rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.demo-accounts-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Empty State / 404 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background: var(--bg-card);
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 2rem 0;
|
||||
margin-top: auto;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.footer-meta {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.footer-meta code {
|
||||
background: #f1f5f9;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
color: #0f172a;
|
||||
}
|
||||
62
public/assets/js/main.js
Normal file
62
public/assets/js/main.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Logique JavaScript côté client
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 1. Filtrage instantané de la liste des véhicules
|
||||
initLiveSearch();
|
||||
|
||||
// 2. Auto-disparition des alertes flash après 5 secondes
|
||||
initFlashAutoDismiss();
|
||||
});
|
||||
|
||||
/**
|
||||
* Filtre en direct les cartes de voitures selon la saisie
|
||||
*/
|
||||
function initLiveSearch() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const carCounter = document.getElementById('carCounter');
|
||||
const cards = document.querySelectorAll('.car-card');
|
||||
|
||||
if (!searchInput || cards.length === 0) return;
|
||||
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const query = e.target.value.toLowerCase().trim();
|
||||
let visibleCount = 0;
|
||||
|
||||
cards.forEach((card) => {
|
||||
const content = card.getAttribute('data-search') || '';
|
||||
if (content.includes(query)) {
|
||||
card.style.display = 'flex';
|
||||
visibleCount++;
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
if (carCounter) {
|
||||
carCounter.textContent = visibleCount;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fait disparaître les messages flash automatiquement
|
||||
*/
|
||||
function initFlashAutoDismiss() {
|
||||
const alert = document.getElementById('flash-alert');
|
||||
if (!alert) return;
|
||||
|
||||
setTimeout(() => {
|
||||
alert.style.transition = 'opacity 0.5s ease';
|
||||
alert.style.opacity = '0';
|
||||
setTimeout(() => alert.remove(), 500);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation générique pour les actions destructives
|
||||
*/
|
||||
function confirmAction(message) {
|
||||
return window.confirm(message || 'Voulez-vous vraiment effectuer cette action ?');
|
||||
}
|
||||
76
public/index.php
Normal file
76
public/index.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Front Controller - Point d'entrée unique de l'application
|
||||
*/
|
||||
|
||||
// Configuration d'un répertoire de sessions accessible en écriture
|
||||
$sessionSavePath = dirname(__DIR__) . '/sessions';
|
||||
if (!is_dir($sessionSavePath)) {
|
||||
@mkdir($sessionSavePath, 0777, true);
|
||||
}
|
||||
|
||||
if (is_dir($sessionSavePath) && is_writable($sessionSavePath)) {
|
||||
session_save_path($sessionSavePath);
|
||||
} else {
|
||||
session_save_path(sys_get_temp_dir());
|
||||
}
|
||||
|
||||
// Paramètres et démarrage de la session
|
||||
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
@session_start();
|
||||
}
|
||||
|
||||
// Chargement de la configuration
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
// Chargement et enregistrement de l'autoloader PSR-4
|
||||
require_once __DIR__ . '/../src/Core/Autoloader.php';
|
||||
\App\Core\Autoloader::register();
|
||||
|
||||
use App\Core\Router;
|
||||
use App\Controllers\CarController;
|
||||
use App\Controllers\AuthController;
|
||||
|
||||
$router = new Router();
|
||||
|
||||
// --- Routes d'authentification ---
|
||||
$router->get('/login', [AuthController::class, 'login']);
|
||||
$router->post('/login', [AuthController::class, 'authenticate']);
|
||||
$router->get('/register', [AuthController::class, 'register']);
|
||||
$router->post('/register', [AuthController::class, 'storeUser']);
|
||||
$router->get('/logout', [AuthController::class, 'logout']);
|
||||
|
||||
// --- Routes des véhicules ---
|
||||
$router->get('/', [CarController::class, 'index']);
|
||||
$router->get('/cars', [CarController::class, 'index']);
|
||||
|
||||
// Création d'un véhicule
|
||||
$router->get('/cars/create', [CarController::class, 'create']);
|
||||
$router->post('/cars/create', [CarController::class, 'store']);
|
||||
|
||||
// Consultation d'un véhicule
|
||||
$router->get('/cars/{id}', [CarController::class, 'show']);
|
||||
|
||||
// Modification d'un véhicule
|
||||
$router->get('/cars/{id}/edit', [CarController::class, 'edit']);
|
||||
$router->post('/cars/{id}/edit', [CarController::class, 'update']);
|
||||
|
||||
// Suppression d'un véhicule
|
||||
$router->post('/cars/{id}/delete', [CarController::class, 'delete']);
|
||||
|
||||
// Relevés kilométriques
|
||||
$router->post('/cars/{id}/kilometrage', [CarController::class, 'addKilometrage']);
|
||||
$router->post('/cars/{id}/kilometrage/{kmId}/delete', [CarController::class, 'deleteKilometrage']);
|
||||
|
||||
// Entretiens & Maintenance
|
||||
$router->post('/cars/{id}/maintenance', [CarController::class, 'addMaintenance']);
|
||||
$router->post('/cars/{id}/maintenance/{maintId}/delete', [CarController::class, 'deleteMaintenance']);
|
||||
|
||||
// Notes confidentielles
|
||||
$router->post('/cars/{id}/notes', [CarController::class, 'addNote']);
|
||||
$router->post('/cars/{id}/notes/{noteId}/delete', [CarController::class, 'deleteNote']);
|
||||
|
||||
// Résolution de la requête
|
||||
$router->dispatch();
|
||||
121
src/Controllers/AuthController.php
Normal file
121
src/Controllers/AuthController.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Services\ApiService;
|
||||
|
||||
/**
|
||||
* Contrôleur gérant l'authentification (connexion, inscription, déconnexion)
|
||||
*/
|
||||
class AuthController extends BaseController
|
||||
{
|
||||
private ApiService $apiService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiService = new ApiService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formulaire de connexion
|
||||
*/
|
||||
public function login(): void
|
||||
{
|
||||
if (!empty($_SESSION['user'])) {
|
||||
$this->redirect('/cars');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->render('auth/login', [
|
||||
'title' => 'Connexion à l\'espace gestionnaire'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Traitement de la connexion
|
||||
*/
|
||||
public function authenticate(): void
|
||||
{
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
$this->setFlash('danger', 'Veuillez saisir votre nom d\'utilisateur et mot de passe.');
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->apiService->post('/auth/login', [
|
||||
'username' => $username,
|
||||
'password' => $password
|
||||
]);
|
||||
|
||||
if ($response['success'] && !empty($response['data']['token'])) {
|
||||
$_SESSION['token'] = $response['data']['token'];
|
||||
$_SESSION['user'] = $response['data']['user'];
|
||||
|
||||
$this->setFlash('success', "Bienvenue, {$response['data']['user']['nom']} ! Connecté en tant que " . ucfirst($response['data']['user']['role']));
|
||||
$this->redirect('/cars');
|
||||
} else {
|
||||
$this->setFlash('danger', $response['error'] ?? 'Identifiants invalides');
|
||||
$this->redirect('/login');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formulaire d'inscription
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->render('auth/register', [
|
||||
'title' => 'Créer un compte'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Traitement de l'inscription
|
||||
*/
|
||||
public function storeUser(): void
|
||||
{
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$nom = trim($_POST['nom'] ?? '');
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
$this->setFlash('danger', 'Le nom d\'utilisateur et le mot de passe sont obligatoires.');
|
||||
$this->redirect('/register');
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->apiService->post('/auth/register', [
|
||||
'username' => $username,
|
||||
'nom' => $nom,
|
||||
'password' => $password
|
||||
]);
|
||||
|
||||
if ($response['success']) {
|
||||
$_SESSION['token'] = $response['data']['token'];
|
||||
$_SESSION['user'] = $response['data']['user'];
|
||||
|
||||
$this->setFlash('success', 'Votre compte a été créé avec succès !');
|
||||
$this->redirect('/cars');
|
||||
} else {
|
||||
$this->setFlash('danger', $response['error'] ?? 'Échec lors de l\'inscription');
|
||||
$this->redirect('/register');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Déconnexion
|
||||
*/
|
||||
public function logout(): void
|
||||
{
|
||||
if (!empty($_SESSION['token'])) {
|
||||
$this->apiService->post('/auth/logout');
|
||||
}
|
||||
|
||||
unset($_SESSION['token'], $_SESSION['user']);
|
||||
$this->setFlash('info', 'Vous avez été déconnecté avec succès.');
|
||||
$this->redirect('/cars');
|
||||
}
|
||||
}
|
||||
88
src/Controllers/BaseController.php
Normal file
88
src/Controllers/BaseController.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
/**
|
||||
* Contrôleur de base fournissant les méthodes de rendu et utilitaires
|
||||
*/
|
||||
class BaseController
|
||||
{
|
||||
/**
|
||||
* Rend une vue avec un layout standard (header, contenu, footer)
|
||||
*/
|
||||
public function render(string $view, array $data = []): void
|
||||
{
|
||||
// Extraction des variables pour les rendre accessibles dans la vue
|
||||
extract($data);
|
||||
|
||||
// Récupération des messages flash
|
||||
$flashMessage = $this->getFlash();
|
||||
|
||||
$baseViewDir = dirname(__DIR__) . '/Views/';
|
||||
$viewFile = $baseViewDir . $view . '.php';
|
||||
|
||||
if (!file_exists($viewFile)) {
|
||||
die("Erreur : La vue <strong>{$view}</strong> est introuvable dans {$viewFile}");
|
||||
}
|
||||
|
||||
// Chargement du layout global
|
||||
require_once $baseViewDir . 'layout/header.php';
|
||||
require_once $viewFile;
|
||||
require_once $baseViewDir . 'layout/footer.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirige l'utilisateur vers une URL donnée
|
||||
*/
|
||||
public function redirect(string $url): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_write_close();
|
||||
}
|
||||
header("Location: {$url}");
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Réponse JSON (utile pour les appels AJAX du front JS)
|
||||
*/
|
||||
public function json(mixed $data, int $statusCode = 200): void
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Définit un message flash en session (ex: 'success', 'error', 'warning')
|
||||
*/
|
||||
public function setFlash(string $type, string $message): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
|
||||
@session_start();
|
||||
}
|
||||
$_SESSION['flash'] = [
|
||||
'type' => $type,
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère et vide le message flash en cours
|
||||
*/
|
||||
public function getFlash(): ?array
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
|
||||
@session_start();
|
||||
}
|
||||
|
||||
if (isset($_SESSION['flash'])) {
|
||||
$flash = $_SESSION['flash'];
|
||||
unset($_SESSION['flash']);
|
||||
return $flash;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
414
src/Controllers/CarController.php
Normal file
414
src/Controllers/CarController.php
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\Car;
|
||||
use App\Services\ApiService;
|
||||
|
||||
/**
|
||||
* Contrôleur principal pour la gestion des véhicules et de leur suivi
|
||||
*/
|
||||
class CarController extends BaseController
|
||||
{
|
||||
private ApiService $apiService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiService = new ApiService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des véhicules avec support des filtres (mes voitures, immatriculation)
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$queryParams = [];
|
||||
$isMineOnly = !empty($_GET['mine']);
|
||||
|
||||
if ($isMineOnly && !empty($_SESSION['token'])) {
|
||||
$response = $this->apiService->get('/cars/mes-voitures');
|
||||
} elseif (!empty($_GET['immatriculation'])) {
|
||||
$plate = trim((string)$_GET['immatriculation']);
|
||||
$response = $this->apiService->get("/cars/immatriculation/{$plate}");
|
||||
} else {
|
||||
$response = $this->apiService->get('/cars');
|
||||
}
|
||||
|
||||
$cars = [];
|
||||
$apiError = null;
|
||||
|
||||
if ($response['success']) {
|
||||
$data = $response['data'];
|
||||
// Si c'est une recherche par plaque, l'API retourne un seul objet
|
||||
if (isset($data['id']) && !isset($data[0])) {
|
||||
$cars[] = Car::fromArray($data);
|
||||
} elseif (is_array($data)) {
|
||||
foreach ($data as $item) {
|
||||
$cars[] = Car::fromArray($item);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$apiError = $response['error'];
|
||||
}
|
||||
|
||||
$this->render('cars/index', [
|
||||
'title' => 'Parc Automobile - Véhicules',
|
||||
'cars' => $cars,
|
||||
'apiError' => $apiError,
|
||||
'isMineOnly' => $isMineOnly,
|
||||
'searchedPlate' => $_GET['immatriculation'] ?? ''
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Affiche la fiche complète d'un véhicule (avec relevés km, entretiens et notes)
|
||||
*/
|
||||
public function show($id): void
|
||||
{
|
||||
$carResponse = $this->apiService->get("/cars/{$id}");
|
||||
|
||||
if (!$carResponse['success'] || empty($carResponse['data'])) {
|
||||
$this->setFlash('danger', $carResponse['error'] ?? "Véhicule #{$id} introuvable.");
|
||||
$this->redirect('/cars');
|
||||
return;
|
||||
}
|
||||
|
||||
$car = Car::fromArray($carResponse['data']);
|
||||
|
||||
// Récupération de l'historique des relevés kilométriques
|
||||
$kmResponse = $this->apiService->get("/cars/{$id}/kilometrage");
|
||||
$kilometrages = $kmResponse['success'] && is_array($kmResponse['data']) ? $kmResponse['data'] : [];
|
||||
|
||||
// Récupération de l'historique des entretiens
|
||||
$maintenanceResponse = $this->apiService->get("/cars/{$id}/maintenance");
|
||||
$maintenances = $maintenanceResponse['success'] && is_array($maintenanceResponse['data']) ? $maintenanceResponse['data'] : [];
|
||||
|
||||
// Récupération des notes confidentielles (accessible uniquement pour propriétaire ou admin)
|
||||
$notes = [];
|
||||
if (!empty($_SESSION['token'])) {
|
||||
$notesResponse = $this->apiService->get("/cars/{$id}/notes");
|
||||
if ($notesResponse['success'] && is_array($notesResponse['data'])) {
|
||||
$notes = $notesResponse['data'];
|
||||
}
|
||||
}
|
||||
|
||||
$this->render('cars/show', [
|
||||
'title' => $car->getFullName() . ' (' . ($car->immatriculation ?: 'Sans plaque') . ')',
|
||||
'car' => $car,
|
||||
'kilometrages' => $kilometrages,
|
||||
'maintenances' => $maintenances,
|
||||
'notes' => $notes,
|
||||
'currentUser' => $_SESSION['user'] ?? null
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formulaire d'ajout
|
||||
*/
|
||||
public function create(): void
|
||||
{
|
||||
if (empty($_SESSION['token'])) {
|
||||
$this->setFlash('warning', 'Veuillez vous connecter pour ajouter un véhicule.');
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->render('cars/form', [
|
||||
'title' => 'Ajouter un nouveau véhicule',
|
||||
'car' => new Car(),
|
||||
'action' => '/cars/create',
|
||||
'isEdit' => false
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un nouveau véhicule dans l'API
|
||||
*/
|
||||
public function store(): void
|
||||
{
|
||||
if (empty($_SESSION['token'])) {
|
||||
$this->setFlash('danger', 'Session expirée ou non connecté.');
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'marque' => trim($_POST['marque'] ?? ''),
|
||||
'modele' => trim($_POST['modele'] ?? ''),
|
||||
'annee' => !empty($_POST['annee']) ? (int)$_POST['annee'] : null,
|
||||
'dateAchat' => trim($_POST['dateAchat'] ?? ''),
|
||||
'immatriculation' => trim($_POST['immatriculation'] ?? ''),
|
||||
'VIN' => trim($_POST['vin'] ?? $_POST['VIN'] ?? ''),
|
||||
'kilometrage_initial' => !empty($_POST['kilometrage_initial']) ? (int)$_POST['kilometrage_initial'] : null,
|
||||
];
|
||||
|
||||
if (empty($data['marque']) || empty($data['modele']) || empty($data['annee']) || empty($data['dateAchat'])) {
|
||||
$this->setFlash('danger', 'La marque, le modèle, l\'année et la date d\'achat sont obligatoires.');
|
||||
$this->redirect('/cars/create');
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->apiService->post('/cars', $data);
|
||||
|
||||
if ($response['success']) {
|
||||
$newId = $response['data']['id'] ?? null;
|
||||
$this->setFlash('success', 'Véhicule créé avec succès !');
|
||||
$this->redirect($newId ? "/cars/{$newId}" : '/cars');
|
||||
} else {
|
||||
$this->setFlash('danger', $response['error'] ?? 'Échec de la création du véhicule.');
|
||||
$this->redirect('/cars/create');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formulaire de modification
|
||||
*/
|
||||
public function edit($id): void
|
||||
{
|
||||
if (empty($_SESSION['token'])) {
|
||||
$this->setFlash('warning', 'Veuillez vous connecter pour modifier un véhicule.');
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->apiService->get("/cars/{$id}");
|
||||
|
||||
if (!$response['success'] || empty($response['data'])) {
|
||||
$this->setFlash('danger', 'Véhicule introuvable.');
|
||||
$this->redirect('/cars');
|
||||
return;
|
||||
}
|
||||
|
||||
$car = Car::fromArray($response['data']);
|
||||
|
||||
$this->render('cars/form', [
|
||||
'title' => 'Modifier : ' . $car->getFullName(),
|
||||
'car' => $car,
|
||||
'action' => "/cars/{$id}/edit",
|
||||
'isEdit' => true
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour les paramètres d'une voiture
|
||||
*/
|
||||
public function update($id): void
|
||||
{
|
||||
if (empty($_SESSION['token'])) {
|
||||
$this->setFlash('danger', 'Authentification requise.');
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'marque' => trim($_POST['marque'] ?? ''),
|
||||
'modele' => trim($_POST['modele'] ?? ''),
|
||||
'annee' => !empty($_POST['annee']) ? (int)$_POST['annee'] : null,
|
||||
'dateAchat' => trim($_POST['dateAchat'] ?? ''),
|
||||
'immatriculation' => trim($_POST['immatriculation'] ?? ''),
|
||||
'VIN' => trim($_POST['vin'] ?? $_POST['VIN'] ?? ''),
|
||||
];
|
||||
|
||||
$response = $this->apiService->put("/cars/{$id}", $data);
|
||||
|
||||
if ($response['success']) {
|
||||
$this->setFlash('success', 'Informations du véhicule mises à jour.');
|
||||
$this->redirect("/cars/{$id}");
|
||||
} else {
|
||||
$this->setFlash('danger', $response['error'] ?? 'Erreur lors de la mise à jour.');
|
||||
$this->redirect("/cars/{$id}/edit");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une voiture
|
||||
*/
|
||||
public function delete($id): void
|
||||
{
|
||||
if (empty($_SESSION['token'])) {
|
||||
$this->setFlash('danger', 'Authentification requise.');
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->apiService->delete("/cars/{$id}");
|
||||
|
||||
if ($response['success']) {
|
||||
$this->setFlash('success', "Le véhicule #{$id} a été supprimé avec succès.");
|
||||
} else {
|
||||
$this->setFlash('danger', $response['error'] ?? 'Erreur lors de la suppression.');
|
||||
}
|
||||
|
||||
$this->redirect('/cars');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un relevé kilométrique à une voiture
|
||||
*/
|
||||
public function addKilometrage($id): void
|
||||
{
|
||||
if (empty($_SESSION['token'])) {
|
||||
$this->setFlash('danger', 'Authentification requise.');
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
$valeur = !empty($_POST['valeur']) ? (int)$_POST['valeur'] : null;
|
||||
$dateReleve = !empty($_POST['date_releve']) ? trim($_POST['date_releve']) : date('Y-m-d');
|
||||
|
||||
if (!$valeur) {
|
||||
$this->setFlash('danger', 'Veuillez saisir une valeur de kilométrage.');
|
||||
$this->redirect("/cars/{$id}");
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->apiService->post("/cars/{$id}/kilometrage", [
|
||||
'valeur' => $valeur,
|
||||
'date_releve' => $dateReleve
|
||||
]);
|
||||
|
||||
if ($response['success']) {
|
||||
$this->setFlash('success', 'Nouveau relevé kilométrique enregistré !');
|
||||
} else {
|
||||
$this->setFlash('danger', $response['error'] ?? 'Échec de l\'enregistrement du relevé.');
|
||||
}
|
||||
|
||||
$this->redirect("/cars/{$id}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un relevé kilométrique
|
||||
*/
|
||||
public function deleteKilometrage($carId, $kmId): void
|
||||
{
|
||||
if (empty($_SESSION['token'])) {
|
||||
$this->setFlash('danger', 'Authentification requise.');
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->apiService->delete("/kilometrage/{$kmId}");
|
||||
|
||||
if ($response['success']) {
|
||||
$this->setFlash('success', 'Relevé kilométrique supprimé.');
|
||||
} else {
|
||||
$this->setFlash('danger', $response['error'] ?? 'Échec de la suppression.');
|
||||
}
|
||||
|
||||
$this->redirect("/cars/{$carId}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un entretien
|
||||
*/
|
||||
public function addMaintenance($id): void
|
||||
{
|
||||
if (empty($_SESSION['token'])) {
|
||||
$this->setFlash('danger', 'Authentification requise.');
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'type_entretien' => trim($_POST['type_entretien'] ?? ''),
|
||||
'date_evenement' => trim($_POST['date_evenement'] ?? date('Y-m-d')),
|
||||
'kilometrage' => !empty($_POST['kilometrage']) ? (int)$_POST['kilometrage'] : null,
|
||||
'description' => trim($_POST['description'] ?? ''),
|
||||
'prix' => !empty($_POST['prix']) ? (float)$_POST['prix'] : null,
|
||||
];
|
||||
|
||||
if (empty($data['type_entretien']) || empty($data['date_evenement']) || $data['kilometrage'] === null) {
|
||||
$this->setFlash('danger', 'Le type, la date et le kilométrage de l\'entretien sont obligatoires.');
|
||||
$this->redirect("/cars/{$id}");
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->apiService->post("/cars/{$id}/maintenance", $data);
|
||||
|
||||
if ($response['success']) {
|
||||
$this->setFlash('success', 'Entretien enregistré avec succès !');
|
||||
} else {
|
||||
$this->setFlash('danger', $response['error'] ?? 'Erreur lors de l\'enregistrement de l\'entretien.');
|
||||
}
|
||||
|
||||
$this->redirect("/cars/{$id}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un entretien
|
||||
*/
|
||||
public function deleteMaintenance($carId, $maintId): void
|
||||
{
|
||||
if (empty($_SESSION['token'])) {
|
||||
$this->setFlash('danger', 'Authentification requise.');
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->apiService->delete("/maintenance/{$maintId}");
|
||||
|
||||
if ($response['success']) {
|
||||
$this->setFlash('success', 'Entretien supprimé.');
|
||||
} else {
|
||||
$this->setFlash('danger', $response['error'] ?? 'Échec de la suppression.');
|
||||
}
|
||||
|
||||
$this->redirect("/cars/{$carId}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une note confidentielle
|
||||
*/
|
||||
public function addNote($id): void
|
||||
{
|
||||
if (empty($_SESSION['token'])) {
|
||||
$this->setFlash('danger', 'Authentification requise.');
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
$titre = trim($_POST['titre'] ?? '');
|
||||
$contenu = trim($_POST['contenu'] ?? '');
|
||||
|
||||
if (empty($contenu)) {
|
||||
$this->setFlash('danger', 'Le contenu de la note est obligatoire.');
|
||||
$this->redirect("/cars/{$id}");
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->apiService->post("/cars/{$id}/notes", [
|
||||
'titre' => $titre,
|
||||
'contenu' => $contenu
|
||||
]);
|
||||
|
||||
if ($response['success']) {
|
||||
$this->setFlash('success', 'Note confidentielle ajoutée.');
|
||||
} else {
|
||||
$this->setFlash('danger', $response['error'] ?? 'Erreur lors de l\'ajout de la note.');
|
||||
}
|
||||
|
||||
$this->redirect("/cars/{$id}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une note confidentielle
|
||||
*/
|
||||
public function deleteNote($carId, $noteId): void
|
||||
{
|
||||
if (empty($_SESSION['token'])) {
|
||||
$this->setFlash('danger', 'Authentification requise.');
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->apiService->delete("/notes/{$noteId}");
|
||||
|
||||
if ($response['success']) {
|
||||
$this->setFlash('success', 'Note supprimée.');
|
||||
} else {
|
||||
$this->setFlash('danger', $response['error'] ?? 'Échec de la suppression.');
|
||||
}
|
||||
|
||||
$this->redirect("/cars/{$carId}");
|
||||
}
|
||||
}
|
||||
29
src/Core/Autoloader.php
Normal file
29
src/Core/Autoloader.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Autoloader PSR-4 simplifié pour charger les classes du namespace App\
|
||||
*/
|
||||
class Autoloader
|
||||
{
|
||||
public static function register(): void
|
||||
{
|
||||
spl_autoload_register(function (string $class): void {
|
||||
$prefix = 'App\\';
|
||||
$baseDir = dirname(__DIR__) . '/';
|
||||
|
||||
$len = strlen($prefix);
|
||||
if (strncmp($prefix, $class, $len) !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
94
src/Core/Router.php
Normal file
94
src/Core/Router.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Routeur léger permettant de faire correspondre une URL et une méthode HTTP à une action de contrôleur.
|
||||
*/
|
||||
class Router
|
||||
{
|
||||
private array $routes = [];
|
||||
|
||||
/**
|
||||
* Enregistre une route GET
|
||||
*/
|
||||
public function get(string $path, array $handler): void
|
||||
{
|
||||
$this->addRoute('GET', $path, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre une route POST
|
||||
*/
|
||||
public function post(string $path, array $handler): void
|
||||
{
|
||||
$this->addRoute('POST', $path, $handler);
|
||||
}
|
||||
|
||||
private function addRoute(string $method, string $path, array $handler): void
|
||||
{
|
||||
// Normaliser le chemin
|
||||
$path = '/' . trim($path, '/');
|
||||
if ($path !== '/' && str_ends_with($path, '/')) {
|
||||
$path = rtrim($path, '/');
|
||||
}
|
||||
|
||||
$this->routes[] = [
|
||||
'method' => strtoupper($method),
|
||||
'path' => $path,
|
||||
'handler' => $handler // [ControllerClass, 'actionMethod']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout la requête entrante et exécute le contrôleur correspondant
|
||||
*/
|
||||
public function dispatch(): void
|
||||
{
|
||||
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
|
||||
// Détection et suppression du sous-dossier éventuel (Wamp / sous-répertoires)
|
||||
$scriptDir = str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? ''));
|
||||
if ($scriptDir !== '/' && $scriptDir !== '.' && $scriptDir !== '' && strpos($uri, $scriptDir) === 0) {
|
||||
$uri = substr($uri, strlen($scriptDir));
|
||||
}
|
||||
$uri = preg_replace('#^/?index\.php#i', '', $uri);
|
||||
|
||||
// Normaliser l'URI demandée
|
||||
$requestUri = '/' . trim($uri, '/');
|
||||
if ($requestUri !== '/' && str_ends_with($requestUri, '/')) {
|
||||
$requestUri = rtrim($requestUri, '/');
|
||||
}
|
||||
|
||||
foreach ($this->routes as $route) {
|
||||
if ($route['method'] !== $requestMethod) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Conversion des paramètres dynamiques {id} en regex
|
||||
$pattern = preg_replace('#\{([a-zA-Z0-9_]+)\}#', '(?P<$1>[^/]+)', $route['path']);
|
||||
$pattern = '#^' . $pattern . '$#';
|
||||
|
||||
if (preg_match($pattern, $requestUri, $matches)) {
|
||||
// Filtrer pour ne garder que les paramètres nommés
|
||||
$params = array_filter($matches, fn($key) => !is_numeric($key), ARRAY_FILTER_USE_KEY);
|
||||
|
||||
[$controllerClass, $method] = $route['handler'];
|
||||
|
||||
if (class_exists($controllerClass)) {
|
||||
$controller = new $controllerClass();
|
||||
if (method_exists($controller, $method)) {
|
||||
call_user_func_array([$controller, $method], array_values($params));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aucune route trouvée -> 404
|
||||
http_response_code(404);
|
||||
$errorController = new \App\Controllers\BaseController();
|
||||
$errorController->render('errors/404', ['title' => 'Page non trouvée']);
|
||||
}
|
||||
}
|
||||
137
src/Models/Car.php
Normal file
137
src/Models/Car.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* Modèle Car correspondant à la structure de données de l'API Voitures
|
||||
*/
|
||||
class Car
|
||||
{
|
||||
public ?int $id;
|
||||
public ?int $userId;
|
||||
public string $marque;
|
||||
public string $modele;
|
||||
public ?int $annee;
|
||||
public ?string $dateAchat;
|
||||
public ?string $immatriculation;
|
||||
public ?string $vin;
|
||||
public ?int $dernierKm;
|
||||
public ?array $proprietaire;
|
||||
|
||||
public function __construct(
|
||||
?int $id = null,
|
||||
?int $userId = null,
|
||||
string $marque = '',
|
||||
string $modele = '',
|
||||
?int $annee = null,
|
||||
?string $dateAchat = null,
|
||||
?string $immatriculation = null,
|
||||
?string $vin = null,
|
||||
?int $dernierKm = null,
|
||||
?array $proprietaire = null
|
||||
) {
|
||||
$this->id = $id;
|
||||
$this->userId = $userId;
|
||||
$this->marque = $marque;
|
||||
$this->modele = $modele;
|
||||
$this->annee = $annee;
|
||||
$this->dateAchat = $dateAchat;
|
||||
$this->immatriculation = $immatriculation;
|
||||
$this->vin = $vin;
|
||||
$this->dernierKm = $dernierKm;
|
||||
$this->proprietaire = $proprietaire;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une instance depuis le retour JSON de l'API
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
id: isset($data['id']) ? (int)$data['id'] : null,
|
||||
userId: isset($data['user_id']) ? (int)$data['user_id'] : null,
|
||||
marque: (string)($data['marque'] ?? $data['brand'] ?? ''),
|
||||
modele: (string)($data['modele'] ?? $data['model'] ?? ''),
|
||||
annee: isset($data['annee']) ? (int)$data['annee'] : (isset($data['year']) ? (int)$data['year'] : null),
|
||||
dateAchat: (string)($data['dateAchat'] ?? $data['date_achat'] ?? ''),
|
||||
immatriculation: (string)($data['immatriculation'] ?? ''),
|
||||
vin: (string)($data['VIN'] ?? $data['vin'] ?? ''),
|
||||
dernierKm: isset($data['dernier_km']) && $data['dernier_km'] !== null ? (int)$data['dernier_km'] : null,
|
||||
proprietaire: isset($data['proprietaire']) && is_array($data['proprietaire']) ? $data['proprietaire'] : null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prépare les données pour l'envoi vers l'API
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'marque' => $this->marque,
|
||||
'modele' => $this->modele,
|
||||
'annee' => $this->annee,
|
||||
'dateAchat' => $this->dateAchat,
|
||||
'immatriculation' => $this->immatriculation,
|
||||
'VIN' => $this->vin,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nom complet (Marque Modèle)
|
||||
*/
|
||||
public function getFullName(): string
|
||||
{
|
||||
return trim($this->marque . ' ' . $this->modele);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kilométrage formaté
|
||||
*/
|
||||
public function getFormattedKm(): string
|
||||
{
|
||||
if ($this->dernierKm === null) {
|
||||
return 'Non renseigné';
|
||||
}
|
||||
return number_format($this->dernierKm, 0, ',', ' ') . ' km';
|
||||
}
|
||||
|
||||
/**
|
||||
* Date d'achat formatée au format FR
|
||||
*/
|
||||
public function getFormattedDateAchat(): string
|
||||
{
|
||||
if (empty($this->dateAchat)) {
|
||||
return '—';
|
||||
}
|
||||
$timestamp = strtotime($this->dateAchat);
|
||||
return $timestamp ? date('d/m/Y', $timestamp) : $this->dateAchat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nom ou identifiant du propriétaire
|
||||
*/
|
||||
public function getOwnerName(): string
|
||||
{
|
||||
if (!empty($this->proprietaire['nom'])) {
|
||||
return $this->proprietaire['nom'];
|
||||
}
|
||||
if (!empty($this->proprietaire['username'])) {
|
||||
return $this->proprietaire['username'];
|
||||
}
|
||||
return 'Non assigné';
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur actuellement connecté peut modifier ce véhicule
|
||||
*/
|
||||
public function canEdit(?array $currentUser): bool
|
||||
{
|
||||
if (!$currentUser) {
|
||||
return false;
|
||||
}
|
||||
if (strtolower($currentUser['role'] ?? '') === 'admin') {
|
||||
return true;
|
||||
}
|
||||
return isset($currentUser['id']) && (int)$currentUser['id'] === (int)$this->userId;
|
||||
}
|
||||
}
|
||||
137
src/Services/ApiService.php
Normal file
137
src/Services/ApiService.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Service de communication HTTP avec l'API REST Voitures
|
||||
*/
|
||||
class ApiService
|
||||
{
|
||||
private string $baseUrl;
|
||||
private int $timeout;
|
||||
|
||||
public function __construct(?string $baseUrl = null, ?int $timeout = null)
|
||||
{
|
||||
$this->baseUrl = rtrim($baseUrl ?? API_BASE_URL, '/');
|
||||
$this->timeout = $timeout ?? API_TIMEOUT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une requête GET
|
||||
*/
|
||||
public function get(string $endpoint, array $queryParams = []): array
|
||||
{
|
||||
$url = $this->buildUrl($endpoint, $queryParams);
|
||||
return $this->request('GET', $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une requête POST
|
||||
*/
|
||||
public function post(string $endpoint, array $data = []): array
|
||||
{
|
||||
$url = $this->buildUrl($endpoint);
|
||||
return $this->request('POST', $url, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une requête PUT
|
||||
*/
|
||||
public function put(string $endpoint, array $data = []): array
|
||||
{
|
||||
$url = $this->buildUrl($endpoint);
|
||||
return $this->request('PUT', $url, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une requête DELETE
|
||||
*/
|
||||
public function delete(string $endpoint): array
|
||||
{
|
||||
$url = $this->buildUrl($endpoint);
|
||||
return $this->request('DELETE', $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit l'URL avec d'éventuels paramètres GET
|
||||
*/
|
||||
private function buildUrl(string $endpoint, array $queryParams = []): string
|
||||
{
|
||||
$url = $this->baseUrl . '/' . ltrim($endpoint, '/');
|
||||
if (!empty($queryParams)) {
|
||||
$url .= '?' . http_build_query($queryParams);
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécute la requête HTTP cURL
|
||||
*/
|
||||
private function request(string $method, string $url, ?array $data = null): array
|
||||
{
|
||||
$ch = curl_init();
|
||||
|
||||
$headers = [
|
||||
'Accept: application/json',
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
|
||||
// Inclusion automatique du jeton si l'utilisateur est connecté en session
|
||||
$token = $_SESSION['token'] ?? $_SESSION['api_token'] ?? null;
|
||||
if (!empty($token)) {
|
||||
$headers[] = 'Authorization: Bearer ' . $token;
|
||||
$headers[] = 'X-API-KEY: ' . $token;
|
||||
}
|
||||
|
||||
$options = [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
];
|
||||
|
||||
if ($data !== null && in_array($method, ['POST', 'PUT', 'PATCH'])) {
|
||||
$options[CURLOPT_POSTFIELDS] = json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, $options);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
|
||||
// Erreur réseau de cURL
|
||||
if ($response === false || !empty($curlError)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'status' => 0,
|
||||
'data' => null,
|
||||
'error' => 'Impossible de joindre l\'API sur ' . $this->baseUrl . ' (' . ($curlError ?: 'Erreur réseau') . ')'
|
||||
];
|
||||
}
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
|
||||
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE && !empty($response)) {
|
||||
$decoded = ['raw' => $response];
|
||||
}
|
||||
|
||||
$isSuccess = ($httpCode >= 200 && $httpCode < 300);
|
||||
$errorMessage = null;
|
||||
|
||||
if (!$isSuccess) {
|
||||
$errorMessage = $decoded['error'] ?? $decoded['message'] ?? ('Erreur HTTP ' . $httpCode);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => $isSuccess,
|
||||
'status' => $httpCode,
|
||||
'data' => $decoded['data'] ?? $decoded,
|
||||
'error' => $errorMessage,
|
||||
'message' => $decoded['message'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
53
src/Views/auth/login.php
Normal file
53
src/Views/auth/login.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<div class="auth-wrapper">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<span class="auth-icon">🔐</span>
|
||||
<h1 class="auth-title">Connexion</h1>
|
||||
<p class="auth-subtitle">Accédez à la gestion de vos véhicules et carnets d'entretien</p>
|
||||
</div>
|
||||
|
||||
<form action="/login" method="POST" id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="username" class="form-label">Nom d'utilisateur</label>
|
||||
<input type="text" id="username" name="username" class="form-input" required
|
||||
placeholder="Ex: admin ou user" autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label">Mot de passe</label>
|
||||
<input type="password" id="password" name="password" class="form-input" required
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">Se connecter</button>
|
||||
</form>
|
||||
|
||||
<!-- Boutons de test rapide avec les comptes préconfigurés de l'API -->
|
||||
<div class="demo-accounts-box">
|
||||
<p class="demo-accounts-title">💡 Raccourcis de connexion rapide (1-clic) :</p>
|
||||
<div class="demo-buttons">
|
||||
<button type="button" class="btn btn-outline btn-sm" onclick="fillLoginForm('erwann', 'password')">
|
||||
👤 Erwann PHILIPPE (Admin)
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline btn-sm" onclick="fillLoginForm('admin', 'adminpassword')">
|
||||
👑 Admin démonstration
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline btn-sm" onclick="fillLoginForm('user', 'userpassword')">
|
||||
🚗 Utilisateur démonstration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Pas encore de compte ? <a href="/register">Créer un compte</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function fillLoginForm(user, pass) {
|
||||
document.getElementById('username').value = user;
|
||||
document.getElementById('password').value = pass;
|
||||
document.getElementById('loginForm').submit();
|
||||
}
|
||||
</script>
|
||||
35
src/Views/auth/register.php
Normal file
35
src/Views/auth/register.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<div class="auth-wrapper">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<span class="auth-icon">📝</span>
|
||||
<h1 class="auth-title">Inscription</h1>
|
||||
<p class="auth-subtitle">Créez votre compte pour gérer vos véhicules</p>
|
||||
</div>
|
||||
|
||||
<form action="/register" method="POST">
|
||||
<div class="form-group">
|
||||
<label for="username" class="form-label">Nom d'utilisateur <span class="required">*</span></label>
|
||||
<input type="text" id="username" name="username" class="form-input" required
|
||||
placeholder="Ex: jdupont" autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nom" class="form-label">Nom complet / Prénom</label>
|
||||
<input type="text" id="nom" name="nom" class="form-input"
|
||||
placeholder="Ex: Jean Dupont">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label">Mot de passe <span class="required">*</span></label>
|
||||
<input type="password" id="password" name="password" class="form-input" required
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">Créer mon compte</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Déjà un compte ? <a href="/login">Se connecter</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
75
src/Views/cars/form.php
Normal file
75
src/Views/cars/form.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<div class="page-header">
|
||||
<div>
|
||||
<a href="/cars" class="back-link">← Annuler et revenir aux véhicules</a>
|
||||
<h1 class="page-title"><?= htmlspecialchars($title) ?></h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-card-container">
|
||||
<div class="form-card">
|
||||
<form action="<?= htmlspecialchars($action) ?>" method="POST" id="carForm">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="marque" class="form-label">Marque <span class="required">*</span></label>
|
||||
<input type="text" id="marque" name="marque" class="form-input" required
|
||||
placeholder="Ex: Peugeot, Renault, Volkswagen..."
|
||||
value="<?= htmlspecialchars($car->marque ?? '') ?>">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="modele" class="form-label">Modèle <span class="required">*</span></label>
|
||||
<input type="text" id="modele" name="modele" class="form-input" required
|
||||
placeholder="Ex: 208 Allure, Clio V, Golf..."
|
||||
value="<?= htmlspecialchars($car->modele ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="annee" class="form-label">Année de fabrication <span class="required">*</span></label>
|
||||
<input type="number" id="annee" name="annee" class="form-input" required
|
||||
min="1950" max="<?= date('Y') + 1 ?>" placeholder="Ex: 2021"
|
||||
value="<?= htmlspecialchars($car->annee ?? '') ?>">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateAchat" class="form-label">Date d'achat <span class="required">*</span></label>
|
||||
<input type="date" id="dateAchat" name="dateAchat" class="form-input" required
|
||||
value="<?= htmlspecialchars($car->dateAchat ?? date('Y-m-d')) ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="immatriculation" class="form-label">Plaque d'immatriculation</label>
|
||||
<input type="text" id="immatriculation" name="immatriculation" class="form-input"
|
||||
placeholder="Ex: AB-123-CD ou 1234 AB 75"
|
||||
value="<?= htmlspecialchars($car->immatriculation ?? '') ?>">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="vin" class="form-label">Numéro de châssis (VIN)</label>
|
||||
<input type="text" id="vin" name="vin" class="form-input" maxlength="17"
|
||||
placeholder="Ex: VF3WA8HZCXXXXXXXX"
|
||||
value="<?= htmlspecialchars($car->vin ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!$isEdit): ?>
|
||||
<div class="form-group">
|
||||
<label for="kilometrage_initial" class="form-label">Kilométrage initial lors de l'achat (km)</label>
|
||||
<input type="number" id="kilometrage_initial" name="kilometrage_initial" class="form-input"
|
||||
placeholder="Ex: 45000">
|
||||
<small class="form-help">Optionnel : Crée automatiquement un premier relevé kilométrique à la date d'achat.</small>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="/cars" class="btn btn-outline">Annuler</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<?= $isEdit ? 'Enregistrer les modifications' : 'Créer le véhicule' ?>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
127
src/Views/cars/index.php
Normal file
127
src/Views/cars/index.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">
|
||||
<?= $isMineOnly ? 'Mes Véhicules' : 'Parc Automobile' ?>
|
||||
</h1>
|
||||
<p class="page-subtitle">
|
||||
<?= $isMineOnly ? 'Véhicules dont vous êtes le propriétaire déclaré' : 'Consultation en direct des données depuis l\'API REST' ?>
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<?php if (!empty($_SESSION['user'])): ?>
|
||||
<a href="/cars/create" class="btn btn-primary">
|
||||
<span>+</span> Ajouter un véhicule
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<a href="/login" class="btn btn-outline">
|
||||
🔐 Se connecter pour ajouter un véhicule
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($searchedPlate)): ?>
|
||||
<div class="alert alert-info">
|
||||
<span>Résultat de la recherche pour la plaque <strong><?= htmlspecialchars($searchedPlate) ?></strong></span>
|
||||
<a href="/cars" class="btn btn-sm btn-outline">Réinitialiser le filtre</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($apiError)): ?>
|
||||
<div class="alert alert-danger">
|
||||
<strong>Erreur API :</strong> <?= htmlspecialchars($apiError) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Barre de recherche et filtres instantanés côté client -->
|
||||
<div class="filter-bar">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input type="text" id="searchInput" placeholder="Filtrer en direct (marque, modèle, plaque, année...)" class="form-input">
|
||||
</div>
|
||||
|
||||
<div class="filter-tabs">
|
||||
<a href="/cars" class="filter-tab <?= !$isMineOnly ? 'active' : '' ?>">Tous les véhicules</a>
|
||||
<?php if (!empty($_SESSION['user'])): ?>
|
||||
<a href="/cars?mine=1" class="filter-tab <?= $isMineOnly ? 'active' : '' ?>">Mes véhicules</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="filter-count">
|
||||
<span id="carCounter"><?= count($cars) ?></span> véhicule(s)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des voitures sous forme de cartes -->
|
||||
<div class="cars-grid" id="carsGrid">
|
||||
<?php if (empty($cars)): ?>
|
||||
<div class="empty-state">
|
||||
<p>Aucun véhicule trouvé.</p>
|
||||
<?php if (!empty($_SESSION['user'])): ?>
|
||||
<a href="/cars/create" class="btn btn-primary">Ajouter un véhicule maintenant</a>
|
||||
<?php else: ?>
|
||||
<a href="/login" class="btn btn-primary">Se connecter pour ajouter un véhicule</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php
|
||||
$user = $_SESSION['user'] ?? null;
|
||||
foreach ($cars as $car):
|
||||
$canManage = $car->canEdit($user);
|
||||
?>
|
||||
<article class="car-card" data-search="<?= strtolower(htmlspecialchars($car->getFullName() . ' ' . $car->immatriculation . ' ' . $car->annee . ' ' . $car->getOwnerName())) ?>">
|
||||
<div class="car-card-header">
|
||||
<?php if (!empty($car->immatriculation)): ?>
|
||||
<div class="license-plate" title="Plaque d'immatriculation">
|
||||
<span class="plate-country">F</span>
|
||||
<span class="plate-number"><?= htmlspecialchars($car->immatriculation) ?></span>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="badge badge-secondary">Sans immatriculation</span>
|
||||
<?php endif; ?>
|
||||
|
||||
<span class="car-year"><?= htmlspecialchars($car->annee ?? '—') ?></span>
|
||||
</div>
|
||||
|
||||
<div class="car-card-body">
|
||||
<h2 class="car-title"><?= htmlspecialchars($car->getFullName()) ?></h2>
|
||||
|
||||
<div class="car-stats-row">
|
||||
<div class="stat-badge stat-km">
|
||||
<span class="stat-icon">🛣️</span>
|
||||
<span class="stat-text"><?= htmlspecialchars($car->getFormattedKm()) ?></span>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($car->dateAchat)): ?>
|
||||
<div class="stat-badge stat-date" title="Date d'acquisition">
|
||||
<span class="stat-icon">📅</span>
|
||||
<span class="stat-text"><?= htmlspecialchars($car->getFormattedDateAchat()) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($car->vin)): ?>
|
||||
<p class="car-attribute" title="Numéro de série VIN">
|
||||
<strong>VIN :</strong> <code><?= htmlspecialchars($car->vin) ?></code>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<p class="car-owner">
|
||||
<strong>Propriétaire :</strong> <?= htmlspecialchars($car->getOwnerName()) ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="car-card-footer">
|
||||
<a href="/cars/<?= $car->id ?>" class="btn btn-secondary btn-sm">Détails & Suivi</a>
|
||||
|
||||
<?php if ($canManage): ?>
|
||||
<a href="/cars/<?= $car->id ?>/edit" class="btn btn-outline btn-sm">Modifier</a>
|
||||
<form action="/cars/<?= $car->id ?>/delete" method="POST" class="inline-form" onsubmit="return confirmAction('Voulez-vous vraiment supprimer définitivement ce véhicule et toutes ses données associées (kilométrage, entretiens, notes) ?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Supprimer</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
286
src/Views/cars/show.php
Normal file
286
src/Views/cars/show.php
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
<?php
|
||||
$canManage = $car->canEdit($currentUser);
|
||||
?>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<a href="/cars" class="back-link">← Retour à la liste des véhicules</a>
|
||||
<h1 class="page-title"><?= htmlspecialchars($car->getFullName()) ?></h1>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<?php if ($canManage): ?>
|
||||
<a href="/cars/<?= $car->id ?>/edit" class="btn btn-primary">Modifier le véhicule</a>
|
||||
<form action="/cars/<?= $car->id ?>/delete" method="POST" class="inline-form" onsubmit="return confirmAction('Êtes-vous certain de vouloir supprimer cette voiture et l\'ensemble de son historique ?');">
|
||||
<button type="submit" class="btn btn-danger">Supprimer</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fiche d'identité du véhicule -->
|
||||
<div class="detail-card mb-4">
|
||||
<div class="detail-hero-info">
|
||||
<div>
|
||||
<?php if (!empty($car->immatriculation)): ?>
|
||||
<div class="license-plate license-plate-lg">
|
||||
<span class="plate-country">F</span>
|
||||
<span class="plate-number"><?= htmlspecialchars($car->immatriculation) ?></span>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="badge badge-secondary">Non immatriculé</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="stat-badge stat-km-lg">
|
||||
<span class="stat-icon">🛣️</span>
|
||||
<span><?= htmlspecialchars($car->getFormattedKm()) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Identifiant API</span>
|
||||
<span class="detail-value">#<?= htmlspecialchars($car->id) ?></span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Marque & Modèle</span>
|
||||
<span class="detail-value"><?= htmlspecialchars($car->getFullName()) ?></span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Année de mise en circulation</span>
|
||||
<span class="detail-value"><?= htmlspecialchars($car->annee ?? '—') ?></span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Date d'achat</span>
|
||||
<span class="detail-value"><?= htmlspecialchars($car->getFormattedDateAchat()) ?></span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Numéro de châssis (VIN)</span>
|
||||
<span class="detail-value"><code><?= htmlspecialchars($car->vin ?? 'Non renseigné') ?></code></span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Propriétaire déclaré</span>
|
||||
<span class="detail-value"><?= htmlspecialchars($car->getOwnerName()) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation par onglets -->
|
||||
<div class="tab-navigation">
|
||||
<button type="button" class="tab-btn active" onclick="switchTab('km-tab')">🛣️ Kilométrage (<?= count($kilometrages) ?>)</button>
|
||||
<button type="button" class="tab-btn" onclick="switchTab('maint-tab')">🔧 Entretiens (<?= count($maintenances) ?>)</button>
|
||||
<button type="button" class="tab-btn" onclick="switchTab('notes-tab')">📝 Notes confidentielles (<?= count($notes) ?>)</button>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 1 : RELEVÉS KILOMÉTRIQUES -->
|
||||
<div id="km-tab" class="tab-content active">
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<h3>Historique des relevés kilométriques</h3>
|
||||
<?php if ($canManage): ?>
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="toggleElement('formAddKm')">+ Nouveau relevé</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($canManage): ?>
|
||||
<div id="formAddKm" class="collapsible-form hidden">
|
||||
<form action="/cars/<?= $car->id ?>/kilometrage" method="POST" class="inline-add-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Date du relevé</label>
|
||||
<input type="date" name="date_releve" class="form-input" value="<?= date('Y-m-d') ?>" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Kilométrage (km)</label>
|
||||
<input type="number" name="valeur" class="form-input" placeholder="Ex: 145000" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer le relevé</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (empty($kilometrages)): ?>
|
||||
<p class="text-muted">Aucun relevé kilométrique enregistré pour ce véhicule.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Kilométrage</th>
|
||||
<?php if ($canManage): ?>
|
||||
<th class="text-right">Action</th>
|
||||
<?php endif; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($kilometrages as $km): ?>
|
||||
<tr>
|
||||
<td><?= date('d/m/Y', strtotime($km['date_releve'])) ?></td>
|
||||
<td><strong><?= number_format($km['valeur'], 0, ',', ' ') ?> km</strong></td>
|
||||
<?php if ($canManage): ?>
|
||||
<td class="text-right">
|
||||
<form action="/cars/<?= $car->id ?>/kilometrage/<?= $km['id'] ?>/delete" method="POST" class="inline-form" onsubmit="return confirmAction();">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Supprimer</button>
|
||||
</form>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 2 : ENTRETIENS ET MAINTENANCE -->
|
||||
<div id="maint-tab" class="tab-content">
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<h3>Historique de maintenance et réparations</h3>
|
||||
<?php if ($canManage): ?>
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="toggleElement('formAddMaint')">+ Ajouter un entretien</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($canManage): ?>
|
||||
<div id="formAddMaint" class="collapsible-form hidden">
|
||||
<form action="/cars/<?= $car->id ?>/maintenance" method="POST">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Type d'entretien <span class="required">*</span></label>
|
||||
<input type="text" name="type_entretien" class="form-input" placeholder="Ex: Vidange, Pneus, Révision..." required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Date <span class="required">*</span></label>
|
||||
<input type="date" name="date_evenement" class="form-input" value="<?= date('Y-m-d') ?>" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Kilométrage lors de l'opération (km) <span class="required">*</span></label>
|
||||
<input type="number" name="kilometrage" class="form-input" placeholder="Ex: 135000" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Prix (€)</label>
|
||||
<input type="number" step="0.01" name="prix" class="form-input" placeholder="Ex: 149.90">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description / Détails de la facture</label>
|
||||
<textarea name="description" rows="2" class="form-input" placeholder="Ex: Remplacement filtre à huile, filtre à air et huile 5W30..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer l'opération</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (empty($maintenances)): ?>
|
||||
<p class="text-muted">Aucun entretien enregistré pour ce véhicule.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Opération</th>
|
||||
<th>Kilométrage</th>
|
||||
<th>Prix</th>
|
||||
<th>Description</th>
|
||||
<?php if ($canManage): ?>
|
||||
<th class="text-right">Action</th>
|
||||
<?php endif; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($maintenances as $m): ?>
|
||||
<tr>
|
||||
<td><?= date('d/m/Y', strtotime($m['date_evenement'])) ?></td>
|
||||
<td><strong><?= htmlspecialchars($m['type_entretien']) ?></strong></td>
|
||||
<td><?= number_format($m['kilometrage'], 0, ',', ' ') ?> km</td>
|
||||
<td><?= !empty($m['prix']) ? number_format((float)$m['prix'], 2, ',', ' ') . ' €' : '—' ?></td>
|
||||
<td><?= htmlspecialchars($m['description'] ?? '—') ?></td>
|
||||
<?php if ($canManage): ?>
|
||||
<td class="text-right">
|
||||
<form action="/cars/<?= $car->id ?>/maintenance/<?= $m['id'] ?>/delete" method="POST" class="inline-form" onsubmit="return confirmAction();">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Supprimer</button>
|
||||
</form>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 3 : NOTES CONFIDENTIELLES -->
|
||||
<div id="notes-tab" class="tab-content">
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<h3>Notes de suivi confidentielles</h3>
|
||||
<?php if ($canManage): ?>
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="toggleElement('formAddNote')">+ Ajouter une note</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!$canManage): ?>
|
||||
<div class="alert alert-info">
|
||||
🔒 Les notes de suivi internes sont confidentielles et accessibles uniquement par le propriétaire déclaré ou un administrateur connecté.
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div id="formAddNote" class="collapsible-form hidden">
|
||||
<form action="/cars/<?= $car->id ?>/notes" method="POST">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Titre de la note</label>
|
||||
<input type="text" name="titre" class="form-input" placeholder="Ex: Prochain contrôle technique, Devis garage...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Contenu <span class="required">*</span></label>
|
||||
<textarea name="contenu" rows="3" class="form-input" required placeholder="Saisissez vos observations confidentielles ici..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer la note</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?php if (empty($notes)): ?>
|
||||
<p class="text-muted">Aucune note confidentielle pour le moment.</p>
|
||||
<?php else: ?>
|
||||
<div class="notes-grid">
|
||||
<?php foreach ($notes as $note): ?>
|
||||
<div class="note-card">
|
||||
<div class="note-header">
|
||||
<h4><?= htmlspecialchars($note['titre'] ?: 'Note sans titre') ?></h4>
|
||||
<span class="note-date"><?= date('d/m/Y H:i', strtotime($note['date_creation'])) ?></span>
|
||||
</div>
|
||||
<p class="note-content"><?= nl2br(htmlspecialchars($note['contenu'])) ?></p>
|
||||
<div class="note-footer">
|
||||
<form action="/cars/<?= $car->id ?>/notes/<?= $note['id'] ?>/delete" method="POST" class="inline-form" onsubmit="return confirmAction();">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Supprimer la note</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTab(tabId) {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
const activeBtn = Array.from(document.querySelectorAll('.tab-btn')).find(b => b.getAttribute('onclick').includes(tabId));
|
||||
if (activeBtn) activeBtn.classList.add('active');
|
||||
|
||||
const content = document.getElementById(tabId);
|
||||
if (content) content.classList.add('active');
|
||||
}
|
||||
|
||||
function toggleElement(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
6
src/Views/errors/404.php
Normal file
6
src/Views/errors/404.php
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<div class="empty-state error-page">
|
||||
<h1 class="error-code">404</h1>
|
||||
<h2>Oups ! Page introuvable</h2>
|
||||
<p>La ressource ou l'URL que vous recherchez n'existe pas ou a été déplacée.</p>
|
||||
<a href="/" class="btn btn-primary">Retour à l'accueil</a>
|
||||
</div>
|
||||
13
src/Views/layout/footer.php
Normal file
13
src/Views/layout/footer.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container footer-content">
|
||||
<p>© <?= date('Y') ?> <strong><?= APP_NAME ?></strong> - Architecture MVC PHP connectée à une API REST</p>
|
||||
<p class="footer-meta">API Endpoint : <code><?= htmlspecialchars(API_BASE_URL) ?></code></p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Script JavaScript principal -->
|
||||
<script src="/assets/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
60
src/Views/layout/header.php
Normal file
60
src/Views/layout/header.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
$currentUser = $_SESSION['user'] ?? null;
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($title ?? APP_NAME) ?></title>
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<!-- Feuille de style principale -->
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="navbar">
|
||||
<div class="container navbar-container">
|
||||
<a href="/" class="brand-logo">
|
||||
<span class="logo-icon">🚗</span>
|
||||
<span class="logo-text"><?= APP_NAME ?></span>
|
||||
</a>
|
||||
|
||||
<!-- Formulaire de recherche par plaque rapide -->
|
||||
<form action="/cars" method="GET" class="navbar-search">
|
||||
<input type="text" name="immatriculation" placeholder="Plaque : AB-123-CD..."
|
||||
value="<?= htmlspecialchars($_GET['immatriculation'] ?? '') ?>" class="navbar-search-input">
|
||||
<button type="submit" class="navbar-search-btn">🔍</button>
|
||||
</form>
|
||||
|
||||
<nav class="nav-links">
|
||||
<a href="/cars" class="nav-link">Tous les véhicules</a>
|
||||
|
||||
<?php if ($currentUser): ?>
|
||||
<a href="/cars?mine=1" class="nav-link">Mes véhicules</a>
|
||||
<a href="/cars/create" class="btn btn-primary btn-sm">+ Nouveau véhicule</a>
|
||||
|
||||
<div class="user-pill">
|
||||
<span class="user-role-badge badge-<?= htmlspecialchars(strtolower($currentUser['role'] ?? 'user')) ?>">
|
||||
<?= htmlspecialchars(ucfirst($currentUser['role'] ?? 'User')) ?>
|
||||
</span>
|
||||
<span class="user-name"><?= htmlspecialchars($currentUser['username']) ?></span>
|
||||
<a href="/logout" class="nav-link-logout" title="Déconnexion">🚪</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<a href="/login" class="btn btn-outline btn-sm">Se connecter</a>
|
||||
<a href="/register" class="btn btn-primary btn-sm">S'inscrire</a>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container main-content">
|
||||
<?php if (!empty($flashMessage)): ?>
|
||||
<div class="alert alert-<?= htmlspecialchars($flashMessage['type']) ?> alert-dismissible" id="flash-alert">
|
||||
<span><?= htmlspecialchars($flashMessage['message']) ?></span>
|
||||
<button type="button" class="alert-close" onclick="this.parentElement.remove()">✕</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
Loading…
Reference in a new issue