feat: module Garage — véhicules, entretiens, pièces (intégration GarageManager)
Deploy to Pacha Family / deploy (push) Failing after 3s
Deploy to Pacha Family / deploy (push) Failing after 3s
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f6de4918bd
commit
62eabfb5ee
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
require_once dirname(__DIR__, 2) . '/includes/auth.php';
|
||||
require_login();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
||||
|
||||
$UPLOAD_DIR = '/uploads/garage/';
|
||||
if (!is_dir($UPLOAD_DIR)) { @mkdir($UPLOAD_DIR, 0755, true); }
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
function gOk($d) { echo json_encode(['ok' => 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);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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='<div class="empty-state"><div class="icon">\ud83d\ude97</div><p>Aucun v\u00e9hicule</p></div>';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?'<img src="'+UPLOADS+v.photo+'" alt="">':'<span class="no-photo">\ud83d\ude97</span>';
|
||||
const fuel=v.fuel_type||'Essence';
|
||||
const fuelClass='fuel-'+fuel.replace(/\s/g,'');
|
||||
return '<div class="vehicle-card" data-id="'+v.id+'">'+
|
||||
'<div class="vehicle-card-img">'+photoHTML+'<span class="fuel-badge '+fuelClass+'">'+fuel+'</span></div>'+
|
||||
'<div class="vehicle-card-body">'+
|
||||
'<div class="vehicle-card-title">'+v.name+'</div>'+
|
||||
'<div class="vehicle-card-sub">'+v.brand+' '+v.model+(v.year?' · '+v.year:'')+'</div>'+
|
||||
'<div class="vehicle-card-stats">'+
|
||||
'<div class="vc-stat">\ud83d\udd27 <strong>'+(v.maintenance_count||0)+'</strong> entretiens</div>'+
|
||||
'<div class="vc-stat">\ud83d\udcb6 <strong>'+fmtPrice(v.total_cost)+'</strong></div>'+
|
||||
'</div>'+
|
||||
'</div>'+
|
||||
'<div class="vehicle-card-footer">'+
|
||||
(v.license_plate?'<span class="plate">'+v.license_plate+'</span>':'<span></span>')+
|
||||
'<span class="vc-stat">'+(v.current_km?fmt(v.current_km)+' km':'--')+'</span>'+
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function renderReminders(list){
|
||||
const el=$('#reminders-list');const today=new Date();
|
||||
if(!list.length){el.innerHTML='<div class="empty-state" style="padding:1.5rem"><p style="color:var(--muted)">Aucun rappel configur\u00e9</p></div>';return;}
|
||||
el.innerHTML='<div class="table-wrap"><table><thead><tr><th>V\u00e9hicule</th><th>Type</th><th>Prochaine date</th><th>Prochain km</th><th>\u00c9cart km</th></tr></thead><tbody>'+
|
||||
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 '<tr><td><strong>'+r.vehicle_name+'</strong>'+(r.license_plate?' <span class="plate">'+r.license_plate+'</span>':'')+'</td>'+
|
||||
'<td>'+r.type+'</td>'+
|
||||
'<td>'+(r.next_date?'<span class="badge '+cls+'">'+fmtDate(r.next_date)+'</span>':'--')+'</td>'+
|
||||
'<td>'+(r.next_km?fmt(r.next_km)+' km':'--')+'</td>'+
|
||||
'<td>'+(diff!=null?'<span class="badge '+(diff<0?'badge-red':diff<2000?'badge-amber':'badge-green')+'">'+(diff>=0?'+':'')+fmt(diff)+' km</span>':'--')+'</td></tr>';
|
||||
}).join('')+
|
||||
'</tbody></table></div>';
|
||||
}
|
||||
|
||||
// VEHICLES LIST
|
||||
async function loadVehicles(){
|
||||
try{
|
||||
const list=await api('vehicles');
|
||||
const el=$('#vehicles-grid');
|
||||
if(!list.length){el.innerHTML='<div class="empty-state"><div class="icon">\ud83d\ude97</div><p>Aucun v\u00e9hicule. Ajoutez-en un !</p></div>';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?'<img src="'+UPLOADS+v.photo+'" alt="">':'<div class="no-photo">\ud83d\ude97</div>';
|
||||
const totalCost=(parseFloat(v.stats?.total||0)+parseFloat(v.parts_stats?.total||0)).toFixed(2);
|
||||
$('#vehicle-header').innerHTML=
|
||||
'<div class="vehicle-detail-header">'+
|
||||
'<div class="vehicle-detail-img">'+photoHTML+'</div>'+
|
||||
'<div class="vehicle-meta">'+
|
||||
'<h2>'+v.name+'</h2>'+
|
||||
'<p class="sub">'+v.brand+' '+v.model+(v.year?' \u00b7 '+v.year:'')+'</p>'+
|
||||
'<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:.75rem">'+
|
||||
(v.license_plate?'<span class="plate">'+v.license_plate+'</span>':'')+
|
||||
'<span class="badge badge-blue">'+(v.fuel_type||'Essence')+'</span>'+
|
||||
(v.color?'<span class="badge badge-gray">\ud83c\udfa8 '+v.color+'</span>':'')+
|
||||
'</div>'+
|
||||
'<div class="meta-grid">'+
|
||||
'<div class="meta-item"><span class="k">Kilom\u00e9trage</span><br><span class="v">'+fmt(v.current_km)+' km</span></div>'+
|
||||
'<div class="meta-item"><span class="k">Entretiens</span><br><span class="v">'+(v.stats?.cnt||0)+'</span></div>'+
|
||||
'<div class="meta-item"><span class="k">Co\u00fbt total</span><br><span class="v">'+fmtPrice(totalCost)+'</span></div>'+
|
||||
(v.purchase_date?'<div class="meta-item"><span class="k">Achat</span><br><span class="v">'+fmtDate(v.purchase_date)+'</span></div>':'')+
|
||||
(v.vin?'<div class="meta-item"><span class="k">VIN</span><br><span class="v" style="font-size:.75rem;font-family:monospace">'+v.vin+'</span></div>':'')+
|
||||
'</div>'+
|
||||
'</div>'+
|
||||
'<div style="display:flex;gap:.5rem;flex-wrap:wrap;align-self:flex-start">'+
|
||||
'<button class="btn btn-secondary btn-sm" onclick="openEditVehicle('+v.id+')">\u270f\ufe0f Modifier</button>'+
|
||||
'<button class="btn btn-secondary btn-sm" onclick="openUploadPhoto('+v.id+')">\ud83d\udcf7 Photo</button>'+
|
||||
'<button class="btn btn-danger btn-sm" onclick="deleteVehicle('+v.id+')">\ud83d\uddd1\ufe0f Supprimer</button>'+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
}
|
||||
|
||||
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='<div class="empty-state"><div class="icon">\ud83d\udd27</div><p>Aucun entretien enregistr\u00e9</p></div>';return;}
|
||||
el.innerHTML='<div class="table-wrap"><table><thead><tr><th>Date</th><th>Type</th><th>Description</th><th>Kilom\u00e9trage</th><th>Co\u00fbt MO</th><th>Pi\u00e8ces</th><th>Prochain</th><th></th></tr></thead><tbody>'+
|
||||
list.map(m=>'<tr>'+
|
||||
'<td><strong>'+fmtDate(m.date)+'</strong><br><span style="color:var(--muted);font-size:.75rem">'+ago(m.date)+'</span></td>'+
|
||||
'<td><span class="badge badge-blue">'+m.type+'</span></td>'+
|
||||
'<td style="max-width:200px;color:var(--muted)">'+(m.description||'--')+'</td>'+
|
||||
'<td>'+(m.km?fmt(m.km)+' km':'--')+'</td>'+
|
||||
'<td>'+fmtPrice(m.cost)+'</td>'+
|
||||
'<td>'+(m.parts_count>0?'<span class="badge badge-purple">\ud83d\udd29 '+m.parts_count+' ('+fmtPrice(m.parts_cost)+')</span>':'--')+'</td>'+
|
||||
'<td style="font-size:.78rem">'+(m.next_date?'\ud83d\udcc5 '+fmtDate(m.next_date):'')+' '+(m.next_km?'<br>\ud83d\udee3\ufe0f '+fmt(m.next_km)+' km':'')+'</td>'+
|
||||
'<td><button class="btn btn-danger btn-sm btn-icon" onclick="deleteMaintenance('+m.id+')" title="Supprimer">\ud83d\uddd1\ufe0f</button></td>'+
|
||||
'</tr>').join('')+
|
||||
'</tbody></table></div>';
|
||||
}
|
||||
|
||||
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='<div class="empty-state"><div class="icon">\ud83d\udd29</div><p>Aucune pi\u00e8ce enregistr\u00e9e</p></div>';return;}
|
||||
el.innerHTML='<div class="table-wrap"><table><thead><tr><th>Photo</th><th>Nom</th><th>Marque</th><th>R\u00e9f\u00e9rence</th><th>Cat\u00e9gorie</th><th>Prix unit.</th><th>Qt\u00e9</th><th>Total</th><th>Fournisseur</th><th></th></tr></thead><tbody>'+
|
||||
list.map(p=>'<tr>'+
|
||||
'<td>'+(p.photo?'<img class="part-thumb" src="'+UPLOADS+p.photo+'" alt="">':'<span style="color:var(--muted)">--</span>')+'</td>'+
|
||||
'<td><strong>'+p.name+'</strong></td>'+
|
||||
'<td>'+(p.brand||'--')+'</td>'+
|
||||
'<td><code style="font-size:.75rem;color:var(--muted)">'+(p.reference||'--')+'</code></td>'+
|
||||
'<td><span class="badge badge-gray">'+p.category+'</span></td>'+
|
||||
'<td>'+fmtPrice(p.price)+'</td>'+
|
||||
'<td>'+p.quantity+' '+p.unit+'</td>'+
|
||||
'<td><strong>'+fmtPrice(parseFloat(p.price||0)*parseInt(p.quantity||1))+'</strong></td>'+
|
||||
'<td>'+(p.supplier||'--')+'</td>'+
|
||||
'<td><button class="btn btn-danger btn-sm btn-icon" onclick="deletePart('+p.id+')" title="Supprimer">\ud83d\uddd1\ufe0f</button></td>'+
|
||||
'</tr>').join('')+
|
||||
'</tbody></table></div>';
|
||||
}
|
||||
|
||||
// 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='<div class="empty-state"><div class="icon">\ud83d\udd29</div><p>Aucune pi\u00e8ce</p></div>';return;}
|
||||
el.innerHTML='<div class="table-wrap"><table><thead><tr><th>Photo</th><th>Nom</th><th>V\u00e9hicule</th><th>Marque</th><th>R\u00e9f\u00e9rence</th><th>Cat\u00e9gorie</th><th>Prix unit.</th><th>Qt\u00e9</th><th>Total</th><th>Entretien</th><th></th></tr></thead><tbody>'+
|
||||
list.map(p=>'<tr>'+
|
||||
'<td>'+(p.photo?'<img class="part-thumb" src="'+UPLOADS+p.photo+'" alt="">':'--')+'</td>'+
|
||||
'<td><strong>'+p.name+'</strong></td>'+
|
||||
'<td>'+(p.vehicle_name?'<span class="badge badge-blue">'+p.vehicle_name+'</span>':'--')+'</td>'+
|
||||
'<td>'+(p.brand||'--')+'</td>'+
|
||||
'<td><code style="font-size:.75rem;color:var(--muted)">'+(p.reference||'--')+'</code></td>'+
|
||||
'<td><span class="badge badge-gray">'+p.category+'</span></td>'+
|
||||
'<td>'+fmtPrice(p.price)+'</td>'+
|
||||
'<td>'+p.quantity+' '+p.unit+'</td>'+
|
||||
'<td><strong>'+fmtPrice(parseFloat(p.price||0)*parseInt(p.quantity||1))+'</strong></td>'+
|
||||
'<td>'+(p.maintenance_type?p.maintenance_type+' ('+fmtDate(p.maintenance_date)+')':'--')+'</td>'+
|
||||
'<td><button class="btn btn-danger btn-sm btn-icon" onclick="deletePart('+p.id+')" title="Supprimer">\ud83d\uddd1\ufe0f</button></td>'+
|
||||
'</tr>').join('')+
|
||||
'</tbody></table></div>';
|
||||
}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');
|
||||
});
|
||||
Reference in New Issue
Block a user