From 62eabfb5ee65f9a4dba5099a7c48765feb6eceb3 Mon Sep 17 00:00:00 2001 From: perco Date: Mon, 11 May 2026 15:57:24 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20module=20Garage=20=E2=80=94=20v=C3=A9hi?= =?UTF-8?q?cules,=20entretiens,=20pi=C3=A8ces=20(int=C3=A9gration=20Garage?= =?UTF-8?q?Manager)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - modules/garage/api.php : API REST MySQL (vehicles, maintenances, parts, stats, photos) - modules/garage/assets/garage.css + garage.js : UI adaptée (chemins API mis à jour) - garage.php : page principale avec header/footer HouseHub + i18n - docker/schema_family.sql : tables pf_vehicles, pf_maintenances, pf_parts - docker-compose.yml : volume househub_uploads pour photos - includes/lang/fr|ca|en.php : ~50 clés de traduction garage - header.php : lien Garage dans nav desktop + mobile - settings.php : module garage dans les toggles - README.md : tableau des modules + mise à jour description Co-Authored-By: Claude Sonnet 4.6 --- README.md | 17 +- docker-compose.yml | 4 + docker/schema_family.sql | 58 +++++ garage.php | 257 +++++++++++++++++++++ header.php | 2 + includes/lang/ca.php | 55 ++++- includes/lang/en.php | 53 +++++ includes/lang/fr.php | 54 ++++- modules/garage/api.php | 161 ++++++++++++++ modules/garage/assets/garage.css | 361 ++++++++++++++++++++++++++++++ modules/garage/assets/garage.js | 369 +++++++++++++++++++++++++++++++ settings.php | 3 +- 12 files changed, 1388 insertions(+), 6 deletions(-) create mode 100644 garage.php create mode 100644 modules/garage/api.php create mode 100644 modules/garage/assets/garage.css create mode 100644 modules/garage/assets/garage.js 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 @@ + + + + +
+ +
+
+
+
--
+
--
+
--
+
--
+
+
+
+
+

+ +
+
+
+
+
🔔
+
+
+
+
+
+ +
+
+
+

🚗

+ +
+
+
+
+ +
+
+
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+

🔩

+

0 · Total : --

+
+ +
+
+
+
+ + + + + + + + + + + + + + + + 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'; + @@ -90,6 +91,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr'; 💰 🏖️ 🎁 + 🚗 ⚙️ 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='
'+ + 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 ''+ + ''+ + ''+ + ''+ + ''; + }).join('')+ + '
V\u00e9hiculeTypeProchaine dateProchain km\u00c9cart km
'+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':'--')+'
'; +} + +// 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= + '
'+ + '
'+photoHTML+'
'+ + '
'+ + '

'+v.name+'

'+ + '

'+v.brand+' '+v.model+(v.year?' \u00b7 '+v.year:'')+'

'+ + '
'+ + (v.license_plate?''+v.license_plate+'':'')+ + ''+(v.fuel_type||'Essence')+''+ + (v.color?'\ud83c\udfa8 '+v.color+'':'')+ + '
'+ + '
'+ + '
Kilom\u00e9trage
'+fmt(v.current_km)+' km
'+ + '
Entretiens
'+(v.stats?.cnt||0)+'
'+ + '
Co\u00fbt total
'+fmtPrice(totalCost)+'
'+ + (v.purchase_date?'
Achat
'+fmtDate(v.purchase_date)+'
':'')+ + (v.vin?'
VIN
'+v.vin+'
':'')+ + '
'+ + '
'+ + '
'+ + ''+ + ''+ + ''+ + '
'+ + '
'; +} + +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='
'+ + list.map(m=>''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + '').join('')+ + '
DateTypeDescriptionKilom\u00e9trageCo\u00fbt MOPi\u00e8cesProchain
'+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':'')+'
'; +} + +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='
'+ + list.map(p=>''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + '').join('')+ + '
PhotoNomMarqueR\u00e9f\u00e9renceCat\u00e9goriePrix unit.Qt\u00e9TotalFournisseur
'+(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||'--')+'
'; +} + +// 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='
'+ + list.map(p=>''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + '').join('')+ + '
PhotoNomV\u00e9hiculeMarqueR\u00e9f\u00e9renceCat\u00e9goriePrix unit.Qt\u00e9TotalEntretien
'+(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)+')':'--')+'
'; + }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')], ]; ?>