diff --git a/pos/blueprints/fleet_bp.py b/pos/blueprints/fleet_bp.py new file mode 100644 index 0000000..cc39279 --- /dev/null +++ b/pos/blueprints/fleet_bp.py @@ -0,0 +1,540 @@ +# /home/Autopartes/pos/blueprints/fleet_bp.py +"""Fleet management blueprint: vehicles, maintenance schedules, logs, alerts.""" + +from datetime import datetime, timedelta +from flask import Blueprint, request, jsonify, g +from middleware import require_auth +from tenant_db import get_tenant_conn +from services.audit import log_action + +fleet_bp = Blueprint('fleet', __name__, url_prefix='/pos/api/fleet') + + +# ─── Vehicles CRUD ───────────────────────────── + +@fleet_bp.route('/vehicles', methods=['GET']) +@require_auth() +def list_vehicles(): + """List fleet vehicles with pagination and search. + + Query params: + q: search string (matches plate, make, model, owner_name) + page: page number (default 1) + per_page: items per page (default 50, max 200) + active_only: filter active only (default true) + """ + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + page = int(request.args.get('page', 1)) + per_page = min(int(request.args.get('per_page', 50)), 200) + search = request.args.get('q', '').strip() + active_only = request.args.get('active_only', 'true').lower() != 'false' + + where_clauses = [] + params = [] + + if active_only: + where_clauses.append("v.is_active = true") + if g.branch_id: + where_clauses.append("v.branch_id = %s") + params.append(g.branch_id) + if search: + where_clauses.append( + "(v.plate ILIKE %s OR v.make ILIKE %s OR v.model ILIKE %s OR v.owner_name ILIKE %s OR v.vin ILIKE %s)" + ) + params.extend([f'%{search}%'] * 5) + + where = " AND ".join(where_clauses) if where_clauses else "true" + + # Count + cur.execute(f"SELECT count(*) FROM fleet_vehicles v WHERE {where}", params) + total = cur.fetchone()[0] + + # Fetch + cur.execute(f""" + SELECT v.id, v.branch_id, v.plate, v.vin, v.make, v.model, v.year, + v.current_mileage, v.fuel_type, v.color, v.owner_name, + v.notes, v.is_active, v.created_at + FROM fleet_vehicles v + WHERE {where} + ORDER BY v.created_at DESC + LIMIT %s OFFSET %s + """, params + [per_page, (page - 1) * per_page]) + + vehicles = [] + for r in cur.fetchall(): + vehicles.append({ + 'id': r[0], 'branch_id': r[1], 'plate': r[2], 'vin': r[3], + 'make': r[4], 'model': r[5], 'year': r[6], + 'current_mileage': r[7], 'fuel_type': r[8], 'color': r[9], + 'owner_name': r[10], 'notes': r[11], 'is_active': r[12], + 'created_at': str(r[13]) if r[13] else None, + }) + + cur.close() + conn.close() + + total_pages = (total + per_page - 1) // per_page + return jsonify({ + 'data': vehicles, + 'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages} + }) + + +@fleet_bp.route('/vehicles/', methods=['GET']) +@require_auth() +def get_vehicle(vehicle_id): + """Vehicle detail with maintenance schedules and recent logs.""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + cur.execute(""" + SELECT id, branch_id, plate, vin, make, model, year, + current_mileage, fuel_type, color, owner_name, + notes, is_active, created_at + FROM fleet_vehicles WHERE id = %s + """, (vehicle_id,)) + row = cur.fetchone() + if not row: + cur.close(); conn.close() + return jsonify({'error': 'Vehicle not found'}), 404 + + vehicle = { + 'id': row[0], 'branch_id': row[1], 'plate': row[2], 'vin': row[3], + 'make': row[4], 'model': row[5], 'year': row[6], + 'current_mileage': row[7], 'fuel_type': row[8], 'color': row[9], + 'owner_name': row[10], 'notes': row[11], 'is_active': row[12], + 'created_at': str(row[13]) if row[13] else None, + } + + # Maintenance schedules + cur.execute(""" + SELECT id, maintenance_type, interval_km, interval_months, + last_done_at, last_done_km, next_due_at, next_due_km, + notes, is_active + FROM fleet_maintenance_schedules + WHERE vehicle_id = %s AND is_active = true + ORDER BY next_due_at NULLS LAST, next_due_km NULLS LAST + """, (vehicle_id,)) + vehicle['schedules'] = [] + for r in cur.fetchall(): + vehicle['schedules'].append({ + 'id': r[0], 'maintenance_type': r[1], 'interval_km': r[2], + 'interval_months': r[3], + 'last_done_at': str(r[4]) if r[4] else None, + 'last_done_km': r[5], + 'next_due_at': str(r[6]) if r[6] else None, + 'next_due_km': r[7], + 'notes': r[8], 'is_active': r[9], + }) + + # Recent logs (last 20) + cur.execute(""" + SELECT l.id, l.schedule_id, l.maintenance_type, l.mileage_at, + l.cost, l.parts_used, l.employee_id, l.notes, l.created_at, + e.name as employee_name + FROM fleet_maintenance_logs l + LEFT JOIN employees e ON l.employee_id = e.id + WHERE l.vehicle_id = %s + ORDER BY l.created_at DESC + LIMIT 20 + """, (vehicle_id,)) + vehicle['recent_logs'] = [] + for r in cur.fetchall(): + vehicle['recent_logs'].append({ + 'id': r[0], 'schedule_id': r[1], 'maintenance_type': r[2], + 'mileage_at': r[3], 'cost': float(r[4]) if r[4] else 0, + 'parts_used': r[5], 'employee_id': r[6], 'notes': r[7], + 'created_at': str(r[8]) if r[8] else None, + 'employee_name': r[9], + }) + + cur.close() + conn.close() + return jsonify(vehicle) + + +@fleet_bp.route('/vehicles', methods=['POST']) +@require_auth() +def create_vehicle(): + """Create a fleet vehicle. + + Body: {plate, vin, make, model, year, current_mileage, fuel_type, color, owner_name, notes} + """ + data = request.get_json() or {} + if not data.get('plate') and not data.get('vin'): + return jsonify({'error': 'plate or vin is required'}), 400 + + branch_id = data.get('branch_id', g.branch_id) + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + try: + cur.execute(""" + INSERT INTO fleet_vehicles + (branch_id, plate, vin, make, model, year, + current_mileage, fuel_type, color, owner_name, notes) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + RETURNING id + """, ( + branch_id, data.get('plate'), data.get('vin'), + data.get('make'), data.get('model'), data.get('year'), + data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'), + data.get('color'), data.get('owner_name'), data.get('notes'), + )) + vehicle_id = cur.fetchone()[0] + + log_action(conn, 'FLEET_VEHICLE_CREATE', 'fleet_vehicle', vehicle_id, + new_value={'plate': data.get('plate'), 'make': data.get('make'), + 'model': data.get('model')}) + + conn.commit() + cur.close(); conn.close() + return jsonify({'id': vehicle_id, 'message': 'Vehicle created'}), 201 + + except Exception as e: + conn.rollback() + cur.close(); conn.close() + return jsonify({'error': str(e)}), 500 + + +@fleet_bp.route('/vehicles/', methods=['PUT']) +@require_auth() +def update_vehicle(vehicle_id): + """Update vehicle fields including mileage. + + Body: any subset of {plate, vin, make, model, year, current_mileage, fuel_type, + color, owner_name, notes, is_active} + """ + data = request.get_json() or {} + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + cur.execute("SELECT id FROM fleet_vehicles WHERE id = %s", (vehicle_id,)) + if not cur.fetchone(): + cur.close(); conn.close() + return jsonify({'error': 'Vehicle not found'}), 404 + + allowed = ['plate', 'vin', 'make', 'model', 'year', 'current_mileage', + 'fuel_type', 'color', 'owner_name', 'notes', 'is_active', 'branch_id'] + sets = [] + vals = [] + for field in allowed: + if field in data: + sets.append(f"{field} = %s") + vals.append(data[field]) + + if not sets: + cur.close(); conn.close() + return jsonify({'error': 'No fields to update'}), 400 + + vals.append(vehicle_id) + try: + cur.execute(f"UPDATE fleet_vehicles SET {', '.join(sets)} WHERE id = %s", vals) + log_action(conn, 'FLEET_VEHICLE_UPDATE', 'fleet_vehicle', vehicle_id, + new_value=data) + conn.commit() + cur.close(); conn.close() + return jsonify({'message': 'Vehicle updated'}) + except Exception as e: + conn.rollback() + cur.close(); conn.close() + return jsonify({'error': str(e)}), 500 + + +@fleet_bp.route('/vehicles/', methods=['DELETE']) +@require_auth() +def deactivate_vehicle(vehicle_id): + """Soft-delete: set is_active = false.""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + cur.execute("SELECT id FROM fleet_vehicles WHERE id = %s", (vehicle_id,)) + if not cur.fetchone(): + cur.close(); conn.close() + return jsonify({'error': 'Vehicle not found'}), 404 + + cur.execute("UPDATE fleet_vehicles SET is_active = false WHERE id = %s", (vehicle_id,)) + log_action(conn, 'FLEET_VEHICLE_DEACTIVATE', 'fleet_vehicle', vehicle_id) + conn.commit() + cur.close(); conn.close() + return jsonify({'message': 'Vehicle deactivated'}) + + +# ─── Maintenance Schedules ───────────────────────────── + +@fleet_bp.route('/vehicles//schedules', methods=['GET']) +@require_auth() +def list_schedules(vehicle_id): + """Maintenance schedules for a vehicle.""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + cur.execute(""" + SELECT id, maintenance_type, interval_km, interval_months, + last_done_at, last_done_km, next_due_at, next_due_km, + notes, is_active + FROM fleet_maintenance_schedules + WHERE vehicle_id = %s AND is_active = true + ORDER BY next_due_at NULLS LAST, next_due_km NULLS LAST + """, (vehicle_id,)) + + schedules = [] + for r in cur.fetchall(): + schedules.append({ + 'id': r[0], 'maintenance_type': r[1], 'interval_km': r[2], + 'interval_months': r[3], + 'last_done_at': str(r[4]) if r[4] else None, + 'last_done_km': r[5], + 'next_due_at': str(r[6]) if r[6] else None, + 'next_due_km': r[7], + 'notes': r[8], 'is_active': r[9], + }) + + cur.close() + conn.close() + return jsonify({'data': schedules}) + + +@fleet_bp.route('/vehicles//schedules', methods=['POST']) +@require_auth() +def create_schedule(vehicle_id): + """Create maintenance schedule for a vehicle. + + Body: {maintenance_type, interval_km, interval_months, next_due_at, next_due_km, notes} + """ + data = request.get_json() or {} + if not data.get('maintenance_type'): + return jsonify({'error': 'maintenance_type is required'}), 400 + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + # Verify vehicle exists + cur.execute("SELECT id FROM fleet_vehicles WHERE id = %s", (vehicle_id,)) + if not cur.fetchone(): + cur.close(); conn.close() + return jsonify({'error': 'Vehicle not found'}), 404 + + try: + cur.execute(""" + INSERT INTO fleet_maintenance_schedules + (vehicle_id, maintenance_type, interval_km, interval_months, + next_due_at, next_due_km, notes) + VALUES (%s,%s,%s,%s,%s,%s,%s) + RETURNING id + """, ( + vehicle_id, data['maintenance_type'], + data.get('interval_km'), data.get('interval_months'), + data.get('next_due_at'), data.get('next_due_km'), + data.get('notes'), + )) + schedule_id = cur.fetchone()[0] + conn.commit() + cur.close(); conn.close() + return jsonify({'id': schedule_id, 'message': 'Schedule created'}), 201 + except Exception as e: + conn.rollback() + cur.close(); conn.close() + return jsonify({'error': str(e)}), 500 + + +# ─── Maintenance Logs ───────────────────────────── + +@fleet_bp.route('/vehicles//log', methods=['POST']) +@require_auth() +def record_maintenance(vehicle_id): + """Record maintenance done. Updates schedule next_due if schedule_id provided. + + Body: {schedule_id, maintenance_type, mileage_at, cost, parts_used, notes} + """ + data = request.get_json() or {} + if not data.get('maintenance_type'): + return jsonify({'error': 'maintenance_type is required'}), 400 + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + # Verify vehicle exists + cur.execute("SELECT id, current_mileage FROM fleet_vehicles WHERE id = %s", (vehicle_id,)) + veh = cur.fetchone() + if not veh: + cur.close(); conn.close() + return jsonify({'error': 'Vehicle not found'}), 404 + + mileage_at = data.get('mileage_at', veh[1] or 0) + schedule_id = data.get('schedule_id') + + try: + # Insert log + cur.execute(""" + INSERT INTO fleet_maintenance_logs + (vehicle_id, schedule_id, maintenance_type, mileage_at, + cost, parts_used, employee_id, notes) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s) + RETURNING id + """, ( + vehicle_id, schedule_id, data['maintenance_type'], + mileage_at, data.get('cost', 0), data.get('parts_used'), + getattr(g, 'employee_id', None), data.get('notes'), + )) + log_id = cur.fetchone()[0] + + # Update vehicle mileage if new mileage is higher + if mileage_at > (veh[1] or 0): + cur.execute("UPDATE fleet_vehicles SET current_mileage = %s WHERE id = %s", + (mileage_at, vehicle_id)) + + # Update schedule next_due if schedule_id provided + if schedule_id: + cur.execute(""" + SELECT interval_km, interval_months + FROM fleet_maintenance_schedules WHERE id = %s + """, (schedule_id,)) + sched = cur.fetchone() + if sched: + interval_km, interval_months = sched + now = datetime.utcnow() + + next_due_km = (mileage_at + interval_km) if interval_km else None + next_due_at = None + if interval_months: + next_due_at = now + timedelta(days=interval_months * 30) + + cur.execute(""" + UPDATE fleet_maintenance_schedules + SET last_done_at = %s, last_done_km = %s, + next_due_at = %s, next_due_km = %s + WHERE id = %s + """, (now, mileage_at, next_due_at, next_due_km, schedule_id)) + + log_action(conn, 'FLEET_MAINTENANCE_LOG', 'fleet_vehicle', vehicle_id, + new_value={'maintenance_type': data['maintenance_type'], + 'mileage_at': mileage_at, 'cost': data.get('cost', 0)}) + + conn.commit() + cur.close(); conn.close() + return jsonify({'id': log_id, 'message': 'Maintenance recorded'}), 201 + + except Exception as e: + conn.rollback() + cur.close(); conn.close() + return jsonify({'error': str(e)}), 500 + + +# ─── Alerts ───────────────────────────── + +@fleet_bp.route('/alerts', methods=['GET']) +@require_auth() +def fleet_alerts(): + """Vehicles with overdue maintenance (next_due_at < NOW() or next_due_km < current_mileage).""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + branch_filter = "" + params = [] + if g.branch_id: + branch_filter = "AND v.branch_id = %s" + params.append(g.branch_id) + + cur.execute(f""" + SELECT v.id, v.plate, v.make, v.model, v.year, v.current_mileage, + s.id as schedule_id, s.maintenance_type, + s.next_due_at, s.next_due_km + FROM fleet_maintenance_schedules s + JOIN fleet_vehicles v ON s.vehicle_id = v.id + WHERE s.is_active = true AND v.is_active = true + {branch_filter} + AND ( + (s.next_due_at IS NOT NULL AND s.next_due_at < NOW()) + OR (s.next_due_km IS NOT NULL AND s.next_due_km <= v.current_mileage) + ) + ORDER BY s.next_due_at NULLS LAST + """, params) + + alerts = [] + for r in cur.fetchall(): + alerts.append({ + 'vehicle_id': r[0], 'plate': r[1], 'make': r[2], 'model': r[3], + 'year': r[4], 'current_mileage': r[5], + 'schedule_id': r[6], 'maintenance_type': r[7], + 'next_due_at': str(r[8]) if r[8] else None, + 'next_due_km': r[9], + }) + + cur.close() + conn.close() + return jsonify({'data': alerts, 'total': len(alerts)}) + + +# ─── Stats ───────────────────────────── + +@fleet_bp.route('/stats', methods=['GET']) +@require_auth() +def fleet_stats(): + """Fleet summary: total vehicles, overdue count, upcoming this month, total cost this month.""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + branch_filter = "" + params = [] + if g.branch_id: + branch_filter = "AND v.branch_id = %s" + params.append(g.branch_id) + + # Total active vehicles + cur.execute(f""" + SELECT count(*) FROM fleet_vehicles v + WHERE v.is_active = true {branch_filter} + """, params) + total_vehicles = cur.fetchone()[0] + + # Overdue count + cur.execute(f""" + SELECT count(DISTINCT v.id) + FROM fleet_maintenance_schedules s + JOIN fleet_vehicles v ON s.vehicle_id = v.id + WHERE s.is_active = true AND v.is_active = true + {branch_filter} + AND ( + (s.next_due_at IS NOT NULL AND s.next_due_at < NOW()) + OR (s.next_due_km IS NOT NULL AND s.next_due_km <= v.current_mileage) + ) + """, params) + overdue_count = cur.fetchone()[0] + + # Upcoming this month + cur.execute(f""" + SELECT count(*) + FROM fleet_maintenance_schedules s + JOIN fleet_vehicles v ON s.vehicle_id = v.id + WHERE s.is_active = true AND v.is_active = true + {branch_filter} + AND s.next_due_at IS NOT NULL + AND s.next_due_at >= NOW() + AND s.next_due_at < date_trunc('month', NOW()) + interval '1 month' + """, params) + upcoming_this_month = cur.fetchone()[0] + + # Total cost this month + cur.execute(f""" + SELECT COALESCE(SUM(l.cost), 0) + FROM fleet_maintenance_logs l + JOIN fleet_vehicles v ON l.vehicle_id = v.id + WHERE v.is_active = true + {branch_filter} + AND l.created_at >= date_trunc('month', NOW()) + """, params) + total_cost_month = float(cur.fetchone()[0]) + + cur.close() + conn.close() + + return jsonify({ + 'total_vehicles': total_vehicles, + 'overdue_count': overdue_count, + 'upcoming_this_month': upcoming_this_month, + 'total_cost_month': total_cost_month, + }) diff --git a/pos/migrations/v1.3_fleet.sql b/pos/migrations/v1.3_fleet.sql new file mode 100644 index 0000000..124767c --- /dev/null +++ b/pos/migrations/v1.3_fleet.sql @@ -0,0 +1,50 @@ +-- v1.3 Fleet Management tables +-- Vehicles, maintenance schedules, and maintenance logs + +CREATE TABLE IF NOT EXISTS fleet_vehicles ( + id SERIAL PRIMARY KEY, + branch_id INTEGER REFERENCES branches(id), + plate VARCHAR(20), + vin VARCHAR(17), + make VARCHAR(100), + model VARCHAR(100), + year INTEGER, + current_mileage INTEGER DEFAULT 0, + fuel_type VARCHAR(20) DEFAULT 'gasolina', + color VARCHAR(50), + owner_name VARCHAR(200), + notes TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS fleet_maintenance_schedules ( + id SERIAL PRIMARY KEY, + vehicle_id INTEGER REFERENCES fleet_vehicles(id), + maintenance_type VARCHAR(100) NOT NULL, + interval_km INTEGER, + interval_months INTEGER, + last_done_at TIMESTAMPTZ, + last_done_km INTEGER, + next_due_at TIMESTAMPTZ, + next_due_km INTEGER, + notes TEXT, + is_active BOOLEAN DEFAULT TRUE +); + +CREATE TABLE IF NOT EXISTS fleet_maintenance_logs ( + id SERIAL PRIMARY KEY, + vehicle_id INTEGER REFERENCES fleet_vehicles(id), + schedule_id INTEGER REFERENCES fleet_maintenance_schedules(id), + maintenance_type VARCHAR(100), + mileage_at INTEGER, + cost NUMERIC(12,2), + parts_used TEXT, + employee_id INTEGER REFERENCES employees(id), + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_fleet_vehicles_plate ON fleet_vehicles(plate); +CREATE INDEX IF NOT EXISTS idx_fleet_maint_vehicle ON fleet_maintenance_schedules(vehicle_id); +CREATE INDEX IF NOT EXISTS idx_fleet_logs_vehicle ON fleet_maintenance_logs(vehicle_id); diff --git a/pos/static/js/fleet.js b/pos/static/js/fleet.js new file mode 100644 index 0000000..48155c2 --- /dev/null +++ b/pos/static/js/fleet.js @@ -0,0 +1,609 @@ +/** + * fleet.js — Fleet Management module for Nexus POS + * + * Handles vehicle CRUD, maintenance schedules, logs, and alerts. + */ +var Fleet = (function() { + 'use strict'; + + var API = '/pos/api/fleet'; + var token = localStorage.getItem('pos_token'); + var vehicles = []; + var currentPage = 1; + var searchTimeout = null; + + // ─── Helpers ─── + + function headers() { + return { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + token + }; + } + + function fmt(n) { + if (n == null) return '0'; + return parseFloat(n).toLocaleString('es-MX'); + } + + function fmtMoney(n) { + if (n == null) return '$0.00'; + return '$' + parseFloat(n).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2}); + } + + function fmtDate(d) { + if (!d) return '—'; + var dt = new Date(d); + return dt.toLocaleDateString('es-MX', {day: '2-digit', month: 'short', year: 'numeric'}); + } + + function esc(s) { + if (!s) return ''; + var d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + } + + // ─── Init ─── + + function init() { + // Tab switching + document.querySelectorAll('.tab-btn').forEach(function(btn) { + btn.addEventListener('click', function() { + var tab = this.getAttribute('data-tab'); + document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('is-active'); }); + document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('is-active'); }); + this.classList.add('is-active'); + document.getElementById('tab-' + tab).classList.add('is-active'); + + if (tab === 'maintenance') loadMaintenance(); + if (tab === 'history') loadHistory(); + if (tab === 'alerts') loadAlerts(); + }); + }); + + // Search + var searchInput = document.getElementById('searchInput'); + if (searchInput) { + searchInput.addEventListener('input', function() { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(function() { + currentPage = 1; + loadVehicles(); + }, 300); + }); + } + + // New vehicle button + document.getElementById('btnNewVehicle').addEventListener('click', function() { + openVehicleModal(); + }); + + // Load initial data + loadStats(); + loadVehicles(); + } + + // ─── Stats ─── + + function loadStats() { + fetch(API + '/stats', {headers: headers()}) + .then(function(r) { return r.json(); }) + .then(function(d) { + document.getElementById('statTotal').textContent = fmt(d.total_vehicles); + document.getElementById('statOverdue').textContent = fmt(d.overdue_count); + document.getElementById('statUpcoming').textContent = fmt(d.upcoming_this_month); + document.getElementById('statCost').textContent = fmtMoney(d.total_cost_month); + }) + .catch(function() {}); + } + + // ─── Vehicles Tab ─── + + function loadVehicles() { + var q = (document.getElementById('searchInput') || {}).value || ''; + var url = API + '/vehicles?page=' + currentPage + '&per_page=50'; + if (q) url += '&q=' + encodeURIComponent(q); + + fetch(url, {headers: headers()}) + .then(function(r) { return r.json(); }) + .then(function(d) { + vehicles = d.data || []; + renderVehicleGrid(vehicles); + renderPagination(d.pagination); + populateVehicleSelects(vehicles); + }) + .catch(function() { + document.getElementById('vehicleGrid').innerHTML = + '
Error al cargar vehiculos
'; + }); + } + + function renderVehicleGrid(list) { + var grid = document.getElementById('vehicleGrid'); + if (!list.length) { + grid.innerHTML = '
' + + '
🚚
' + + '
No hay vehiculos registrados
' + + '' + + '
'; + return; + } + + var html = ''; + list.forEach(function(v) { + var label = (v.make || '') + ' ' + (v.model || ''); + if (v.year) label += ' ' + v.year; + + html += '
' + + '
' + + '' + esc(v.plate || 'SIN PLACA') + '' + + '' + + (v.is_active ? 'Activo' : 'Inactivo') + '' + + '
' + + '
' + + '
' + esc(label.trim()) + '
' + + (v.owner_name ? '
' + esc(v.owner_name) + '
' : '') + + (v.color ? '
Color: ' + esc(v.color) + '
' : '') + + '
' + + '' + + '
'; + }); + grid.innerHTML = html; + } + + function renderPagination(p) { + var el = document.getElementById('vehiclePagination'); + if (!p || p.total_pages <= 1) { el.innerHTML = ''; return; } + + el.innerHTML = '' + + 'Pagina ' + p.page + ' de ' + p.total_pages + + ' (' + p.total + ' vehiculos)' + + ''; + } + + function goPage(n) { + currentPage = n; + loadVehicles(); + } + + function populateVehicleSelects(list) { + var opts = ''; + list.forEach(function(v) { + var label = (v.plate || 'S/P') + ' - ' + (v.make || '') + ' ' + (v.model || ''); + opts += ''; + }); + var selects = ['schedVehicleSelect', 'logVehicleSelect']; + selects.forEach(function(id) { + var el = document.getElementById(id); + if (el) el.innerHTML = opts; + }); + } + + // ─── Vehicle Detail (view in edit modal) ─── + + function viewVehicle(id) { + fetch(API + '/vehicles/' + id, {headers: headers()}) + .then(function(r) { return r.json(); }) + .then(function(v) { + openVehicleModal(v); + }); + } + + // ─── Vehicle Modal ─── + + function openVehicleModal(data) { + var modal = document.getElementById('vehicleModal'); + var title = document.getElementById('vehicleModalTitle'); + + if (data && data.id) { + title.textContent = 'Editar Vehiculo'; + document.getElementById('vehEditId').value = data.id; + document.getElementById('vehPlate').value = data.plate || ''; + document.getElementById('vehVin').value = data.vin || ''; + document.getElementById('vehMake').value = data.make || ''; + document.getElementById('vehModel').value = data.model || ''; + document.getElementById('vehYear').value = data.year || ''; + document.getElementById('vehMileage').value = data.current_mileage || 0; + document.getElementById('vehFuel').value = data.fuel_type || 'gasolina'; + document.getElementById('vehColor').value = data.color || ''; + document.getElementById('vehOwner').value = data.owner_name || ''; + document.getElementById('vehNotes').value = data.notes || ''; + } else { + title.textContent = 'Nuevo Vehiculo'; + document.getElementById('vehEditId').value = ''; + ['vehPlate','vehVin','vehMake','vehModel','vehYear','vehColor','vehOwner','vehNotes'] + .forEach(function(id) { document.getElementById(id).value = ''; }); + document.getElementById('vehMileage').value = '0'; + document.getElementById('vehFuel').value = 'gasolina'; + } + + modal.classList.add('is-open'); + } + + function closeVehicleModal() { + document.getElementById('vehicleModal').classList.remove('is-open'); + } + + function saveVehicle() { + var editId = document.getElementById('vehEditId').value; + var payload = { + plate: document.getElementById('vehPlate').value.trim(), + vin: document.getElementById('vehVin').value.trim(), + make: document.getElementById('vehMake').value.trim(), + model: document.getElementById('vehModel').value.trim(), + year: parseInt(document.getElementById('vehYear').value) || null, + current_mileage: parseInt(document.getElementById('vehMileage').value) || 0, + fuel_type: document.getElementById('vehFuel').value, + color: document.getElementById('vehColor').value.trim(), + owner_name: document.getElementById('vehOwner').value.trim(), + notes: document.getElementById('vehNotes').value.trim(), + }; + + if (!payload.plate && !payload.vin) { + alert('Se requiere placa o VIN'); + return; + } + + var url = API + '/vehicles'; + var method = 'POST'; + if (editId) { + url += '/' + editId; + method = 'PUT'; + } + + fetch(url, { + method: method, + headers: headers(), + body: JSON.stringify(payload) + }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.error) { alert(d.error); return; } + closeVehicleModal(); + loadVehicles(); + loadStats(); + }) + .catch(function(e) { alert('Error: ' + e.message); }); + } + + // ─── Maintenance Tab ─── + + function loadMaintenance() { + // Load all vehicles with their schedules + fetch(API + '/vehicles?per_page=200', {headers: headers()}) + .then(function(r) { return r.json(); }) + .then(function(d) { + var allVehicles = d.data || []; + var promises = allVehicles.map(function(v) { + return fetch(API + '/vehicles/' + v.id + '/schedules', {headers: headers()}) + .then(function(r) { return r.json(); }) + .then(function(s) { + return {vehicle: v, schedules: s.data || []}; + }); + }); + return Promise.all(promises); + }) + .then(function(results) { + renderMaintenance(results); + }) + .catch(function() { + document.getElementById('maintBody').innerHTML = + 'Error al cargar'; + }); + } + + function renderMaintenance(results) { + var body = document.getElementById('maintBody'); + var rows = []; + + results.forEach(function(r) { + var v = r.vehicle; + r.schedules.forEach(function(s) { + var now = new Date(); + var isOverdue = false; + if (s.next_due_at && new Date(s.next_due_at) < now) isOverdue = true; + if (s.next_due_km && s.next_due_km <= v.current_mileage) isOverdue = true; + + var interval = ''; + if (s.interval_km) interval += fmt(s.interval_km) + ' km'; + if (s.interval_km && s.interval_months) interval += ' / '; + if (s.interval_months) interval += s.interval_months + ' meses'; + + var next = ''; + if (s.next_due_at) next += fmtDate(s.next_due_at); + if (s.next_due_at && s.next_due_km) next += ' / '; + if (s.next_due_km) next += fmt(s.next_due_km) + ' km'; + + rows.push( + '' + + '' + esc(v.plate || 'S/P') + '
' + + '' + + esc((v.make || '') + ' ' + (v.model || '')) + '' + + '' + esc(s.maintenance_type) + '' + + '' + (interval || '—') + '' + + '' + fmtDate(s.last_done_at) + (s.last_done_km ? '
' + fmt(s.last_done_km) + ' km' : '') + '' + + '' + (next || '—') + '' + + '' + + (isOverdue ? 'Vencido' : 'Al dia') + '' + + '' + + '' + ); + }); + }); + + if (!rows.length) { + body.innerHTML = '' + + 'No hay programas de mantenimiento.
'; + return; + } + + body.innerHTML = rows.join(''); + } + + // ─── History Tab ─── + + function loadHistory() { + fetch(API + '/vehicles?per_page=200', {headers: headers()}) + .then(function(r) { return r.json(); }) + .then(function(d) { + var allVehicles = d.data || []; + var promises = allVehicles.map(function(v) { + return fetch(API + '/vehicles/' + v.id, {headers: headers()}) + .then(function(r) { return r.json(); }) + .then(function(detail) { + return {vehicle: v, logs: detail.recent_logs || []}; + }); + }); + return Promise.all(promises); + }) + .then(function(results) { + renderHistory(results); + }) + .catch(function() { + document.getElementById('historyBody').innerHTML = + 'Error al cargar'; + }); + } + + function renderHistory(results) { + var body = document.getElementById('historyBody'); + var allLogs = []; + + results.forEach(function(r) { + r.logs.forEach(function(l) { + l._plate = r.vehicle.plate || 'S/P'; + l._make = (r.vehicle.make || '') + ' ' + (r.vehicle.model || ''); + allLogs.push(l); + }); + }); + + // Sort by date desc + allLogs.sort(function(a, b) { + return new Date(b.created_at) - new Date(a.created_at); + }); + + if (!allLogs.length) { + body.innerHTML = 'No hay registros de mantenimiento'; + return; + } + + var html = ''; + allLogs.forEach(function(l) { + html += '' + + '' + fmtDate(l.created_at) + '' + + '' + esc(l._plate) + '
' + + '' + esc(l._make) + '' + + '' + esc(l.maintenance_type) + '' + + '' + fmt(l.mileage_at) + '' + + '' + fmtMoney(l.cost) + '' + + '' + esc(l.employee_name || '—') + '' + + '' + esc(l.notes || '—') + '' + + ''; + }); + body.innerHTML = html; + } + + // ─── Alerts Tab ─── + + function loadAlerts() { + fetch(API + '/alerts', {headers: headers()}) + .then(function(r) { return r.json(); }) + .then(function(d) { + renderAlerts(d.data || []); + }) + .catch(function() { + document.getElementById('alertsList').innerHTML = + '
Error al cargar alertas
'; + }); + } + + function renderAlerts(list) { + var el = document.getElementById('alertsList'); + if (!list.length) { + el.innerHTML = '
' + + '
' + + '
No hay mantenimientos vencidos
' + + '
'; + return; + } + + var html = ''; + list.forEach(function(a) { + var detail = ''; + if (a.next_due_at) detail += 'Vencio: ' + fmtDate(a.next_due_at); + if (a.next_due_km) detail += (detail ? ' | ' : '') + 'Limite: ' + fmt(a.next_due_km) + ' km (actual: ' + fmt(a.current_mileage) + ' km)'; + + html += '
' + + '
' + + '
' + + esc(a.plate || 'S/P') + ' — ' + esc((a.make || '') + ' ' + (a.model || '')) + + (a.year ? ' ' + a.year : '') + + '
' + + '
' + + '' + esc(a.maintenance_type) + '' + + '   ' + detail + + '
' + + '
' + + '' + + '
'; + }); + el.innerHTML = html; + } + + // ─── Schedule Modal ─── + + function openScheduleModal(vehicleId) { + var modal = document.getElementById('scheduleModal'); + document.getElementById('schedType').value = ''; + document.getElementById('schedIntervalKm').value = ''; + document.getElementById('schedIntervalMonths').value = ''; + document.getElementById('schedNextDate').value = ''; + document.getElementById('schedNextKm').value = ''; + document.getElementById('schedNotes').value = ''; + + if (vehicleId) { + document.getElementById('schedVehicleSelect').value = vehicleId; + } + + modal.classList.add('is-open'); + } + + function closeScheduleModal() { + document.getElementById('scheduleModal').classList.remove('is-open'); + } + + function saveSchedule() { + var vehicleId = document.getElementById('schedVehicleSelect').value; + if (!vehicleId) { alert('Seleccione un vehiculo'); return; } + + var mType = document.getElementById('schedType').value.trim(); + if (!mType) { alert('Ingrese el tipo de mantenimiento'); return; } + + var payload = { + maintenance_type: mType, + interval_km: parseInt(document.getElementById('schedIntervalKm').value) || null, + interval_months: parseInt(document.getElementById('schedIntervalMonths').value) || null, + next_due_at: document.getElementById('schedNextDate').value || null, + next_due_km: parseInt(document.getElementById('schedNextKm').value) || null, + notes: document.getElementById('schedNotes').value.trim(), + }; + + fetch(API + '/vehicles/' + vehicleId + '/schedules', { + method: 'POST', + headers: headers(), + body: JSON.stringify(payload) + }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.error) { alert(d.error); return; } + closeScheduleModal(); + loadMaintenance(); + loadStats(); + }) + .catch(function(e) { alert('Error: ' + e.message); }); + } + + // ─── Log Modal ─── + + function openLogModal() { + var modal = document.getElementById('logModal'); + document.getElementById('logScheduleSelect').innerHTML = ''; + document.getElementById('logType').value = ''; + document.getElementById('logMileage').value = ''; + document.getElementById('logCost').value = ''; + document.getElementById('logParts').value = ''; + document.getElementById('logNotes').value = ''; + modal.classList.add('is-open'); + } + + function openLogModalFor(vehicleId, scheduleId, maintType) { + openLogModal(); + document.getElementById('logVehicleSelect').value = vehicleId; + if (maintType) document.getElementById('logType').value = maintType; + + // Load schedules for this vehicle + fetch(API + '/vehicles/' + vehicleId + '/schedules', {headers: headers()}) + .then(function(r) { return r.json(); }) + .then(function(d) { + var opts = ''; + (d.data || []).forEach(function(s) { + var sel = (s.id == scheduleId) ? ' selected' : ''; + opts += ''; + }); + document.getElementById('logScheduleSelect').innerHTML = opts; + }); + + // Pre-fill mileage + var veh = vehicles.find(function(v) { return v.id == vehicleId; }); + if (veh) document.getElementById('logMileage').value = veh.current_mileage || ''; + } + + function closeLogModal() { + document.getElementById('logModal').classList.remove('is-open'); + } + + function saveLog() { + var vehicleId = document.getElementById('logVehicleSelect').value; + if (!vehicleId) { alert('Seleccione un vehiculo'); return; } + + var mType = document.getElementById('logType').value.trim(); + if (!mType) { alert('Ingrese el tipo de mantenimiento'); return; } + + var payload = { + schedule_id: parseInt(document.getElementById('logScheduleSelect').value) || null, + maintenance_type: mType, + mileage_at: parseInt(document.getElementById('logMileage').value) || null, + cost: parseFloat(document.getElementById('logCost').value) || 0, + parts_used: document.getElementById('logParts').value.trim(), + notes: document.getElementById('logNotes').value.trim(), + }; + + fetch(API + '/vehicles/' + vehicleId + '/log', { + method: 'POST', + headers: headers(), + body: JSON.stringify(payload) + }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.error) { alert(d.error); return; } + closeLogModal(); + loadStats(); + // Refresh current tab + var activeTab = document.querySelector('.tab-btn.is-active'); + if (activeTab) { + var tab = activeTab.getAttribute('data-tab'); + if (tab === 'maintenance') loadMaintenance(); + if (tab === 'history') loadHistory(); + if (tab === 'alerts') loadAlerts(); + if (tab === 'vehicles') loadVehicles(); + } + }) + .catch(function(e) { alert('Error: ' + e.message); }); + } + + // ─── Public API ─── + + document.addEventListener('DOMContentLoaded', init); + + return { + openVehicleModal: openVehicleModal, + closeVehicleModal: closeVehicleModal, + saveVehicle: saveVehicle, + viewVehicle: viewVehicle, + goPage: goPage, + openScheduleModal: openScheduleModal, + closeScheduleModal: closeScheduleModal, + saveSchedule: saveSchedule, + openLogModal: openLogModal, + openLogModalFor: openLogModalFor, + closeLogModal: closeLogModal, + saveLog: saveLog, + }; + +})(); diff --git a/pos/templates/fleet.html b/pos/templates/fleet.html new file mode 100644 index 0000000..c947d85 --- /dev/null +++ b/pos/templates/fleet.html @@ -0,0 +1,972 @@ + + + + + + + Flotillas — Nexus Autoparts POS + + + + + + + + +
+ + + +
+ +
+ + + +
+ +
+
+ Vehiculos Activos + -- +
+
+ Mant. Vencidos + -- +
+
+ Prox. Este Mes + -- +
+
+ Costo Mes + -- +
+
+ + +
+ + + + +
+ + +
+
+
+
🚚
+
Cargando vehiculos...
+
+
+ +
+ + +
+
+

Programas de Mantenimiento

+
+ + + + + + + + + + + + + + + +
VehiculoTipoIntervaloUltimoProximoEstado
Cargando...
+
+ + +
+ + + + + + + + + + + + + + + +
FechaVehiculoTipoKmCostoEmpleadoNotas
Cargando...
+
+ + +
+
+
+
Cargando alertas...
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + +