diff --git a/README.md b/README.md
index 909e672..0efae05 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
🦙 HouseHub OS
-Système de gestion familiale sur-mesure : Budget, Calendrier, Voyages et Cadeaux.
+Système de gestion familiale sur-mesure : Budget, Calendrier, Voyages, Cadeaux et Garage.
## 🚀 Déploiement (Docker)
@@ -32,8 +32,9 @@ HouseHub supporte plusieurs familles indépendantes sur la même instance :
|-----|-------------|
| `/register.php` | Créer un compte + nouvel espace, ou rejoindre un espace existant via code |
| `/login.php` | Connexion |
-| `/settings.php` | Paramètres du compte : profil, mot de passe, code d'invitation, membres |
+| `/settings.php` | Paramètres : profil, mot de passe, langue, modules actifs, code d'invitation |
| `/admin/` | Panneau d'administration (admin uniquement) |
+| `/garage.php` | Module Garage — véhicules, entretiens, pièces |
### Inviter quelqu'un dans son espace
1. Aller sur `/settings.php` → copier le code d'invitation
@@ -47,6 +48,18 @@ UPDATE househub_meta.users SET is_admin = 1 WHERE username = 'ton_username';
```
L'admin peut ensuite promouvoir/désactiver d'autres utilisateurs depuis `/admin/`.
+## 🧩 Modules
+
+| Module | Route | Description |
+|--------|-------|-------------|
+| Calendrier | `/family-calendar.php` | Congés, modes de garde, planning hebdo |
+| Budget | `/budget.php` | Suivi mensuel, prévisionnel, épargne |
+| Voyages | `/holidays.php` | Roadtrips, cartographie, météo |
+| Cadeaux | `/gift-list.php` | Noël, anniversaires, Tricount |
+| Garage | `/garage.php` | Véhicules, entretiens, pièces détachées |
+
+Les modules sont activables/désactivables par famille depuis `/settings.php`.
+
---
📝 Présentation du projet
diff --git a/docker-compose.yml b/docker-compose.yml
index 46c815f..a202b16 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,6 +12,8 @@ services:
DB_NAME: ${DB_NAME:-househub}
DB_USER: ${DB_USER:-househub}
DB_PASS: ${DB_PASS:-changeme}
+ volumes:
+ - househub_uploads:/uploads
networks:
- househub_net
- proxy
@@ -34,6 +36,7 @@ services:
MYSQL_PASSWORD: ${DB_PASS:-changeme}
volumes:
- househub_db_data:/var/lib/mysql
+ - househub_uploads:/uploads
- ./docker/init:/docker-entrypoint-initdb.d:ro
networks:
- househub_net
@@ -45,6 +48,7 @@ services:
volumes:
househub_db_data:
+ househub_uploads:
networks:
househub_net:
diff --git a/docker/schema_family.sql b/docker/schema_family.sql
index 0bb5b8a..0a7ce87 100644
--- a/docker/schema_family.sql
+++ b/docker/schema_family.sql
@@ -235,3 +235,61 @@ VALUES (1, 'admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/ig
-- Personnes (IDs fixes correspondant aux constantes dans config.php)
INSERT IGNORE INTO pf_people (id, name) VALUES (2, 'Alex');
INSERT IGNORE INTO pf_people (id, name) VALUES (3, 'Laia');
+
+-- ─── Garage Manager ───────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS pf_vehicles (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ brand VARCHAR(100) NOT NULL,
+ model VARCHAR(100) NOT NULL,
+ year INT DEFAULT NULL,
+ license_plate VARCHAR(50) DEFAULT NULL,
+ vin VARCHAR(100) DEFAULT NULL,
+ fuel_type VARCHAR(50) DEFAULT 'Essence',
+ color VARCHAR(50) DEFAULT NULL,
+ purchase_date DATE DEFAULT NULL,
+ purchase_price DECIMAL(10,2) DEFAULT NULL,
+ current_km INT DEFAULT 0,
+ photo VARCHAR(255) DEFAULT NULL,
+ notes TEXT DEFAULT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS pf_maintenances (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ vehicle_id INT NOT NULL,
+ type VARCHAR(100) NOT NULL,
+ description TEXT DEFAULT NULL,
+ date DATE NOT NULL,
+ km INT DEFAULT NULL,
+ cost DECIMAL(10,2) DEFAULT 0,
+ mechanic VARCHAR(100) DEFAULT NULL,
+ garage_name VARCHAR(100) DEFAULT NULL,
+ next_km INT DEFAULT NULL,
+ next_date DATE DEFAULT NULL,
+ invoice_photo VARCHAR(255) DEFAULT NULL,
+ notes TEXT DEFAULT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS pf_parts (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ vehicle_id INT DEFAULT NULL,
+ maintenance_id INT DEFAULT NULL,
+ brand VARCHAR(100) DEFAULT NULL,
+ reference VARCHAR(100) DEFAULT NULL,
+ name VARCHAR(255) NOT NULL,
+ category VARCHAR(100) DEFAULT 'Autre',
+ price DECIMAL(10,2) DEFAULT 0,
+ quantity INT DEFAULT 1,
+ unit VARCHAR(50) DEFAULT 'pièce',
+ supplier VARCHAR(100) DEFAULT NULL,
+ purchase_date DATE DEFAULT NULL,
+ photo VARCHAR(255) DEFAULT NULL,
+ notes TEXT DEFAULT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE SET NULL,
+ FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/garage.php b/garage.php
new file mode 100644
index 0000000..e57e8f8
--- /dev/null
+++ b/garage.php
@@ -0,0 +1,257 @@
+
+
+
+
+
+
+
+
+
+
= tr('garage_stat_vehicles') ?>
--
+
= tr('garage_stat_maintenances') ?>
--
+
= tr('garage_stat_parts') ?>
--
+
= tr('garage_stat_cost') ?>
--
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/header.php b/header.php
index cb356fc..1028c47 100644
--- a/header.php
+++ b/header.php
@@ -50,6 +50,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
= tr('menu_budget') ?>
= tr('menu_holidays') ?>
= tr('menu_gifts') ?>
+ = tr('menu_garage') ?>
@@ -90,6 +91,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
💰 = tr('menu_budget') ?>
🏖️ = tr('menu_holidays') ?>
🎁 = tr('menu_gifts') ?>
+ 🚗 = tr('menu_garage') ?>
⚙️ Paramètres
🛡️ Admin
diff --git a/includes/lang/ca.php b/includes/lang/ca.php
index a37773c..e36da2f 100644
--- a/includes/lang/ca.php
+++ b/includes/lang/ca.php
@@ -544,5 +544,56 @@ return [
'gift_filter_all_adults' => 'Tots els adults',
'gift_empty_state_no_gifts' => 'Cap regal de moment.',
'gift_empty_state_no_filter' => 'Cap regal correspon al filtre.',
- 'gift_view_matrix' => 'Veure la matriu detallada',
-];
\ No newline at end of file
+ // ==========================================
+ // GARAGE MANAGER
+ // ==========================================
+ 'menu_garage' => 'Garatge',
+ 'garage_page_title' => 'HouseHub - Garatge',
+ 'garage_stat_vehicles' => 'Vehicles',
+ 'garage_stat_maintenances' => 'Manteniments',
+ 'garage_stat_parts' => 'Peces',
+ 'garage_stat_cost' => 'Cost total',
+ 'garage_my_vehicles' => 'Els meus vehicles',
+ 'garage_reminders' => 'Recordatoris i propers manteniments',
+ 'garage_vehicles_title' => 'Els meus vehicles',
+ 'garage_all_parts' => 'Totes les peces',
+ 'garage_parts_count' => 'peces',
+ 'garage_maintenances' => 'Manteniments',
+ 'garage_parts' => 'Peces',
+ 'garage_add' => 'Afegir',
+ 'garage_add_vehicle' => 'Afegir un vehicle',
+ 'garage_add_maintenance' => 'Afegir un manteniment',
+ 'garage_add_part' => 'Afegir una peça',
+ 'garage_vehicle_name' => 'Nom del vehicle',
+ 'garage_brand' => 'Marca',
+ 'garage_model' => 'Model',
+ 'garage_year' => 'Any',
+ 'garage_plate' => 'Matrícula',
+ 'garage_fuel' => 'Combustible',
+ 'garage_km' => 'Quilometratge actual',
+ 'garage_color' => 'Color',
+ 'garage_purchase_date' => 'Data de compra',
+ 'garage_purchase_price' => 'Preu de compra (€)',
+ 'garage_photo' => 'Foto',
+ 'garage_notes' => 'Notes',
+ 'garage_maint_type' => 'Tipus de manteniment',
+ 'garage_date' => 'Data',
+ 'garage_km_at' => 'Quilometratge en el moment',
+ 'garage_labor_cost' => 'Cost mà d\'obra (€)',
+ 'garage_description' => 'Descripció',
+ 'garage_mechanic' => 'Mecànic',
+ 'garage_garage' => 'Taller',
+ 'garage_next_reminder' => 'Proper manteniment (recordatori)',
+ 'garage_next_date' => 'Data prevista',
+ 'garage_next_km' => 'Quilometratge previst',
+ 'garage_part_name' => 'Nom de la peça',
+ 'garage_category' => 'Categoria',
+ 'garage_reference' => 'Referència',
+ 'garage_unit_price' => 'Preu unitari (€)',
+ 'garage_quantity' => 'Quantitat',
+ 'garage_unit' => 'Unitat',
+ 'garage_supplier' => 'Proveïdor',
+ 'garage_change_photo' => 'Canviar la foto',
+ 'garage_new_photo' => 'Nova foto',
+ 'garage_update_photo' => 'Actualitzar',
+];
diff --git a/includes/lang/en.php b/includes/lang/en.php
index 98c0e4a..f1268cc 100644
--- a/includes/lang/en.php
+++ b/includes/lang/en.php
@@ -529,4 +529,57 @@ return [
'gift_empty_state_no_gifts' => 'No gifts yet.',
'gift_empty_state_no_filter' => 'No gifts match the filter.',
'gift_view_matrix' => 'View detailed matrix',
+
+ // ==========================================
+ // GARAGE MANAGER
+ // ==========================================
+ 'menu_garage' => 'Garage',
+ 'garage_page_title' => 'HouseHub - Garage',
+ 'garage_stat_vehicles' => 'Vehicles',
+ 'garage_stat_maintenances' => 'Services',
+ 'garage_stat_parts' => 'Parts',
+ 'garage_stat_cost' => 'Total cost',
+ 'garage_my_vehicles' => 'My vehicles',
+ 'garage_reminders' => 'Reminders & upcoming services',
+ 'garage_vehicles_title' => 'My vehicles',
+ 'garage_all_parts' => 'All parts',
+ 'garage_parts_count' => 'parts',
+ 'garage_maintenances' => 'Services',
+ 'garage_parts' => 'Parts',
+ 'garage_add' => 'Add',
+ 'garage_add_vehicle' => 'Add a vehicle',
+ 'garage_add_maintenance' => 'Add a service',
+ 'garage_add_part' => 'Add a part',
+ 'garage_vehicle_name' => 'Vehicle name',
+ 'garage_brand' => 'Make',
+ 'garage_model' => 'Model',
+ 'garage_year' => 'Year',
+ 'garage_plate' => 'Licence plate',
+ 'garage_fuel' => 'Fuel type',
+ 'garage_km' => 'Current mileage',
+ 'garage_color' => 'Colour',
+ 'garage_purchase_date' => 'Purchase date',
+ 'garage_purchase_price' => 'Purchase price (€)',
+ 'garage_photo' => 'Photo',
+ 'garage_notes' => 'Notes',
+ 'garage_maint_type' => 'Service type',
+ 'garage_date' => 'Date',
+ 'garage_km_at' => 'Mileage at the time',
+ 'garage_labor_cost' => 'Labour cost (€)',
+ 'garage_description' => 'Description',
+ 'garage_mechanic' => 'Mechanic',
+ 'garage_garage' => 'Garage / Workshop',
+ 'garage_next_reminder' => 'Next service (reminder)',
+ 'garage_next_date' => 'Planned date',
+ 'garage_next_km' => 'Planned mileage',
+ 'garage_part_name' => 'Part name',
+ 'garage_category' => 'Category',
+ 'garage_reference' => 'Reference',
+ 'garage_unit_price' => 'Unit price (€)',
+ 'garage_quantity' => 'Quantity',
+ 'garage_unit' => 'Unit',
+ 'garage_supplier' => 'Supplier',
+ 'garage_change_photo' => 'Change photo',
+ 'garage_new_photo' => 'New photo',
+ 'garage_update_photo' => 'Update',
];
diff --git a/includes/lang/fr.php b/includes/lang/fr.php
index 7de7cc3..ee883ac 100644
--- a/includes/lang/fr.php
+++ b/includes/lang/fr.php
@@ -547,4 +547,56 @@ return [
'gift_empty_state_no_gifts' => 'Aucun cadeau pour le moment.',
'gift_empty_state_no_filter' => 'Aucun cadeau ne correspond au filtre.',
'gift_view_matrix' => 'Voir la matrice détaillée',
-];
\ No newline at end of file
+ // ==========================================
+ // GARAGE MANAGER
+ // ==========================================
+ 'menu_garage' => 'Garage',
+ 'garage_page_title' => 'HouseHub - Garage',
+ 'garage_stat_vehicles' => 'Véhicules',
+ 'garage_stat_maintenances' => 'Entretiens',
+ 'garage_stat_parts' => 'Pièces',
+ 'garage_stat_cost' => 'Coût total',
+ 'garage_my_vehicles' => 'Mes véhicules',
+ 'garage_reminders' => 'Rappels & prochains entretiens',
+ 'garage_vehicles_title' => 'Mes véhicules',
+ 'garage_all_parts' => 'Toutes les pièces',
+ 'garage_parts_count' => 'pièces',
+ 'garage_maintenances' => 'Entretiens',
+ 'garage_parts' => 'Pièces',
+ 'garage_add' => 'Ajouter',
+ 'garage_add_vehicle' => 'Ajouter un véhicule',
+ 'garage_add_maintenance' => 'Ajouter un entretien',
+ 'garage_add_part' => 'Ajouter une pièce',
+ 'garage_vehicle_name' => 'Nom du véhicule',
+ 'garage_brand' => 'Marque',
+ 'garage_model' => 'Modèle',
+ 'garage_year' => 'Année',
+ 'garage_plate' => 'Immatriculation',
+ 'garage_fuel' => 'Carburant',
+ 'garage_km' => 'Kilométrage actuel',
+ 'garage_color' => 'Couleur',
+ 'garage_purchase_date' => 'Date d\'achat',
+ 'garage_purchase_price' => 'Prix d\'achat (€)',
+ 'garage_photo' => 'Photo',
+ 'garage_notes' => 'Notes',
+ 'garage_maint_type' => 'Type d\'entretien',
+ 'garage_date' => 'Date',
+ 'garage_km_at' => 'Kilométrage au moment',
+ 'garage_labor_cost' => 'Coût main d\'œuvre (€)',
+ 'garage_description' => 'Description',
+ 'garage_mechanic' => 'Mécanicien',
+ 'garage_garage' => 'Garage / Atelier',
+ 'garage_next_reminder' => 'Prochain entretien (rappel)',
+ 'garage_next_date' => 'Date prévue',
+ 'garage_next_km' => 'Kilométrage prévu',
+ 'garage_part_name' => 'Nom de la pièce',
+ 'garage_category' => 'Catégorie',
+ 'garage_reference' => 'Référence',
+ 'garage_unit_price' => 'Prix unitaire (€)',
+ 'garage_quantity' => 'Quantité',
+ 'garage_unit' => 'Unité',
+ 'garage_supplier' => 'Fournisseur',
+ 'garage_change_photo' => 'Changer la photo',
+ 'garage_new_photo' => 'Nouvelle photo',
+ 'garage_update_photo' => 'Mettre à jour',
+];
diff --git a/modules/garage/api.php b/modules/garage/api.php
new file mode 100644
index 0000000..1a4c72c
--- /dev/null
+++ b/modules/garage/api.php
@@ -0,0 +1,161 @@
+ true, 'data' => $d]); exit; }
+function gErr($m, $c = 400) { http_response_code($c); echo json_encode(['ok' => false, 'error' => $m]); exit; }
+function gBody() { return json_decode(file_get_contents('php://input'), true) ?? []; }
+
+function handleUpload(string $field, string $dir): ?string {
+ if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== 0) return null;
+ $ext = strtolower(pathinfo($_FILES[$field]['name'], PATHINFO_EXTENSION));
+ if (!in_array($ext, ['jpg','jpeg','png','gif','webp'])) return null;
+ $fname = uniqid('g', true) . '.' . $ext;
+ if (move_uploaded_file($_FILES[$field]['tmp_name'], $dir . $fname)) return $fname;
+ return null;
+}
+
+// ─── Vehicles ─────────────────────────────────────────────────────────────────
+if ($action === 'vehicles') {
+ if ($method === 'GET') {
+ $id = $_GET['id'] ?? null;
+ if ($id) {
+ $v = $pdo->prepare("SELECT * FROM pf_vehicles WHERE id = ?"); $v->execute([$id]);
+ $vehicle = $v->fetch(); if (!$vehicle) gErr('Véhicule introuvable', 404);
+ $s = $pdo->prepare("SELECT COUNT(*) as cnt, COALESCE(SUM(cost),0) as total FROM pf_maintenances WHERE vehicle_id = ?"); $s->execute([$id]); $vehicle['stats'] = $s->fetch();
+ $p = $pdo->prepare("SELECT COUNT(*) as cnt, COALESCE(SUM(price*quantity),0) as total FROM pf_parts WHERE vehicle_id = ?"); $p->execute([$id]); $vehicle['parts_stats'] = $p->fetch();
+ gOk($vehicle);
+ }
+ $stmt = $pdo->query("SELECT v.*, (SELECT COUNT(*) FROM pf_maintenances m WHERE m.vehicle_id = v.id) as maintenance_count, (SELECT COALESCE(SUM(cost),0) FROM pf_maintenances m WHERE m.vehicle_id = v.id) as total_cost, (SELECT date FROM pf_maintenances m WHERE m.vehicle_id = v.id ORDER BY date DESC LIMIT 1) as last_maintenance FROM pf_vehicles v ORDER BY v.created_at DESC");
+ gOk($stmt->fetchAll());
+ }
+ if ($method === 'POST') {
+ $photo = handleUpload('photo', $UPLOAD_DIR); $d = $_POST ?: gBody();
+ $stmt = $pdo->prepare("INSERT INTO pf_vehicles (name,brand,model,year,license_plate,vin,fuel_type,color,purchase_date,purchase_price,current_km,photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)");
+ $stmt->execute([$d['name']??'', $d['brand']??'', $d['model']??'', $d['year']??null, $d['license_plate']??null, $d['vin']??null, $d['fuel_type']??'Essence', $d['color']??null, $d['purchase_date']??null, $d['purchase_price']??null, $d['current_km']??0, $photo, $d['notes']??null]);
+ gOk(['id' => $pdo->lastInsertId()]);
+ }
+ if ($method === 'PUT') {
+ $id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant'); $d = gBody();
+ $fields = ['name','brand','model','year','license_plate','vin','fuel_type','color','purchase_date','purchase_price','current_km','notes'];
+ $sets = array_map(fn($f) => "$f = ?", $fields); $vals = array_map(fn($f) => $d[$f] ?? null, $fields); $vals[] = $id;
+ $pdo->prepare("UPDATE pf_vehicles SET " . implode(', ', $sets) . ", updated_at = NOW() WHERE id = ?")->execute($vals);
+ gOk(['updated' => true]);
+ }
+ if ($method === 'DELETE') {
+ $id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
+ $v = $pdo->prepare("SELECT photo FROM pf_vehicles WHERE id=?"); $v->execute([$id]); $row = $v->fetch(); if ($row['photo']) @unlink($UPLOAD_DIR . $row['photo']);
+ $pdo->prepare("DELETE FROM pf_vehicles WHERE id = ?")->execute([$id]); gOk(['deleted' => true]);
+ }
+}
+
+// ─── Maintenances ─────────────────────────────────────────────────────────────
+if ($action === 'maintenances') {
+ $vid = $_GET['vehicle_id'] ?? null;
+ if ($method === 'GET') {
+ if ($vid) {
+ $stmt = $pdo->prepare("SELECT m.*, GROUP_CONCAT(p.name SEPARATOR ', ') as parts_names, COUNT(p.id) as parts_count, COALESCE(SUM(p.price*p.quantity),0) as parts_cost FROM pf_maintenances m LEFT JOIN pf_parts p ON p.maintenance_id = m.id WHERE m.vehicle_id = ? GROUP BY m.id ORDER BY m.date DESC, m.created_at DESC");
+ $stmt->execute([$vid]); gOk($stmt->fetchAll());
+ }
+ $stmt = $pdo->query("SELECT m.*, v.name as vehicle_name, v.license_plate, v.current_km FROM pf_maintenances m JOIN pf_vehicles v ON v.id = m.vehicle_id WHERE m.next_date IS NOT NULL OR m.next_km IS NOT NULL ORDER BY m.next_date ASC");
+ gOk($stmt->fetchAll());
+ }
+ if ($method === 'POST') {
+ $photo = handleUpload('invoice_photo', $UPLOAD_DIR); $d = $_POST ?: gBody();
+ if (!($d['vehicle_id'] ?? null)) gErr('vehicle_id manquant');
+ $stmt = $pdo->prepare("INSERT INTO pf_maintenances (vehicle_id,type,description,date,km,cost,mechanic,garage_name,next_km,next_date,invoice_photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
+ $stmt->execute([$d['vehicle_id'], $d['type']??'', $d['description']??null, $d['date']??date('Y-m-d'), $d['km']??null, $d['cost']??0, $d['mechanic']??null, $d['garage']??null, $d['next_km']??null, $d['next_date']??null, $photo, $d['notes']??null]);
+ $mid = $pdo->lastInsertId();
+ if (!empty($d['km'])) { $pdo->prepare("UPDATE pf_vehicles SET current_km = GREATEST(current_km, ?), updated_at = NOW() WHERE id = ?")->execute([$d['km'], $d['vehicle_id']]); }
+ gOk(['id' => $mid]);
+ }
+ if ($method === 'PUT') {
+ $id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant'); $d = gBody();
+ $fields = ['type','description','date','km','cost','mechanic','garage_name','next_km','next_date','notes'];
+ $sets = array_map(fn($f) => "$f = ?", $fields); $vals = array_map(fn($f) => $d[$f] ?? null, $fields); $vals[] = $id;
+ $pdo->prepare("UPDATE pf_maintenances SET " . implode(', ', $sets) . " WHERE id = ?")->execute($vals); gOk(['updated' => true]);
+ }
+ if ($method === 'DELETE') {
+ $id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
+ $pdo->prepare("DELETE FROM pf_maintenances WHERE id = ?")->execute([$id]); gOk(['deleted' => true]);
+ }
+}
+
+// ─── Parts ────────────────────────────────────────────────────────────────────
+if ($action === 'parts') {
+ if ($method === 'GET') {
+ $vid = $_GET['vehicle_id'] ?? null; $mid = $_GET['maintenance_id'] ?? null;
+ if ($mid) { $stmt = $pdo->prepare("SELECT * FROM pf_parts WHERE maintenance_id = ? ORDER BY created_at DESC"); $stmt->execute([$mid]); gOk($stmt->fetchAll()); }
+ if ($vid) { $stmt = $pdo->prepare("SELECT p.*, m.type as maintenance_type, m.date as maintenance_date FROM pf_parts p LEFT JOIN pf_maintenances m ON m.id = p.maintenance_id WHERE p.vehicle_id = ? ORDER BY p.created_at DESC"); $stmt->execute([$vid]); gOk($stmt->fetchAll()); }
+ $stmt = $pdo->query("SELECT p.*, v.name as vehicle_name, m.type as maintenance_type, m.date as maintenance_date FROM pf_parts p LEFT JOIN pf_vehicles v ON v.id = p.vehicle_id LEFT JOIN pf_maintenances m ON m.id = p.maintenance_id ORDER BY p.created_at DESC");
+ gOk($stmt->fetchAll());
+ }
+ if ($method === 'POST') {
+ $photo = handleUpload('photo', $UPLOAD_DIR); $d = $_POST ?: gBody();
+ $stmt = $pdo->prepare("INSERT INTO pf_parts (vehicle_id,maintenance_id,brand,reference,name,category,price,quantity,unit,supplier,purchase_date,photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)");
+ $stmt->execute([$d['vehicle_id']??null, $d['maintenance_id']??null, $d['brand']??null, $d['reference']??null, $d['name']??'', $d['category']??'Autre', $d['price']??0, $d['quantity']??1, $d['unit']??'pièce', $d['supplier']??null, $d['purchase_date']??null, $photo, $d['notes']??null]);
+ gOk(['id' => $pdo->lastInsertId()]);
+ }
+ if ($method === 'PUT') {
+ $id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant'); $d = gBody();
+ $fields = ['brand','reference','name','category','price','quantity','unit','supplier','purchase_date','notes'];
+ $sets = array_map(fn($f) => "$f = ?", $fields); $vals = array_map(fn($f) => $d[$f] ?? null, $fields); $vals[] = $id;
+ $pdo->prepare("UPDATE pf_parts SET " . implode(', ', $sets) . " WHERE id = ?")->execute($vals); gOk(['updated' => true]);
+ }
+ if ($method === 'DELETE') {
+ $id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
+ $r = $pdo->prepare("SELECT photo FROM pf_parts WHERE id=?"); $r->execute([$id]); $row = $r->fetch(); if ($row['photo']) @unlink($UPLOAD_DIR . $row['photo']);
+ $pdo->prepare("DELETE FROM pf_parts WHERE id = ?")->execute([$id]); gOk(['deleted' => true]);
+ }
+}
+
+// ─── Stats ────────────────────────────────────────────────────────────────────
+if ($action === 'stats') {
+ gOk([
+ 'vehicles' => $pdo->query("SELECT COUNT(*) FROM pf_vehicles")->fetchColumn(),
+ 'maintenances' => $pdo->query("SELECT COUNT(*) FROM pf_maintenances")->fetchColumn(),
+ 'parts' => $pdo->query("SELECT COUNT(*) FROM pf_parts")->fetchColumn(),
+ 'total_cost' => $pdo->query("SELECT COALESCE(SUM(cost),0) FROM pf_maintenances")->fetchColumn(),
+ 'total_parts_cost' => $pdo->query("SELECT COALESCE(SUM(price*quantity),0) FROM pf_parts")->fetchColumn(),
+ 'upcoming_reminders'=> $pdo->query("SELECT COUNT(*) FROM pf_maintenances WHERE next_date >= CURDATE() OR next_km IS NOT NULL")->fetchColumn(),
+ ]);
+}
+
+// ─── Photo upload ──────────────────────────────────────────────────────────────
+if ($action === 'upload_vehicle_photo') {
+ $id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
+ $photo = handleUpload('photo', $UPLOAD_DIR); if (!$photo) gErr('Upload échoué');
+ $old = $pdo->prepare("SELECT photo FROM pf_vehicles WHERE id=?"); $old->execute([$id]); $row = $old->fetch(); if ($row['photo']) @unlink($UPLOAD_DIR . $row['photo']);
+ $pdo->prepare("UPDATE pf_vehicles SET photo = ?, updated_at = NOW() WHERE id = ?")->execute([$photo, $id]); gOk(['photo' => $photo]);
+}
+
+if ($action === 'photo') {
+ $f = basename($_GET['file'] ?? '');
+ if ($f && preg_match('/^[a-zA-Z0-9._-]+$/', $f) && file_exists($UPLOAD_DIR . $f)) {
+ $ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
+ $mime = ['jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png','gif'=>'image/gif','webp'=>'image/webp'][$ext] ?? 'image/jpeg';
+ header('Content-Type: ' . $mime);
+ readfile($UPLOAD_DIR . $f);
+ exit;
+ }
+ http_response_code(404); exit;
+}
+
+if ($action === 'upload_part_photo') {
+ $id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
+ $photo = handleUpload('photo', $UPLOAD_DIR); if (!$photo) gErr('Upload échoué');
+ $pdo->prepare("UPDATE pf_parts SET photo = ? WHERE id = ?")->execute([$photo, $id]); gOk(['photo' => $photo]);
+}
+gErr('Action inconnue', 404);
diff --git a/modules/garage/assets/garage.css b/modules/garage/assets/garage.css
new file mode 100644
index 0000000..c33f898
--- /dev/null
+++ b/modules/garage/assets/garage.css
@@ -0,0 +1,361 @@
+:root {
+ --bg: #0d1117;
+ --card: #161b22;
+ --border: #30363d;
+ --text: #e6edf3;
+ --muted: #8b949e;
+ --primary: #2563eb;
+ --success: #22c55e;
+ --warning: #f59e0b;
+ --danger: #ef4444;
+ --info: #06b6d4;
+ --radius: 8px;
+ --radius-lg: 12px;
+}
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+html { font-size: 15px; }
+body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh; }
+a { color: var(--primary); text-decoration: none; }
+a:hover { text-decoration: underline; }
+img { max-width: 100%; display: block; }
+
+/* NAVBAR */
+.navbar {
+ position: sticky; top: 0; z-index: 100;
+ background: var(--card);
+ border-bottom: 1px solid var(--border);
+ padding: 0 1.5rem;
+ display: flex; align-items: center; gap: 1.5rem;
+ height: 56px;
+}
+.navbar-brand {
+ font-size: 1.15rem; font-weight: 700; color: var(--text);
+ text-decoration: none; white-space: nowrap;
+}
+.navbar-brand:hover { text-decoration: none; color: var(--primary); }
+.navbar-nav { display: flex; gap: 0.25rem; margin-left: auto; }
+.navbar-nav a {
+ color: var(--muted); font-size: 0.9rem; font-weight: 500;
+ padding: 0.35rem 0.75rem; border-radius: var(--radius);
+ transition: background 0.15s, color 0.15s;
+}
+.navbar-nav a:hover, .navbar-nav a.active {
+ background: rgba(37,99,235,0.15); color: var(--primary); text-decoration: none;
+}
+
+/* CONTAINER */
+.container { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
+
+/* PAGES */
+.page { display: none; }
+.page.active { display: block; }
+
+/* CARD */
+.card {
+ background: var(--card); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); padding: 1.25rem;
+}
+.card-title { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: var(--text); }
+
+/* STATS GRID */
+.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
+.stat-pill {
+ background: var(--card); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); padding: 1.25rem 1.5rem;
+ display: flex; flex-direction: column; gap: 0.4rem;
+ border-left: 4px solid var(--border);
+}
+.stat-pill-label { font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
+.stat-pill-value { font-size: 1.75rem; font-weight: 700; line-height: 1; }
+.stat-pill-sub { font-size: 0.8rem; color: var(--muted); }
+.stat-pill.blue { border-left-color: var(--primary); }
+.stat-pill.blue .stat-pill-value { color: var(--primary); }
+.stat-pill.green { border-left-color: var(--success); }
+.stat-pill.green .stat-pill-value { color: var(--success); }
+.stat-pill.amber { border-left-color: var(--warning); }
+.stat-pill.amber .stat-pill-value { color: var(--warning); }
+.stat-pill.red { border-left-color: var(--danger); }
+.stat-pill.red .stat-pill-value { color: var(--danger); }
+.stat-pill.cyan { border-left-color: var(--info); }
+.stat-pill.cyan .stat-pill-value { color: var(--info); }
+
+/* VEHICLES GRID */
+.vehicles-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.25rem; }
+.vehicle-card {
+ background: var(--card); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); overflow: hidden;
+ transition: border-color 0.2s, transform 0.15s;
+ cursor: pointer;
+}
+.vehicle-card:hover { border-color: var(--primary); transform: translateY(-2px); }
+.vehicle-card-img {
+ width: 100%; height: 180px; object-fit: cover;
+ background: #1c2128;
+}
+.vehicle-card-img-placeholder {
+ width: 100%; height: 180px; background: #1c2128;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 3.5rem; color: var(--muted);
+}
+.vehicle-card-body { padding: 1rem; }
+.vehicle-card-title { font-size: 1rem; font-weight: 600; margin-bottom: 0.35rem; }
+.vehicle-card-sub { font-size: 0.82rem; color: var(--muted); margin-bottom: 0.6rem; }
+.vehicle-card-meta { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
+.vehicle-card-footer {
+ padding: 0.75rem 1rem; border-top: 1px solid var(--border);
+ display: flex; justify-content: space-between; align-items: center;
+ font-size: 0.8rem; color: var(--muted);
+}
+
+/* FUEL BADGE */
+.fuel-badge {
+ display: inline-block; font-size: 0.72rem; font-weight: 600;
+ padding: 0.2rem 0.5rem; border-radius: 4px;
+ text-transform: uppercase; letter-spacing: 0.03em;
+}
+.fuel-Essence { background: rgba(37,99,235,0.2); color: #60a5fa; }
+.fuel-Diesel { background: rgba(139,92,246,0.2); color: #a78bfa; }
+.fuel-Hybride { background: rgba(34,197,94,0.2); color: #4ade80; }
+.fuel-Electrique { background: rgba(6,182,212,0.2); color: #22d3ee; }
+
+/* PLATE */
+.plate {
+ display: inline-block; background: #fff; color: #111;
+ font-family: 'Courier New', monospace; font-weight: 700;
+ font-size: 0.82rem; padding: 0.2rem 0.5rem;
+ border-radius: 4px; letter-spacing: 0.08em;
+}
+
+/* TABLE */
+.table-wrap { overflow-x: auto; border-radius: var(--radius); }
+.table-wrap table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
+.table-wrap table th {
+ background: #1c2128; color: var(--muted); font-weight: 600;
+ text-align: left; padding: 0.65rem 0.9rem;
+ border-bottom: 1px solid var(--border); white-space: nowrap;
+}
+.table-wrap table td { padding: 0.65rem 0.9rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
+.table-wrap table tr:last-child td { border-bottom: none; }
+.table-wrap table tr:hover td { background: rgba(255,255,255,0.03); }
+
+/* BADGES */
+.badge {
+ display: inline-block; font-size: 0.72rem; font-weight: 600;
+ padding: 0.22rem 0.55rem; border-radius: 999px;
+}
+.badge-green { background: rgba(34,197,94,0.15); color: #4ade80; }
+.badge-blue { background: rgba(37,99,235,0.15); color: #60a5fa; }
+.badge-amber { background: rgba(245,158,11,0.15); color: #fbbf24; }
+.badge-red { background: rgba(239,68,68,0.15); color: #f87171; }
+.badge-gray { background: rgba(139,148,158,0.15); color: var(--muted); }
+.badge-purple { background: rgba(139,92,246,0.15); color: #a78bfa; }
+
+/* BUTTONS */
+.btn {
+ display: inline-flex; align-items: center; gap: 0.4rem;
+ padding: 0.5rem 1rem; border-radius: var(--radius);
+ font-size: 0.875rem; font-weight: 500; cursor: pointer;
+ border: 1px solid transparent; transition: all 0.15s;
+ white-space: nowrap; text-decoration: none;
+}
+.btn:hover { text-decoration: none; }
+.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
+.btn-primary:hover { background: #1d4ed8; border-color: #1d4ed8; }
+.btn-secondary { background: transparent; color: var(--text); border-color: var(--border); }
+.btn-secondary:hover { background: rgba(255,255,255,0.05); border-color: var(--muted); }
+.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
+.btn-danger:hover { background: rgba(239,68,68,0.1); }
+.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
+.btn-icon { padding: 0.35rem; min-width: 32px; justify-content: center; }
+
+/* FORMS */
+.form-group { display: flex; flex-direction: column; gap: 0.35rem; }
+.form-label { font-size: 0.82rem; font-weight: 500; color: var(--muted); }
+.form-control {
+ background: #1c2128; border: 1px solid var(--border); color: var(--text);
+ border-radius: var(--radius); padding: 0.5rem 0.75rem; font-size: 0.875rem;
+ width: 100%; outline: none; transition: border-color 0.15s;
+}
+.form-control:focus { border-color: var(--primary); }
+.form-control::placeholder { color: var(--muted); }
+select.form-control option { background: #1c2128; }
+textarea.form-control { resize: vertical; min-height: 80px; }
+.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; }
+.form-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.9rem; }
+
+/* MODALS */
+.modal-backdrop {
+ display: none; position: fixed; inset: 0; z-index: 200;
+ background: rgba(0,0,0,0.7); backdrop-filter: blur(2px);
+ align-items: center; justify-content: center; padding: 1rem;
+}
+.modal-backdrop.open { display: flex; }
+.modal {
+ background: var(--card); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); width: 100%; max-width: 640px;
+ max-height: 90vh; display: flex; flex-direction: column;
+ animation: modalIn 0.18s ease;
+}
+.modal-lg { max-width: 780px; }
+@keyframes modalIn { from { opacity: 0; transform: scale(0.95) translateY(-10px); } to { opacity: 1; transform: none; } }
+.modal-header {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); flex-shrink: 0;
+}
+.modal-header h3 { font-size: 1rem; font-weight: 600; }
+.modal-close { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1.25rem; line-height: 1; padding: 0.2rem; }
+.modal-close:hover { color: var(--text); }
+.modal-body { padding: 1.25rem; overflow-y: auto; display: flex; flex-direction: column; gap: 0.9rem; }
+.modal-footer { padding: 1rem 1.25rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 0.5rem; flex-shrink: 0; }
+
+/* VEHICLE DETAIL */
+.vehicle-detail-header {
+ display: flex; gap: 1.5rem; align-items: flex-start;
+ background: var(--card); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); padding: 1.5rem; margin-bottom: 1.5rem;
+}
+.vehicle-detail-img {
+ width: 180px; height: 130px; object-fit: cover;
+ border-radius: var(--radius); flex-shrink: 0; background: #1c2128;
+}
+.vehicle-detail-img-placeholder {
+ width: 180px; height: 130px; background: #1c2128;
+ border-radius: var(--radius); display: flex; align-items: center;
+ justify-content: center; font-size: 3rem; flex-shrink: 0;
+}
+.vehicle-detail-info { flex: 1; min-width: 0; }
+.vehicle-detail-name { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.35rem; }
+.vehicle-detail-sub { font-size: 0.9rem; color: var(--muted); margin-bottom: 0.75rem; }
+.vehicle-detail-stats { display: flex; gap: 1.5rem; flex-wrap: wrap; }
+.vehicle-detail-stat { display: flex; flex-direction: column; gap: 0.1rem; }
+.vehicle-detail-stat-label { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; }
+.vehicle-detail-stat-value { font-size: 1rem; font-weight: 600; }
+
+/* TABS */
+.tabs { display: flex; gap: 0.25rem; margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0; }
+.tab-btn {
+ background: none; border: none; cursor: pointer;
+ color: var(--muted); font-size: 0.9rem; font-weight: 500;
+ padding: 0.6rem 1rem; border-bottom: 2px solid transparent;
+ margin-bottom: -1px; transition: color 0.15s, border-color 0.15s;
+}
+.tab-btn:hover { color: var(--text); }
+.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
+.tab-pane { display: none; }
+.tab-pane.active { display: block; }
+
+/* PARTS */
+.part-thumb {
+ width: 40px; height: 40px; object-fit: cover;
+ border-radius: 4px; background: #1c2128;
+}
+.part-thumb-placeholder {
+ width: 40px; height: 40px; border-radius: 4px;
+ background: #1c2128; display: flex; align-items: center;
+ justify-content: center; font-size: 1rem; color: var(--muted);
+}
+
+/* EMPTY STATE */
+.empty-state {
+ display: flex; flex-direction: column; align-items: center;
+ gap: 0.75rem; padding: 3rem 1rem; color: var(--muted); text-align: center;
+}
+.empty-state-icon { font-size: 2.5rem; opacity: 0.5; }
+.empty-state p { font-size: 0.9rem; }
+
+/* TOASTS */
+.toast-container {
+ position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 999;
+ display: flex; flex-direction: column; gap: 0.5rem; pointer-events: none;
+}
+.toast {
+ background: var(--card); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 0.75rem 1rem;
+ font-size: 0.875rem; min-width: 220px; max-width: 360px;
+ box-shadow: 0 4px 20px rgba(0,0,0,0.4);
+ animation: slideIn 0.25s ease; pointer-events: auto;
+ border-left: 4px solid var(--border);
+}
+.toast.success { border-left-color: var(--success); }
+.toast.error { border-left-color: var(--danger); }
+.toast.info { border-left-color: var(--info); }
+.toast.warning { border-left-color: var(--warning); }
+@keyframes slideIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: none; } }
+
+/* UPLOAD ZONE */
+.upload-zone {
+ border: 2px dashed var(--border); border-radius: var(--radius);
+ padding: 1.5rem; text-align: center; color: var(--muted);
+ cursor: pointer; transition: border-color 0.15s;
+}
+.upload-zone:hover { border-color: var(--primary); color: var(--text); }
+.preview-img {
+ max-height: 120px; border-radius: var(--radius);
+ margin-top: 0.75rem; display: none;
+}
+
+/* SECTION HEADER */
+.section-header {
+ display: flex; align-items: center; justify-content: space-between;
+ margin-bottom: 1.25rem;
+}
+.section-title { font-size: 1.1rem; font-weight: 600; }
+
+/* PAGE HEADER */
+.page-header {
+ display: flex; align-items: center; justify-content: space-between;
+ margin-bottom: 1.5rem; gap: 1rem;
+}
+.page-title { font-size: 1.4rem; font-weight: 700; }
+
+/* REMINDERS */
+.reminder-item {
+ display: flex; align-items: center; gap: 0.75rem;
+ padding: 0.75rem 0; border-bottom: 1px solid var(--border);
+}
+.reminder-item:last-child { border-bottom: none; }
+.reminder-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
+.reminder-dot.overdue { background: var(--danger); }
+.reminder-dot.soon { background: var(--warning); }
+.reminder-dot.ok { background: var(--success); }
+.reminder-info { flex: 1; min-width: 0; }
+.reminder-title { font-size: 0.875rem; font-weight: 500; }
+.reminder-sub { font-size: 0.78rem; color: var(--muted); }
+.reminder-date { font-size: 0.78rem; color: var(--muted); white-space: nowrap; }
+
+/* DASHBOARD LAYOUT */
+.dashboard-grid { display: grid; grid-template-columns: 1fr 340px; gap: 1.5rem; }
+
+/* MISC */
+.text-muted { color: var(--muted); }
+.text-right { text-align: right; }
+.flex { display: flex; }
+.flex-center { display: flex; align-items: center; }
+.gap-1 { gap: 0.5rem; }
+.gap-2 { gap: 1rem; }
+.mt-1 { margin-top: 0.5rem; }
+.mt-2 { margin-top: 1rem; }
+.mb-1 { margin-bottom: 0.5rem; }
+.mb-2 { margin-bottom: 1rem; }
+.back-btn {
+ display: inline-flex; align-items: center; gap: 0.4rem;
+ color: var(--muted); font-size: 0.875rem; cursor: pointer;
+ margin-bottom: 1rem; background: none; border: none; padding: 0;
+ transition: color 0.15s;
+}
+.back-btn:hover { color: var(--text); }
+.actions-cell { display: flex; gap: 0.35rem; }
+
+/* RESPONSIVE */
+@media (max-width: 768px) {
+ .container { padding: 1rem; }
+ .dashboard-grid { grid-template-columns: 1fr; }
+ .vehicle-detail-header { flex-direction: column; }
+ .vehicle-detail-img, .vehicle-detail-img-placeholder { width: 100%; height: 200px; }
+ .form-row, .form-row-3 { grid-template-columns: 1fr; }
+ .stats-grid { grid-template-columns: repeat(2, 1fr); }
+ .modal { max-height: 95vh; }
+ .navbar { padding: 0 1rem; gap: 1rem; }
+ .vehicles-grid { grid-template-columns: 1fr; }
+}
diff --git a/modules/garage/assets/garage.js b/modules/garage/assets/garage.js
new file mode 100644
index 0000000..4564e8c
--- /dev/null
+++ b/modules/garage/assets/garage.js
@@ -0,0 +1,369 @@
+// GarageManager - SPA Frontend
+const API = '/modules/garage/api.php';
+const UPLOADS = '/modules/garage/api.php?action=photo&file=';
+
+function $(s, ctx=document){return ctx.querySelector(s);}
+function $$(s, ctx=document){return [...ctx.querySelectorAll(s)];}
+function fmt(n){return n!=null?Number(n).toLocaleString('fr-FR'):'--';}
+function fmtPrice(n){return n!=null?Number(n).toFixed(2)+' \u20ac':'--';}
+function fmtDate(d){if(!d)return '--';return new Date(d).toLocaleDateString('fr-FR');}
+function ago(d){if(!d)return '';const diff=Math.floor((Date.now()-new Date(d))/86400000);if(diff===0)return "aujourd'hui";if(diff===1)return 'hier';return 'il y a '+diff+'j';}
+function toast(msg,type='success'){
+ const c=document.getElementById('toasts');const t=document.createElement('div');
+ t.className='toast '+type;t.textContent=msg;c.appendChild(t);
+ setTimeout(()=>t.remove(),3000);
+}
+
+// NAV
+let currentPage='dashboard';
+function navigate(page,params={}){
+ currentPage=page;
+ $$('.page').forEach(p=>p.classList.remove('active'));
+ $$('.nav-link').forEach(n=>n.classList.remove('active'));
+ document.getElementById('page-'+page)?.classList.add('active');
+ document.querySelector('.nav-link[data-page="'+page+'"]')?.classList.add('active');
+ if(page==='dashboard')loadDashboard();
+ else if(page==='vehicles')loadVehicles();
+ else if(page==='vehicle')loadVehicleDetail(params.id);
+ else if(page==='parts')loadParts();
+}
+
+// API
+async function api(action,method='GET',data=null,extra=''){
+ const opts={method,headers:{}};
+ if(data&&!(data instanceof FormData)){opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(data);}
+ else if(data instanceof FormData){opts.body=data;}
+ const r=await fetch(API+'?action='+action+extra,opts);
+ const j=await r.json();
+ if(!j.ok)throw new Error(j.error||'Erreur API');
+ return j.data;
+}
+
+// DASHBOARD
+async function loadDashboard(){
+ try{
+ const[stats,vehicles,reminders]=await Promise.all([api('stats'),api('vehicles'),api('maintenances')]);
+ $('#stat-vehicles').textContent=stats.vehicles;
+ $('#stat-maintenances').textContent=stats.maintenances;
+ $('#stat-parts').textContent=stats.parts;
+ $('#stat-cost').textContent=fmtPrice(parseFloat(stats.total_cost)+parseFloat(stats.total_parts_cost));
+ renderDashboardVehicles(vehicles);
+ renderReminders(reminders);
+ }catch(e){toast(e.message,'error');}
+}
+
+function renderDashboardVehicles(list){
+ const el=$('#dashboard-vehicles');
+ if(!list.length){el.innerHTML='\ud83d\ude97
Aucun v\u00e9hicule
';return;}
+ el.innerHTML=list.slice(0,6).map(v=>vehicleCardHTML(v)).join('');
+ $$('.vehicle-card',el).forEach(c=>c.addEventListener('click',()=>navigate('vehicle',{id:c.dataset.id})));
+}
+
+function vehicleCardHTML(v){
+ const photoHTML=v.photo?'
':'\ud83d\ude97';
+ const fuel=v.fuel_type||'Essence';
+ const fuelClass='fuel-'+fuel.replace(/\s/g,'');
+ return ''+
+ '
'+photoHTML+''+fuel+'
'+
+ '
'+
+ '
'+v.name+'
'+
+ '
'+v.brand+' '+v.model+(v.year?' · '+v.year:'')+'
'+
+ '
'+
+ '
\ud83d\udd27 '+(v.maintenance_count||0)+' entretiens
'+
+ '
\ud83d\udcb6 '+fmtPrice(v.total_cost)+'
'+
+ '
'+
+ '
'+
+ '
';
+}
+
+function renderReminders(list){
+ const el=$('#reminders-list');const today=new Date();
+ if(!list.length){el.innerHTML='Aucun rappel configur\u00e9
';return;}
+ el.innerHTML='| V\u00e9hicule | Type | Prochaine date | Prochain km | \u00c9cart km |
'+
+ list.map(r=>{
+ const diff=r.next_km&&r.current_km?r.next_km-r.current_km:null;
+ const dateOk=r.next_date?new Date(r.next_date)>today:true;
+ const kmOk=diff==null||diff>0;
+ const cls=(!dateOk||!kmOk)?'badge-red':diff!=null&&diff<2000?'badge-amber':'badge-green';
+ return '| '+r.vehicle_name+''+(r.license_plate?' '+r.license_plate+'':'')+' | '+
+ ''+r.type+' | '+
+ ''+(r.next_date?''+fmtDate(r.next_date)+'':'--')+' | '+
+ ''+(r.next_km?fmt(r.next_km)+' km':'--')+' | '+
+ ''+(diff!=null?''+(diff>=0?'+':'')+fmt(diff)+' km':'--')+' |
';
+ }).join('')+
+ '
';
+}
+
+// VEHICLES LIST
+async function loadVehicles(){
+ try{
+ const list=await api('vehicles');
+ const el=$('#vehicles-grid');
+ if(!list.length){el.innerHTML='\ud83d\ude97
Aucun v\u00e9hicule. Ajoutez-en un !
';return;}
+ el.innerHTML=list.map(v=>vehicleCardHTML(v)).join('');
+ $$('.vehicle-card',el).forEach(c=>c.addEventListener('click',()=>navigate('vehicle',{id:c.dataset.id})));
+ }catch(e){toast(e.message,'error');}
+}
+
+// VEHICLE DETAIL
+let currentVehicleId=null;
+async function loadVehicleDetail(id){
+ currentVehicleId=id;
+ try{
+ const[v,maintenances,parts]=await Promise.all([
+ api('vehicles','GET',null,'&id='+id),
+ api('maintenances','GET',null,'&vehicle_id='+id),
+ api('parts','GET',null,'&vehicle_id='+id)
+ ]);
+ renderVehicleHeader(v);
+ renderMaintenances(maintenances);
+ renderVehicleParts(parts);
+ document.title='GarageManager \u00b7 '+v.name;
+ }catch(e){toast(e.message,'error');}
+}
+
+function renderVehicleHeader(v){
+ const photoHTML=v.photo?'
':'\ud83d\ude97
';
+ const totalCost=(parseFloat(v.stats?.total||0)+parseFloat(v.parts_stats?.total||0)).toFixed(2);
+ $('#vehicle-header').innerHTML=
+ '';
+}
+
+function renderMaintenances(list){
+ const el=$('#maintenance-list');
+ const totalCost=list.reduce((s,m)=>s+parseFloat(m.cost||0)+parseFloat(m.parts_cost||0),0);
+ $('#maintenance-total').textContent=fmtPrice(totalCost);
+ if(!list.length){el.innerHTML='\ud83d\udd27
Aucun entretien enregistr\u00e9
';return;}
+ el.innerHTML='| Date | Type | Description | Kilom\u00e9trage | Co\u00fbt MO | Pi\u00e8ces | Prochain | |
'+
+ list.map(m=>''+
+ ''+fmtDate(m.date)+' '+ago(m.date)+' | '+
+ ''+m.type+' | '+
+ ''+(m.description||'--')+' | '+
+ ''+(m.km?fmt(m.km)+' km':'--')+' | '+
+ ''+fmtPrice(m.cost)+' | '+
+ ''+(m.parts_count>0?'\ud83d\udd29 '+m.parts_count+' ('+fmtPrice(m.parts_cost)+')':'--')+' | '+
+ ''+(m.next_date?'\ud83d\udcc5 '+fmtDate(m.next_date):'')+' '+(m.next_km?' \ud83d\udee3\ufe0f '+fmt(m.next_km)+' km':'')+' | '+
+ ' | '+
+ '
').join('')+
+ '
';
+}
+
+function renderVehicleParts(list){
+ const el=$('#parts-list-vehicle');
+ const total=list.reduce((s,p)=>s+parseFloat(p.price||0)*parseInt(p.quantity||1),0);
+ $('#parts-total-vehicle').textContent=fmtPrice(total);
+ if(!list.length){el.innerHTML='\ud83d\udd29
Aucune pi\u00e8ce enregistr\u00e9e
';return;}
+ el.innerHTML='| Photo | Nom | Marque | R\u00e9f\u00e9rence | Cat\u00e9gorie | Prix unit. | Qt\u00e9 | Total | Fournisseur | |
'+
+ list.map(p=>''+
+ ''+(p.photo?' ':'--')+' | '+
+ ''+p.name+' | '+
+ ''+(p.brand||'--')+' | '+
+ ''+(p.reference||'--')+' | '+
+ ''+p.category+' | '+
+ ''+fmtPrice(p.price)+' | '+
+ ''+p.quantity+' '+p.unit+' | '+
+ ''+fmtPrice(parseFloat(p.price||0)*parseInt(p.quantity||1))+' | '+
+ ''+(p.supplier||'--')+' | '+
+ ' | '+
+ '
').join('')+
+ '
';
+}
+
+// ALL PARTS
+async function loadParts(){
+ try{
+ const list=await api('parts');
+ const el=$('#all-parts-list');
+ const total=list.reduce((s,p)=>s+parseFloat(p.price||0)*parseInt(p.quantity||1),0);
+ $('#all-parts-total').textContent=fmtPrice(total);
+ $('#all-parts-count').textContent=list.length;
+ if(!list.length){el.innerHTML='\ud83d\udd29
Aucune pi\u00e8ce
';return;}
+ el.innerHTML='| Photo | Nom | V\u00e9hicule | Marque | R\u00e9f\u00e9rence | Cat\u00e9gorie | Prix unit. | Qt\u00e9 | Total | Entretien | |
'+
+ list.map(p=>''+
+ ''+(p.photo?' ':'--')+' | '+
+ ''+p.name+' | '+
+ ''+(p.vehicle_name?''+p.vehicle_name+'':'--')+' | '+
+ ''+(p.brand||'--')+' | '+
+ ''+(p.reference||'--')+' | '+
+ ''+p.category+' | '+
+ ''+fmtPrice(p.price)+' | '+
+ ''+p.quantity+' '+p.unit+' | '+
+ ''+fmtPrice(parseFloat(p.price||0)*parseInt(p.quantity||1))+' | '+
+ ''+(p.maintenance_type?p.maintenance_type+' ('+fmtDate(p.maintenance_date)+')':'--')+' | '+
+ ' | '+
+ '
').join('')+
+ '
';
+ }catch(e){toast(e.message,'error');}
+}
+
+// MODALS
+function openModal(id){document.getElementById(id).classList.add('show');}
+function closeModal(id){document.getElementById(id).classList.remove('show');}
+
+// VEHICLE CRUD
+function openAddVehicle(){
+ $('#form-vehicle').reset();$('#form-vehicle-id').value='';
+ $('#modal-vehicle-title').textContent='Ajouter un v\u00e9hicule';
+ $('#vehicle-photo-preview').src='';$('#vehicle-photo-preview').style.display='none';
+ openModal('modal-vehicle');
+}
+function openEditVehicle(id){
+ api('vehicles','GET',null,'&id='+id).then(v=>{
+ $('#form-vehicle-id').value=v.id;
+ $('#modal-vehicle-title').textContent='Modifier le v\u00e9hicule';
+ ['name','brand','model','year','license_plate','vin','fuel_type','color','purchase_date','purchase_price','current_km','notes'].forEach(f=>{
+ const el=$('#vehicle-'+f.replace(/_/g,'-'));
+ if(el)el.value=v[f]||'';
+ });
+ if(v.photo){$('#vehicle-photo-preview').src=UPLOADS+v.photo;$('#vehicle-photo-preview').style.display='block';}
+ openModal('modal-vehicle');
+ });
+}
+async function saveVehicle(){
+ const id=$('#form-vehicle-id').value;
+ const fd=new FormData($('#form-vehicle'));
+ try{
+ if(id){
+ const d={};for(const[k,v]of fd.entries())d[k]=v;
+ await api('vehicles','PUT',d,'&id='+id);
+ const photoFile=$('#vehicle-photo').files[0];
+ if(photoFile){const pfd=new FormData();pfd.append('photo',photoFile);await fetch(API+'?action=upload_vehicle_photo&id='+id,{method:'POST',body:pfd});}
+ toast('V\u00e9hicule modifi\u00e9');
+ }else{
+ await api('vehicles','POST',fd);
+ toast('V\u00e9hicule ajout\u00e9');
+ }
+ closeModal('modal-vehicle');
+ if(currentPage==='vehicles')loadVehicles();
+ else if(currentPage==='vehicle')loadVehicleDetail(currentVehicleId);
+ else loadDashboard();
+ }catch(e){toast(e.message,'error');}
+}
+async function deleteVehicle(id){
+ if(!confirm('Supprimer ce v\u00e9hicule et tout son historique ?'))return;
+ try{await api('vehicles','DELETE',null,'&id='+id);toast('V\u00e9hicule supprim\u00e9');navigate('vehicles');}catch(e){toast(e.message,'error');}
+}
+
+// MAINTENANCE CRUD
+function openAddMaintenance(){
+ $('#form-maintenance').reset();$('#form-maintenance-id').value='';
+ $('#maintenance-vehicle-id').value=currentVehicleId||'';
+ $('#modal-maintenance-title').textContent='Ajouter un entretien';
+ $('#maintenance-date').value=new Date().toISOString().slice(0,10);
+ openModal('modal-maintenance');
+}
+async function saveMaintenance(){
+ const id=$('#form-maintenance-id').value;
+ const d={};new FormData($('#form-maintenance')).forEach((v,k)=>d[k]=v);
+ try{
+ if(id){await api('maintenances','PUT',d,'&id='+id);toast('Entretien modifi\u00e9');}
+ else{await api('maintenances','POST',d);toast('Entretien ajout\u00e9');}
+ closeModal('modal-maintenance');
+ loadVehicleDetail(currentVehicleId);
+ }catch(e){toast(e.message,'error');}
+}
+async function deleteMaintenance(id){
+ if(!confirm('Supprimer cet entretien ?'))return;
+ try{await api('maintenances','DELETE',null,'&id='+id);toast('Entretien supprim\u00e9');loadVehicleDetail(currentVehicleId);}catch(e){toast(e.message,'error');}
+}
+
+// PARTS CRUD
+function openAddPart(){
+ $('#form-part').reset();$('#form-part-id').value='';
+ $('#part-vehicle-id').value=currentVehicleId||'';
+ $('#modal-part-title').textContent='Ajouter une pi\u00e8ce';
+ $('#part-purchase-date').value=new Date().toISOString().slice(0,10);
+ $('#part-photo-preview').src='';$('#part-photo-preview').style.display='none';
+ openModal('modal-part');
+}
+async function savePart(){
+ const id=$('#form-part-id').value;
+ const fd=new FormData($('#form-part'));
+ try{
+ if(id){
+ const d={};for(const[k,v]of fd.entries())d[k]=v;
+ await api('parts','PUT',d,'&id='+id);
+ const photoFile=$('#part-photo').files[0];
+ if(photoFile){const pfd=new FormData();pfd.append('photo',photoFile);await fetch(API+'?action=upload_part_photo&id='+id,{method:'POST',body:pfd});}
+ toast('Pi\u00e8ce modifi\u00e9e');
+ }else{
+ await api('parts','POST',fd);
+ toast('Pi\u00e8ce ajout\u00e9e');
+ }
+ closeModal('modal-part');
+ if(currentPage==='vehicle')loadVehicleDetail(currentVehicleId);
+ else loadParts();
+ }catch(e){toast(e.message,'error');}
+}
+async function deletePart(id){
+ if(!confirm('Supprimer cette pi\u00e8ce ?'))return;
+ try{
+ await api('parts','DELETE',null,'&id='+id);
+ toast('Pi\u00e8ce supprim\u00e9e');
+ if(currentPage==='vehicle')loadVehicleDetail(currentVehicleId);
+ else loadParts();
+ }catch(e){toast(e.message,'error');}
+}
+
+// UPLOAD PHOTO
+function openUploadPhoto(id){$('#upload-vehicle-id').value=id;openModal('modal-upload-photo');}
+async function doUploadPhoto(){
+ const id=$('#upload-vehicle-id').value;
+ const file=$('#upload-photo-file').files[0];
+ if(!file){toast('Choisissez une photo','error');return;}
+ const fd=new FormData();fd.append('photo',file);
+ try{
+ await fetch(API+'?action=upload_vehicle_photo&id='+id,{method:'POST',body:fd});
+ toast('Photo mise \u00e0 jour');closeModal('modal-upload-photo');
+ loadVehicleDetail(id);
+ }catch(e){toast(e.message,'error');}
+}
+
+// TABS
+function switchTab(tab){
+ $$('.tab-btn').forEach(b=>b.classList.remove('active'));
+ $$('.tab-pane').forEach(p=>p.classList.remove('active'));
+ document.querySelector('.tab-btn[data-tab="'+tab+'"]')?.classList.add('active');
+ document.getElementById('tab-'+tab)?.classList.add('active');
+}
+
+// PHOTO PREVIEW
+function previewPhoto(inputId,previewId){
+ const file=document.getElementById(inputId).files[0];if(!file)return;
+ const reader=new FileReader();
+ reader.onload=e=>{const img=document.getElementById(previewId);img.src=e.target.result;img.style.display='block';};
+ reader.readAsDataURL(file);
+}
+
+// INIT
+document.addEventListener('DOMContentLoaded',()=>{
+ $$('.nav-link').forEach(n=>n.addEventListener('click',()=>navigate(n.dataset.page)));
+ $$('.modal-backdrop').forEach(m=>m.addEventListener('click',e=>{if(e.target===m)m.classList.remove('show');}));
+ navigate('dashboard');
+});
diff --git a/settings.php b/settings.php
index 51254ed..804ed92 100644
--- a/settings.php
+++ b/settings.php
@@ -15,7 +15,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'set_modules' && $family_id) {
- $all = ['calendar', 'budget', 'holidays', 'gifts'];
+ $all = ['calendar', 'budget', 'holidays', 'gifts', 'garage'];
$enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m])));
if (empty($enabled)) {
$error = "Vous devez garder au moins un module actif.";
@@ -160,6 +160,7 @@ require __DIR__ . '/header.php';
'budget' => ['icon' => '💰', 'label' => tr('menu_budget')],
'holidays' => ['icon' => '🏖️', 'label' => tr('menu_holidays')],
'gifts' => ['icon' => '🎁', 'label' => tr('menu_gifts')],
+ 'garage' => ['icon' => '🚗', 'label' => tr('menu_garage')],
];
?>