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:
540
pos/blueprints/fleet_bp.py
Normal file
540
pos/blueprints/fleet_bp.py
Normal 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,
|
||||||
|
})
|
||||||
50
pos/migrations/v1.3_fleet.sql
Normal file
50
pos/migrations/v1.3_fleet.sql
Normal 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
609
pos/static/js/fleet.js
Normal 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">🚚</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) + ')">« 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 »</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;">✓</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>' +
|
||||||
|
' ' + 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
972
pos/templates/fleet.html
Normal 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">🚚</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()">×</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()">×</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()">×</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>
|
||||||
Reference in New Issue
Block a user