feat(pos): fleet management module — vehicles, maintenance schedules, alerts

Add full fleet management (Feature 13): database migration for fleet_vehicles,
fleet_maintenance_schedules, and fleet_maintenance_logs tables; REST API blueprint
with CRUD, scheduling, logging, alerts, and stats endpoints; frontend with tabbed
UI (vehicles grid, maintenance schedules, history, overdue alerts); sidebar nav entry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 02:43:26 +00:00
parent c645bc03f3
commit db5bbf6718
4 changed files with 2171 additions and 0 deletions

540
pos/blueprints/fleet_bp.py Normal file
View File

@@ -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/<int:vehicle_id>', 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/<int:vehicle_id>', 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/<int:vehicle_id>', 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/<int:vehicle_id>/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/<int:vehicle_id>/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/<int:vehicle_id>/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,
})

View File

@@ -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);

609
pos/static/js/fleet.js Normal file
View File

@@ -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 =
'<div class="empty-state"><div class="empty-state__text">Error al cargar vehiculos</div></div>';
});
}
function renderVehicleGrid(list) {
var grid = document.getElementById('vehicleGrid');
if (!list.length) {
grid.innerHTML = '<div class="empty-state">' +
'<div class="empty-state__icon">&#x1F69A;</div>' +
'<div class="empty-state__text">No hay vehiculos registrados</div>' +
'<button class="btn btn--primary" onclick="Fleet.openVehicleModal()">+ Agregar Vehiculo</button>' +
'</div>';
return;
}
var html = '';
list.forEach(function(v) {
var label = (v.make || '') + ' ' + (v.model || '');
if (v.year) label += ' ' + v.year;
html += '<div class="vehicle-card" onclick="Fleet.viewVehicle(' + v.id + ')">' +
'<div class="vehicle-card__header">' +
'<span class="vehicle-card__plate">' + esc(v.plate || 'SIN PLACA') + '</span>' +
'<span class="badge ' + (v.is_active ? 'badge--active' : 'badge--inactive') + '">' +
(v.is_active ? 'Activo' : 'Inactivo') + '</span>' +
'</div>' +
'<div class="vehicle-card__info">' +
'<div><strong>' + esc(label.trim()) + '</strong></div>' +
(v.owner_name ? '<div>' + esc(v.owner_name) + '</div>' : '') +
(v.color ? '<div>Color: ' + esc(v.color) + '</div>' : '') +
'</div>' +
'<div class="vehicle-card__footer">' +
'<span>' + fmt(v.current_mileage) + ' km</span>' +
'<span>' + esc(v.fuel_type || '') + '</span>' +
'</div>' +
'</div>';
});
grid.innerHTML = html;
}
function renderPagination(p) {
var el = document.getElementById('vehiclePagination');
if (!p || p.total_pages <= 1) { el.innerHTML = ''; return; }
el.innerHTML = '<button class="pagination__btn" ' + (p.page <= 1 ? 'disabled' : '') +
' onclick="Fleet.goPage(' + (p.page - 1) + ')">&laquo; Anterior</button>' +
'<span class="pagination__info">Pagina ' + p.page + ' de ' + p.total_pages +
' (' + p.total + ' vehiculos)</span>' +
'<button class="pagination__btn" ' + (p.page >= p.total_pages ? 'disabled' : '') +
' onclick="Fleet.goPage(' + (p.page + 1) + ')">Siguiente &raquo;</button>';
}
function goPage(n) {
currentPage = n;
loadVehicles();
}
function populateVehicleSelects(list) {
var opts = '<option value="">-- Seleccionar vehiculo --</option>';
list.forEach(function(v) {
var label = (v.plate || 'S/P') + ' - ' + (v.make || '') + ' ' + (v.model || '');
opts += '<option value="' + v.id + '">' + esc(label.trim()) + '</option>';
});
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 =
'<tr><td colspan="7" style="text-align:center;color:var(--color-text-muted);">Error al cargar</td></tr>';
});
}
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(
'<tr>' +
'<td><strong>' + esc(v.plate || 'S/P') + '</strong><br>' +
'<span style="font-size:var(--text-caption);color:var(--color-text-muted);">' +
esc((v.make || '') + ' ' + (v.model || '')) + '</span></td>' +
'<td>' + esc(s.maintenance_type) + '</td>' +
'<td class="mono">' + (interval || '—') + '</td>' +
'<td>' + fmtDate(s.last_done_at) + (s.last_done_km ? '<br><span class="mono" style="font-size:var(--text-caption);">' + fmt(s.last_done_km) + ' km</span>' : '') + '</td>' +
'<td>' + (next || '—') + '</td>' +
'<td><span class="badge ' + (isOverdue ? 'badge--overdue' : 'badge--active') + '">' +
(isOverdue ? 'Vencido' : 'Al dia') + '</span></td>' +
'<td><button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button></td>' +
'</tr>'
);
});
});
if (!rows.length) {
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">' +
'No hay programas de mantenimiento.<br><button class="btn btn--primary btn--sm" style="margin-top:var(--space-3);" onclick="Fleet.openScheduleModal()">+ Crear Programa</button></td></tr>';
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 =
'<tr><td colspan="7" style="text-align:center;color:var(--color-text-muted);">Error al cargar</td></tr>';
});
}
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 = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay registros de mantenimiento</td></tr>';
return;
}
var html = '';
allLogs.forEach(function(l) {
html += '<tr>' +
'<td>' + fmtDate(l.created_at) + '</td>' +
'<td><strong>' + esc(l._plate) + '</strong><br>' +
'<span style="font-size:var(--text-caption);color:var(--color-text-muted);">' + esc(l._make) + '</span></td>' +
'<td>' + esc(l.maintenance_type) + '</td>' +
'<td class="mono">' + fmt(l.mileage_at) + '</td>' +
'<td class="mono">' + fmtMoney(l.cost) + '</td>' +
'<td>' + esc(l.employee_name || '—') + '</td>' +
'<td style="max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + esc(l.notes || '—') + '</td>' +
'</tr>';
});
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 =
'<div class="empty-state"><div class="empty-state__text">Error al cargar alertas</div></div>';
});
}
function renderAlerts(list) {
var el = document.getElementById('alertsList');
if (!list.length) {
el.innerHTML = '<div class="empty-state">' +
'<div class="empty-state__icon" style="color:#3FB950;">&#10003;</div>' +
'<div class="empty-state__text">No hay mantenimientos vencidos</div>' +
'</div>';
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 += '<div class="alert-card">' +
'<div class="alert-card__info">' +
'<div class="alert-card__vehicle">' +
esc(a.plate || 'S/P') + ' — ' + esc((a.make || '') + ' ' + (a.model || '')) +
(a.year ? ' ' + a.year : '') +
'</div>' +
'<div class="alert-card__detail">' +
'<span class="badge badge--overdue">' + esc(a.maintenance_type) + '</span>' +
' &nbsp; ' + detail +
'</div>' +
'</div>' +
'<button class="btn btn--sm btn--primary" onclick="Fleet.openLogModalFor(' + a.vehicle_id + ',' + a.schedule_id + ',\'' + esc(a.maintenance_type) + '\')">Registrar Mant.</button>' +
'</div>';
});
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 = '<option value="">-- Sin programa --</option>';
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 = '<option value="">-- Sin programa --</option>';
(d.data || []).forEach(function(s) {
var sel = (s.id == scheduleId) ? ' selected' : '';
opts += '<option value="' + s.id + '"' + sel + '>' + esc(s.maintenance_type) + '</option>';
});
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,
};
})();

972
pos/templates/fleet.html Normal file
View File

@@ -0,0 +1,972 @@
<!DOCTYPE html>
<html lang="es" data-theme="industrial">
<head>
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flotillas — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<style>
/* =========================================================================
BASE RESET & SHELL
========================================================================= */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
}
body {
font-family: var(--font-body);
font-size: var(--text-body-sm);
color: var(--color-text-primary);
background-color: var(--color-bg-base);
transition: background-color var(--duration-normal) var(--ease-in-out),
color var(--duration-normal) var(--ease-in-out);
overflow: hidden;
}
[data-theme="modern"] body {
background-image: radial-gradient(
circle,
var(--dot-grid-color) 1px,
transparent 1px
);
background-size: var(--dot-grid-size) var(--dot-grid-size);
}
/* =========================================================================
LAYOUT
========================================================================= */
.page-shell {
display: flex;
height: 100vh;
overflow: hidden;
}
.main-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-6);
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-elevated);
flex-shrink: 0;
gap: var(--space-4);
}
.page-header h1 {
font-family: var(--font-heading);
font-size: var(--text-h3);
font-weight: var(--font-weight-bold);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-text-primary);
white-space: nowrap;
}
.page-header__actions {
display: flex;
gap: var(--space-3);
align-items: center;
}
.page-content {
flex: 1;
overflow-y: auto;
padding: var(--space-5) var(--space-6);
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb, #444) var(--scrollbar-track, #222);
}
/* =========================================================================
STATS ROW
========================================================================= */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-4);
margin-bottom: var(--space-5);
}
.stat-card {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
padding: var(--space-4) var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
[data-theme="industrial"] .stat-card {
clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 0 100%);
}
[data-theme="modern"] .stat-card {
border-radius: var(--radius-lg);
}
.stat-card__label {
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
letter-spacing: var(--tracking-widest);
text-transform: uppercase;
color: var(--color-text-muted);
}
.stat-card__value {
font-family: var(--font-heading);
font-size: var(--text-h2);
font-weight: var(--font-weight-extrabold);
color: var(--color-text-primary);
}
.stat-card__value--warn {
color: var(--color-warning, #E3B341);
}
.stat-card__value--danger {
color: var(--color-error, #F85149);
}
/* =========================================================================
TABS
========================================================================= */
.tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-border);
margin-bottom: var(--space-5);
flex-shrink: 0;
}
.tab-btn {
padding: var(--space-3) var(--space-5);
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
cursor: pointer;
font-family: var(--font-body);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-semibold);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-text-muted);
transition: all var(--duration-fast) var(--ease-in-out);
}
.tab-btn:hover {
color: var(--color-text-primary);
}
.tab-btn.is-active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.tab-panel {
display: none;
}
.tab-panel.is-active {
display: block;
}
/* =========================================================================
TABLE
========================================================================= */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-body-sm);
}
.data-table th {
text-align: left;
padding: var(--space-3) var(--space-4);
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
letter-spacing: var(--tracking-widest);
text-transform: uppercase;
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-2);
white-space: nowrap;
}
.data-table td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
color: var(--color-text-secondary);
vertical-align: middle;
}
.data-table tr:hover td {
background: var(--color-surface-2);
}
.data-table .mono {
font-family: var(--font-mono);
font-size: 0.8125rem;
}
/* =========================================================================
BADGES
========================================================================= */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 2px var(--space-2);
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
border: 1px solid;
}
[data-theme="modern"] .badge {
border-radius: var(--radius-full);
}
.badge--active {
background: rgba(63, 185, 80, 0.15);
border-color: rgba(63, 185, 80, 0.3);
color: #3FB950;
}
.badge--inactive {
background: rgba(139, 148, 158, 0.15);
border-color: rgba(139, 148, 158, 0.3);
color: #8B949E;
}
.badge--overdue {
background: rgba(248, 81, 73, 0.15);
border-color: rgba(248, 81, 73, 0.3);
color: #F85149;
}
.badge--upcoming {
background: rgba(227, 179, 65, 0.15);
border-color: rgba(227, 179, 65, 0.3);
color: #E3B341;
}
/* =========================================================================
BUTTONS
========================================================================= */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
font-family: var(--font-body);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-semibold);
letter-spacing: var(--tracking-wide);
border: 1px solid var(--color-border);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-in-out);
white-space: nowrap;
background: var(--color-surface-2);
color: var(--color-text-primary);
}
[data-theme="modern"] .btn {
border-radius: var(--radius-md);
}
.btn--primary {
background: var(--color-primary);
color: var(--color-text-inverse, #000);
border-color: var(--color-primary);
}
.btn--primary:hover {
opacity: 0.85;
}
.btn--ghost {
background: none;
border-color: transparent;
color: var(--color-text-secondary);
}
.btn--ghost:hover {
color: var(--color-text-primary);
background: var(--color-surface-2);
}
.btn--sm {
padding: var(--space-1) var(--space-3);
font-size: var(--text-caption);
}
.btn--danger {
background: rgba(248, 81, 73, 0.15);
border-color: rgba(248, 81, 73, 0.3);
color: #F85149;
}
.btn--danger:hover {
background: rgba(248, 81, 73, 0.25);
}
/* =========================================================================
SEARCH BAR
========================================================================= */
.search-input {
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border);
background: var(--color-surface-2);
color: var(--color-text-primary);
font-family: var(--font-body);
font-size: var(--text-body-sm);
width: 280px;
transition: border-color var(--duration-fast);
}
[data-theme="modern"] .search-input {
border-radius: var(--radius-md);
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
}
.search-input::placeholder {
color: var(--color-text-muted);
}
/* =========================================================================
VEHICLE GRID (alternative view)
========================================================================= */
.vehicle-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--space-4);
}
.vehicle-card {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
padding: var(--space-4) var(--space-5);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-in-out);
}
[data-theme="industrial"] .vehicle-card {
clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%);
}
[data-theme="modern"] .vehicle-card {
border-radius: var(--radius-lg);
}
.vehicle-card:hover {
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary);
}
.vehicle-card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-3);
}
.vehicle-card__plate {
font-family: var(--font-heading);
font-size: var(--text-h4);
font-weight: var(--font-weight-extrabold);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--color-primary);
}
.vehicle-card__info {
font-size: var(--text-body-sm);
color: var(--color-text-secondary);
line-height: 1.6;
}
.vehicle-card__info strong {
color: var(--color-text-primary);
}
.vehicle-card__footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
font-size: var(--text-caption);
color: var(--color-text-muted);
}
/* =========================================================================
MODAL
========================================================================= */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 500;
align-items: center;
justify-content: center;
}
.modal-overlay.is-open {
display: flex;
}
.modal {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
width: 90%;
max-width: 560px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
[data-theme="modern"] .modal {
border-radius: var(--radius-lg);
}
.modal__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4) var(--space-6);
border-bottom: 1px solid var(--color-border);
}
.modal__title {
font-family: var(--font-heading);
font-size: var(--text-h4);
font-weight: var(--font-weight-bold);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.modal__close {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1.25rem;
padding: var(--space-1);
}
.modal__close:hover {
color: var(--color-text-primary);
}
.modal__body {
padding: var(--space-5) var(--space-6);
overflow-y: auto;
flex: 1;
}
.modal__footer {
padding: var(--space-4) var(--space-6);
border-top: 1px solid var(--color-border);
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
/* =========================================================================
FORM
========================================================================= */
.form-group {
margin-bottom: var(--space-4);
}
.form-group label {
display: block;
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
letter-spacing: var(--tracking-widest);
text-transform: uppercase;
color: var(--color-text-muted);
margin-bottom: var(--space-1);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
background: var(--color-surface-2);
color: var(--color-text-primary);
font-family: var(--font-body);
font-size: var(--text-body-sm);
}
[data-theme="modern"] .form-group input,
[data-theme="modern"] .form-group select,
[data-theme="modern"] .form-group textarea {
border-radius: var(--radius-md);
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-primary);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
/* =========================================================================
ALERT CARD
========================================================================= */
.alert-card {
background: var(--color-bg-elevated);
border: 1px solid rgba(248, 81, 73, 0.3);
border-left: 4px solid #F85149;
padding: var(--space-4) var(--space-5);
margin-bottom: var(--space-3);
display: flex;
justify-content: space-between;
align-items: center;
}
[data-theme="modern"] .alert-card {
border-radius: var(--radius-md);
}
.alert-card__info {
flex: 1;
}
.alert-card__vehicle {
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin-bottom: var(--space-1);
}
.alert-card__detail {
font-size: var(--text-caption);
color: var(--color-text-muted);
}
/* =========================================================================
PAGINATION
========================================================================= */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-4) 0;
}
.pagination__btn {
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
background: var(--color-surface-2);
color: var(--color-text-secondary);
cursor: pointer;
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
}
[data-theme="modern"] .pagination__btn {
border-radius: var(--radius-sm);
}
.pagination__btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.pagination__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pagination__info {
font-size: var(--text-caption);
color: var(--color-text-muted);
}
/* =========================================================================
EMPTY STATE
========================================================================= */
.empty-state {
text-align: center;
padding: var(--space-8) var(--space-4);
color: var(--color-text-muted);
}
.empty-state__icon {
font-size: 3rem;
margin-bottom: var(--space-3);
opacity: 0.4;
}
.empty-state__text {
font-size: var(--text-body);
margin-bottom: var(--space-4);
}
/* =========================================================================
RESPONSIVE
========================================================================= */
@media (max-width: 768px) {
.page-header {
padding: var(--space-3) var(--space-4);
flex-wrap: wrap;
}
.page-content {
padding: var(--space-4);
}
.search-input {
width: 100%;
}
.form-row {
grid-template-columns: 1fr;
}
.vehicle-grid {
grid-template-columns: 1fr;
}
.stats-row {
grid-template-columns: 1fr 1fr;
}
}
</style>
</head>
<body>
<div class="page-shell">
<!-- Sidebar injected by sidebar.js -->
<aside id="sidebar"></aside>
<div class="main-area pos-main-offset">
<!-- Offline banner injected by offline-banner.js -->
<div id="offlineBanner"></div>
<div class="page-header">
<h1>Flotillas</h1>
<div class="page-header__actions">
<input type="text" class="search-input" id="searchInput"
placeholder="Buscar placa, marca, modelo..." />
<button class="btn btn--primary" id="btnNewVehicle">+ Nuevo Vehiculo</button>
</div>
</div>
<div class="page-content">
<!-- Stats -->
<div class="stats-row" id="statsRow">
<div class="stat-card">
<span class="stat-card__label">Vehiculos Activos</span>
<span class="stat-card__value" id="statTotal">--</span>
</div>
<div class="stat-card">
<span class="stat-card__label">Mant. Vencidos</span>
<span class="stat-card__value stat-card__value--danger" id="statOverdue">--</span>
</div>
<div class="stat-card">
<span class="stat-card__label">Prox. Este Mes</span>
<span class="stat-card__value stat-card__value--warn" id="statUpcoming">--</span>
</div>
<div class="stat-card">
<span class="stat-card__label">Costo Mes</span>
<span class="stat-card__value" id="statCost">--</span>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab-btn is-active" data-tab="vehicles">Vehiculos</button>
<button class="tab-btn" data-tab="maintenance">Mantenimiento</button>
<button class="tab-btn" data-tab="history">Historial</button>
<button class="tab-btn" data-tab="alerts">Alertas</button>
</div>
<!-- Tab: Vehicles -->
<div class="tab-panel is-active" id="tab-vehicles">
<div class="vehicle-grid" id="vehicleGrid">
<div class="empty-state">
<div class="empty-state__icon">&#x1F69A;</div>
<div class="empty-state__text">Cargando vehiculos...</div>
</div>
</div>
<div class="pagination" id="vehiclePagination"></div>
</div>
<!-- Tab: Maintenance -->
<div class="tab-panel" id="tab-maintenance">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-4);">
<h2 style="font-family:var(--font-heading);font-size:var(--text-h4);font-weight:var(--font-weight-bold);letter-spacing:var(--tracking-wide);text-transform:uppercase;">Programas de Mantenimiento</h2>
</div>
<table class="data-table" id="maintTable">
<thead>
<tr>
<th>Vehiculo</th>
<th>Tipo</th>
<th>Intervalo</th>
<th>Ultimo</th>
<th>Proximo</th>
<th>Estado</th>
<th></th>
</tr>
</thead>
<tbody id="maintBody">
<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">Cargando...</td></tr>
</tbody>
</table>
</div>
<!-- Tab: History -->
<div class="tab-panel" id="tab-history">
<table class="data-table">
<thead>
<tr>
<th>Fecha</th>
<th>Vehiculo</th>
<th>Tipo</th>
<th>Km</th>
<th>Costo</th>
<th>Empleado</th>
<th>Notas</th>
</tr>
</thead>
<tbody id="historyBody">
<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">Cargando...</td></tr>
</tbody>
</table>
</div>
<!-- Tab: Alerts -->
<div class="tab-panel" id="tab-alerts">
<div id="alertsList">
<div class="empty-state">
<div class="empty-state__text">Cargando alertas...</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal: New/Edit Vehicle -->
<div class="modal-overlay" id="vehicleModal">
<div class="modal">
<div class="modal__header">
<span class="modal__title" id="vehicleModalTitle">Nuevo Vehiculo</span>
<button class="modal__close" onclick="Fleet.closeVehicleModal()">&times;</button>
</div>
<div class="modal__body">
<input type="hidden" id="vehEditId" />
<div class="form-row">
<div class="form-group">
<label>Placa</label>
<input type="text" id="vehPlate" placeholder="ABC-123" maxlength="20" />
</div>
<div class="form-group">
<label>VIN</label>
<input type="text" id="vehVin" placeholder="17 caracteres" maxlength="17" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Marca</label>
<input type="text" id="vehMake" placeholder="Toyota" />
</div>
<div class="form-group">
<label>Modelo</label>
<input type="text" id="vehModel" placeholder="Hilux" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Anio</label>
<input type="number" id="vehYear" placeholder="2024" min="1900" max="2100" />
</div>
<div class="form-group">
<label>Kilometraje</label>
<input type="number" id="vehMileage" placeholder="0" min="0" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Combustible</label>
<select id="vehFuel">
<option value="gasolina">Gasolina</option>
<option value="diesel">Diesel</option>
<option value="gas">Gas LP</option>
<option value="electrico">Electrico</option>
<option value="hibrido">Hibrido</option>
</select>
</div>
<div class="form-group">
<label>Color</label>
<input type="text" id="vehColor" placeholder="Blanco" />
</div>
</div>
<div class="form-group">
<label>Propietario</label>
<input type="text" id="vehOwner" placeholder="Nombre del propietario" />
</div>
<div class="form-group">
<label>Notas</label>
<textarea id="vehNotes" rows="2" placeholder="Notas adicionales..."></textarea>
</div>
</div>
<div class="modal__footer">
<button class="btn btn--ghost" onclick="Fleet.closeVehicleModal()">Cancelar</button>
<button class="btn btn--primary" id="btnSaveVehicle" onclick="Fleet.saveVehicle()">Guardar</button>
</div>
</div>
</div>
<!-- Modal: New Schedule -->
<div class="modal-overlay" id="scheduleModal">
<div class="modal">
<div class="modal__header">
<span class="modal__title">Nuevo Programa de Mantenimiento</span>
<button class="modal__close" onclick="Fleet.closeScheduleModal()">&times;</button>
</div>
<div class="modal__body">
<input type="hidden" id="schedVehicleId" />
<div class="form-group">
<label>Vehiculo</label>
<select id="schedVehicleSelect"></select>
</div>
<div class="form-group">
<label>Tipo de Mantenimiento</label>
<input type="text" id="schedType" placeholder="Cambio de aceite, Frenos, etc." />
</div>
<div class="form-row">
<div class="form-group">
<label>Intervalo (km)</label>
<input type="number" id="schedIntervalKm" placeholder="10000" min="0" />
</div>
<div class="form-group">
<label>Intervalo (meses)</label>
<input type="number" id="schedIntervalMonths" placeholder="6" min="0" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Proximo Vencimiento (fecha)</label>
<input type="date" id="schedNextDate" />
</div>
<div class="form-group">
<label>Proximo Vencimiento (km)</label>
<input type="number" id="schedNextKm" placeholder="50000" min="0" />
</div>
</div>
<div class="form-group">
<label>Notas</label>
<textarea id="schedNotes" rows="2" placeholder="Notas..."></textarea>
</div>
</div>
<div class="modal__footer">
<button class="btn btn--ghost" onclick="Fleet.closeScheduleModal()">Cancelar</button>
<button class="btn btn--primary" onclick="Fleet.saveSchedule()">Guardar</button>
</div>
</div>
</div>
<!-- Modal: Record Maintenance -->
<div class="modal-overlay" id="logModal">
<div class="modal">
<div class="modal__header">
<span class="modal__title">Registrar Mantenimiento</span>
<button class="modal__close" onclick="Fleet.closeLogModal()">&times;</button>
</div>
<div class="modal__body">
<div class="form-group">
<label>Vehiculo</label>
<select id="logVehicleSelect"></select>
</div>
<div class="form-group">
<label>Programa (opcional)</label>
<select id="logScheduleSelect">
<option value="">-- Sin programa --</option>
</select>
</div>
<div class="form-group">
<label>Tipo de Mantenimiento</label>
<input type="text" id="logType" placeholder="Cambio de aceite" />
</div>
<div class="form-row">
<div class="form-group">
<label>Kilometraje Actual</label>
<input type="number" id="logMileage" placeholder="50000" min="0" />
</div>
<div class="form-group">
<label>Costo</label>
<input type="number" id="logCost" placeholder="0.00" min="0" step="0.01" />
</div>
</div>
<div class="form-group">
<label>Refacciones Usadas</label>
<textarea id="logParts" rows="2" placeholder="Filtro de aceite, aceite 5W-30..."></textarea>
</div>
<div class="form-group">
<label>Notas</label>
<textarea id="logNotes" rows="2" placeholder="Notas..."></textarea>
</div>
</div>
<div class="modal__footer">
<button class="btn btn--ghost" onclick="Fleet.closeLogModal()">Cancelar</button>
<button class="btn btn--primary" onclick="Fleet.saveLog()">Registrar</button>
</div>
</div>
</div>
<script src="/pos/static/js/app-init.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
<script src="/pos/static/js/fleet.js"></script>
<script src="/pos/static/js/offline-banner.js"></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
</body>
</html>