5b7b41aa50
- Flask app with SQLAlchemy, Flask-Login, Flask-Mail - Admin/owner roles, vessel management, charters, work orders - Background launcher (Iniciar.vbs) runs server without terminal window - Root redirect fixed: / → /login - debug=False, use_reloader=False for pythonw.exe compatibility Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1540 lines
92 KiB
HTML
1540 lines
92 KiB
HTML
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>Fleet Management - Admin</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f0f2f5; }
|
||
|
||
.header { background: #0a2a3a; color: white; padding: 15px 30px; display: flex; justify-content: space-between; align-items: center; border-bottom: 3px solid #c4a747; }
|
||
.header h1 { font-size: 22px; }
|
||
.header h1 span { color: #c4a747; }
|
||
.logout-btn { background: #c4a747; color: #0a2a3a; padding: 8px 18px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 14px; }
|
||
.logout-btn:hover { background: #d4b757; }
|
||
|
||
.nav-tabs { background: white; border-bottom: 2px solid #e0e0e0; padding: 0 30px; display: flex; flex-wrap: wrap; gap: 2px; }
|
||
.tab-btn { padding: 13px 22px; background: none; border: none; cursor: pointer; font-size: 14px; font-weight: 600; color: #666; border-bottom: 3px solid transparent; transition: all 0.2s; }
|
||
.tab-btn:hover { color: #0a2a3a; background: #f5f5f5; }
|
||
.tab-btn.active { color: #c4a747; border-bottom-color: #c4a747; background: #fffbf0; }
|
||
|
||
.container { padding: 25px 30px; max-width: 1500px; margin: 0 auto; }
|
||
.tab-content { display: none; }
|
||
.tab-content.active { display: block; }
|
||
|
||
.card { background: white; border-radius: 10px; padding: 25px; margin-bottom: 20px; box-shadow: 0 1px 6px rgba(0,0,0,0.07); border-left: 4px solid #c4a747; }
|
||
.card h3 { color: #0a2a3a; margin-bottom: 18px; border-bottom: 2px solid #f0e8cc; padding-bottom: 10px; font-size: 16px; }
|
||
|
||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||
th, td { padding: 11px 12px; text-align: left; border-bottom: 1px solid #f0f0f0; }
|
||
th { background: #0a2a3a; color: white; font-weight: 600; }
|
||
tr:hover td { background: #fafaf8; }
|
||
|
||
.btn { background: #0a2a3a; color: white; padding: 8px 16px; border: none; border-radius: 5px; cursor: pointer; font-size: 13px; font-weight: 600; margin: 3px; transition: background 0.2s; }
|
||
.btn:hover { background: #c4a747; color: #0a2a3a; }
|
||
.btn-sm { padding: 5px 11px; font-size: 12px; }
|
||
.btn-gold { background: #c4a747; color: #0a2a3a; }
|
||
.btn-gold:hover { background: #d4b757; }
|
||
.btn-success { background: #27ae60; color: white; }
|
||
.btn-success:hover { background: #2ecc71; color: white; }
|
||
.btn-warning { background: #e67e22; color: white; }
|
||
.btn-warning:hover { background: #d35400; color: white; }
|
||
.btn-danger { background: #e74c3c; color: white; }
|
||
.btn-danger:hover { background: #c0392b; color: white; }
|
||
|
||
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 18px; margin-bottom: 20px; }
|
||
.stat-card { background: white; border-radius: 10px; padding: 20px 15px; text-align: center; border-top: 4px solid #c4a747; box-shadow: 0 1px 6px rgba(0,0,0,0.07); }
|
||
.stat-card h4 { color: #666; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
|
||
.stat-card .value { font-size: 30px; font-weight: bold; color: #0a2a3a; }
|
||
.stat-card .sub { font-size: 11px; color: #999; margin-top: 4px; }
|
||
|
||
.form-group { margin-bottom: 14px; }
|
||
.form-group label { display: block; margin-bottom: 5px; color: #0a2a3a; font-weight: 600; font-size: 13px; }
|
||
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
|
||
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: #c4a747; box-shadow: 0 0 0 2px rgba(196,167,71,0.15); }
|
||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 15px; }
|
||
.form-group textarea { min-height: 80px; resize: vertical; }
|
||
|
||
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.55); justify-content: center; align-items: flex-start; z-index: 1000; padding-top: 40px; overflow-y: auto; }
|
||
.modal-content { background: white; padding: 30px; border-radius: 10px; width: 600px; max-width: 95%; margin-bottom: 40px; }
|
||
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 2px solid #f0e8cc; padding-bottom: 12px; }
|
||
.modal-header h3 { color: #0a2a3a; font-size: 18px; }
|
||
.close { cursor: pointer; font-size: 26px; color: #999; line-height: 1; }
|
||
.close:hover { color: #0a2a3a; }
|
||
|
||
.owner-card { background: white; border-radius: 10px; margin-bottom: 18px; overflow: hidden; box-shadow: 0 1px 6px rgba(0,0,0,0.07); }
|
||
.owner-header { background: #0a2a3a; color: white; padding: 14px 20px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
|
||
.owner-header:hover { background: #0d3448; }
|
||
.owner-meta { padding: 12px 20px; background: #f7f9fa; display: flex; gap: 25px; flex-wrap: wrap; font-size: 13px; border-bottom: 1px solid #eee; }
|
||
.owner-vessels { padding: 15px 20px 20px 20px; display: none; }
|
||
.owner-vessels.show { display: block; }
|
||
|
||
.vessel-detail { background: #fafafa; border-radius: 8px; margin-bottom: 14px; padding: 15px; border: 1px solid #e8e8e8; }
|
||
.vessel-name { font-size: 17px; font-weight: bold; color: #0a2a3a; margin-bottom: 10px; }
|
||
.vessel-specs { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; margin-bottom: 12px; }
|
||
.spec-item { background: white; padding: 7px 10px; border-radius: 5px; border: 1px solid #eee; font-size: 13px; }
|
||
.spec-label { font-weight: bold; color: #0a2a3a; display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.3px; color: #888; }
|
||
.spec-value { color: #0a2a3a; font-weight: 600; }
|
||
|
||
.history-item { background: #f9f9f9; padding: 13px 15px; margin: 8px 0; border-left: 3px solid #c4a747; border-radius: 0 5px 5px 0; font-size: 13px; }
|
||
.history-item strong { color: #0a2a3a; }
|
||
|
||
.message-bar { padding: 10px 15px; margin: 0 0 15px 0; border-radius: 5px; display: none; font-size: 14px; font-weight: 600; }
|
||
.message-bar.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||
.message-bar.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
||
|
||
.badge { display: inline-block; padding: 3px 9px; border-radius: 12px; font-size: 11px; font-weight: 600; }
|
||
.badge-draft { background: #e9ecef; color: #495057; }
|
||
.badge-signed { background: #cce5ff; color: #004085; }
|
||
.badge-completed { background: #d4edda; color: #155724; }
|
||
.badge-paid { background: #c4a747; color: #0a2a3a; }
|
||
.badge-pending { background: #fff3cd; color: #856404; }
|
||
.badge-approved { background: #cce5ff; color: #004085; }
|
||
.badge-done { background: #d4edda; color: #155724; }
|
||
.badge-rejected { background: #f8d7da; color: #721c24; }
|
||
|
||
/* Priority badges */
|
||
.badge-normal { background: #e9ecef; color: #495057; }
|
||
.badge-urgente { background: #ffeeba; color: #856404; border: 1px solid #f39c12; }
|
||
.badge-emergencia { background: #f8d7da; color: #721c24; border: 1px solid #e74c3c; animation: pulse-red 1.5s infinite; }
|
||
@keyframes pulse-red { 0%,100% { box-shadow: 0 0 0 0 rgba(231,76,60,0.4); } 50% { box-shadow: 0 0 0 4px rgba(231,76,60,0); } }
|
||
|
||
/* Notification modal */
|
||
.notify-modal .modal-content { border-top: 4px solid #25D366; }
|
||
.notify-channels { display: flex; gap: 12px; margin-bottom: 18px; }
|
||
.btn-wa { background: #25D366; color: white; display: flex; align-items: center; gap: 6px; padding: 10px 18px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; text-decoration: none; }
|
||
.btn-wa:hover { background: #1da851; color: white; }
|
||
.btn-email { background: #0a2a3a; color: white; display: flex; align-items: center; gap: 6px; padding: 10px 18px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; text-decoration: none; }
|
||
.btn-email:hover { background: #c4a747; color: #0a2a3a; }
|
||
.msg-preview { background: #f7f9fa; border: 1px solid #e0e0e0; border-radius: 8px; padding: 15px; font-size: 13px; white-space: pre-wrap; color: #333; max-height: 260px; overflow-y: auto; line-height: 1.6; }
|
||
.priority-emergency-row td { background: #fff5f5 !important; }
|
||
.priority-urgent-row td { background: #fffbf0 !important; }
|
||
.notified-check { color: #27ae60; font-size: 12px; }
|
||
|
||
/* Notification bell on charter row */
|
||
.btn-notify { background: none; border: 1px solid #25D366; color: #25D366; padding: 4px 8px; border-radius: 5px; cursor: pointer; font-size: 12px; }
|
||
.btn-notify:hover { background: #25D366; color: white; }
|
||
|
||
.total-preview { background: #f7f9fa; border: 1px solid #e0e8cc; border-radius: 5px; padding: 12px 15px; margin-top: 8px; font-size: 13px; }
|
||
.total-preview strong { color: #0a2a3a; }
|
||
|
||
.section-title { color: #0a2a3a; font-size: 14px; font-weight: 700; margin: 18px 0 10px 0; padding-bottom: 6px; border-bottom: 1px dashed #e0d8b0; }
|
||
|
||
@media (max-width: 900px) {
|
||
.stats-grid { grid-template-columns: repeat(3, 1fr); }
|
||
}
|
||
@media (max-width: 600px) {
|
||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||
.form-grid { grid-template-columns: 1fr; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
<div style="display:flex;flex-direction:column;gap:3px;">
|
||
<h1>Fleet <span>Management</span></h1>
|
||
<div style="font-size:12px;color:#a0bfcc;letter-spacing:0.3px;">{{ user.company.name if user.company else '' }}</div>
|
||
</div>
|
||
<div style="display:flex;gap:10px;align-items:center;">
|
||
{% if user.is_super_admin %}
|
||
<a href="/admin/companies" class="btn btn-sm btn-gold" style="font-size:11px;">☰ Gestionar Companias</a>
|
||
{% endif %}
|
||
<a href="{{ url_for('auth.logout') }}" class="logout-btn">Cerrar Sesión</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="nav-tabs">
|
||
<button class="tab-btn active" onclick="showTab('dashboard', this)">Dashboard</button>
|
||
<button class="tab-btn" onclick="showTab('owners', this)">Duenos y Botes</button>
|
||
<button class="tab-btn" onclick="showTab('charters', this)">Charters</button>
|
||
<button class="tab-btn" onclick="showTab('captains', this)">Capitanes</button>
|
||
<button class="tab-btn" onclick="showTab('workorders', this)">Work Orders</button>
|
||
<button class="tab-btn" onclick="showTab('history', this)">Historial</button>
|
||
<button class="tab-btn" onclick="showTab('accounting', this)">Contabilidad</button>
|
||
</div>
|
||
|
||
<div class="container">
|
||
<div id="message" class="message-bar"></div>
|
||
|
||
<!-- DASHBOARD -->
|
||
<div id="dashboard" class="tab-content active">
|
||
<div class="stats-grid">
|
||
<div class="stat-card"><h4>Duenos</h4><div class="value" id="statOwners">0</div></div>
|
||
<div class="stat-card"><h4>Botes</h4><div class="value" id="statVessels">0</div></div>
|
||
<div class="stat-card"><h4>Capitanes</h4><div class="value" id="statCaptains">0</div></div>
|
||
<div class="stat-card"><h4>Charters</h4><div class="value" id="statCharters">0</div><div class="sub">completados</div></div>
|
||
<div class="stat-card"><h4>Ingresos</h4><div class="value" id="statRevenue">$0</div><div class="sub">comision mgmt</div></div>
|
||
</div>
|
||
<div class="card"><h3>Bienvenido al Sistema de Gestion de Flota</h3><p style="color:#666;font-size:14px;">Use las pestanas para gestionar duenos, embarcaciones, charters, capitanes, mantenimiento y contabilidad.</p></div>
|
||
</div>
|
||
|
||
<!-- DUENOS Y BOTES -->
|
||
<div id="owners" class="tab-content">
|
||
<div class="card">
|
||
<h3>Duenos y sus Embarcaciones</h3>
|
||
<div style="margin-bottom:15px;">
|
||
<button class="btn" onclick="openModal('ownerModal')">+ Nuevo Dueno</button>
|
||
<button class="btn btn-gold" onclick="openModal('vesselModal')">+ Nueva Embarcacion</button>
|
||
</div>
|
||
<div id="ownersWithVessels"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CHARTERS -->
|
||
<div id="charters" class="tab-content">
|
||
<div class="card">
|
||
<h3>Gestion de Charters</h3>
|
||
<button class="btn" onclick="openModal('charterModal')">+ Nuevo Charter</button>
|
||
<div style="margin-top:15px; overflow-x:auto;">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Fecha/Hora</th>
|
||
<th>Bote</th>
|
||
<th>Dueno</th>
|
||
<th>Cliente</th>
|
||
<th>Capitán</th>
|
||
<th>Horas</th>
|
||
<th>Total</th>
|
||
<th>Estado</th>
|
||
<th>Acciones</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="chartersList"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CAPITANES -->
|
||
<div id="captains" class="tab-content">
|
||
<div class="card">
|
||
<h3>Capitanes (Socios)</h3>
|
||
<button class="btn" onclick="openModal('captainModal')">+ Nuevo Capitan</button>
|
||
<table style="margin-top:15px;">
|
||
<thead>
|
||
<tr><th>Nombre</th><th>Telefono</th><th>Licencia</th><th>Tarifa/hora</th></tr>
|
||
</thead>
|
||
<tbody id="captainsList"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WORK ORDERS -->
|
||
<div id="workorders" class="tab-content">
|
||
<div class="card">
|
||
<h3>Work Orders (Mantenimiento)</h3>
|
||
<button class="btn" onclick="openModal('workorderModal')">+ Nueva Work Order</button>
|
||
<table style="margin-top:15px;">
|
||
<thead>
|
||
<tr><th>Fecha</th><th>Bote</th><th>Dueno</th><th>Descripcion</th><th>Costo Est.</th><th>Prioridad</th><th>Estado</th><th>Acciones</th></tr>
|
||
</thead>
|
||
<tbody id="workordersList"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- HISTORIAL -->
|
||
<div id="history" class="tab-content">
|
||
<div class="card">
|
||
<h3>Historial por Bote</h3>
|
||
<div class="form-group" style="max-width:400px;">
|
||
<label>Seleccionar Bote:</label>
|
||
<select id="historyVesselSelect" onchange="loadHistory()"></select>
|
||
</div>
|
||
<div id="historyContent"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CONTABILIDAD -->
|
||
<div id="accounting" class="tab-content">
|
||
<!-- Resumen global -->
|
||
<div class="stats-grid" style="grid-template-columns:repeat(3,1fr);">
|
||
<div class="stat-card"><h4>Ingresos Totales</h4><div class="value" id="accRevenue">$0</div></div>
|
||
<div class="stat-card"><h4>Gastos Totales</h4><div class="value" id="accExpenses">$0</div></div>
|
||
<div class="stat-card"><h4>Utilidad Neta</h4><div class="value" id="accProfit">$0</div></div>
|
||
</div>
|
||
|
||
<!-- P&L consolidado por bote -->
|
||
<div class="card">
|
||
<h3>P&L Consolidado por Embarcacion</h3>
|
||
<div style="margin-bottom:12px;">
|
||
<button class="btn btn-sm" onclick="syncAllAccounting()">Sincronizar desde Charters y WOs</button>
|
||
</div>
|
||
<table>
|
||
<thead>
|
||
<tr><th>Bote</th><th>Dueno</th><th>Ing. Charters</th><th>Ing. Plan</th><th>Gastos (WO)</th><th>Combustible</th><th>Utilidad</th><th></th></tr>
|
||
</thead>
|
||
<tbody id="pnlBody"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Tarjetas por embarcacion con acceso al diario -->
|
||
<div class="card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:6px;">
|
||
<h3 style="margin:0;">Diario Contable por Embarcacion</h3>
|
||
<div style="display:flex;gap:8px;">
|
||
<button class="btn btn-sm" style="background:#27ae60;color:white;" onclick="openFuelModal()">+ Combustible</button>
|
||
<button class="btn btn-sm btn-gold" onclick="openModal('entryModal')">+ Asiento Manual</button>
|
||
</div>
|
||
</div>
|
||
<p style="color:#666;font-size:13px;margin:0 0 18px;">
|
||
Cada embarcacion tiene su propio diario contable con ingresos, gastos, facturas de combustible, work orders y mantenimiento.
|
||
Todo gasto e ingreso queda enlazado automaticamente.
|
||
</p>
|
||
<div id="vesselAccountingCards" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:16px;">
|
||
<div style="color:#999;text-align:center;padding:40px;grid-column:1/-1;">Cargando embarcaciones...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Combustible reciente (ultimos registros) -->
|
||
<div class="card">
|
||
<h3>Ultimos Registros de Combustible</h3>
|
||
<div style="overflow-x:auto;">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Fecha</th><th>Bote</th><th>Gallons</th><th>$/Gal</th><th>Total</th><th>Proveedor</th><th>Invoice</th><th>Charter</th><th></th></tr>
|
||
</thead>
|
||
<tbody id="fuelBody">
|
||
<tr><td colspan="9" style="color:#999;text-align:center;">Sin registros de combustible</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- =========== MODALES =========== -->
|
||
|
||
<!-- Modal: Nuevo Dueno -->
|
||
<div id="ownerModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3>Nuevo Dueno</h3>
|
||
<span class="close" onclick="closeModal('ownerModal')">×</span>
|
||
</div>
|
||
<form id="ownerForm">
|
||
<div class="form-group"><label>Nombre *</label><input type="text" id="ownerName" required></div>
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Email</label><input type="email" id="ownerEmail"></div>
|
||
<div class="form-group"><label>Telefono</label><input type="text" id="ownerPhone"></div>
|
||
</div>
|
||
<button type="submit" class="btn btn-gold">Guardar</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Nueva Embarcacion -->
|
||
<div id="vesselModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3 id="vesselModalTitle">Nueva Embarcacion</h3>
|
||
<span class="close" onclick="closeModal('vesselModal')">×</span>
|
||
</div>
|
||
<form id="vesselForm">
|
||
<input type="hidden" id="vesselId">
|
||
<div class="form-group"><label>Nombre del Bote *</label><input type="text" id="vesselName" required></div>
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Dueno *</label><select id="vesselOwnerId" required></select></div>
|
||
<div class="form-group"><label>Plan de Servicio</label>
|
||
<select id="vesselPlan">
|
||
<option value="1">Basico ($199/mes) - Solo charters</option>
|
||
<option value="2">Estandar ($399/mes) - Charters + mant.</option>
|
||
<option value="3">Mantenimiento ($299/mes) - Solo mant.</option>
|
||
<option value="4">Plus ($599/mes) - Todo incluido</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Marca / Tipo</label><input type="text" id="vesselMake" placeholder="Ej: Sea Ray, Azimut"></div>
|
||
<div class="form-group"><label>Modelo</label><input type="text" id="vesselModel" placeholder="Ej: 330 Sundancer"></div>
|
||
</div>
|
||
<div class="form-group"><label>Motores</label><input type="text" id="vesselEngines" placeholder="Ej: 2 x Mercruiser 350"></div>
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Eslora (pies)</label><input type="number" id="vesselLength" step="1" min="0"></div>
|
||
<div class="form-group"><label>Consumo Gal/h (a 14 nudos)</label><input type="number" id="vesselFuel" step="1" min="0"></div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Tarifa Base 4 horas ($)</label><input type="number" id="vesselRate" step="50" min="0"></div>
|
||
<div class="form-group"><label>Tarifa Hora Extra ($)</label><input type="number" id="vesselExtraRate" step="25" min="0"></div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Max. Personas (segun doc. oficial del bote, incl. capitan)</label><input type="number" id="vesselMaxPax" step="1" min="1" max="50" value="12"></div>
|
||
<div class="form-group"><label>% Comision Management</label><input type="number" id="vesselPct" step="1" min="0" max="100" value="25"></div>
|
||
</div>
|
||
<button type="submit" class="btn btn-gold">Guardar</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Nuevo Capitan -->
|
||
<div id="captainModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3>Nuevo Capitan</h3>
|
||
<span class="close" onclick="closeModal('captainModal')">×</span>
|
||
</div>
|
||
<form id="captainForm">
|
||
<div class="form-group"><label>Nombre *</label><input type="text" id="captainName" required></div>
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Telefono</label><input type="text" id="captainPhone"></div>
|
||
<div class="form-group"><label>N. Licencia</label><input type="text" id="captainLicense"></div>
|
||
</div>
|
||
<div class="form-group" style="max-width:200px;"><label>Tarifa/hora ($)</label><input type="number" id="captainRate" step="5" min="0"></div>
|
||
<button type="submit" class="btn btn-gold">Guardar</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Nuevo Charter -->
|
||
<div id="charterModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3 id="charterModalTitle">Nuevo Charter</h3>
|
||
<span class="close" onclick="closeModal('charterModal')">×</span>
|
||
</div>
|
||
<form id="charterForm">
|
||
<div class="form-group"><label>Embarcacion *</label><select id="charterVesselId" required onchange="updateCharterTotal()"></select></div>
|
||
<div class="form-group"><label>Cliente *</label><input type="text" id="chartererName" required></div>
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Email Cliente</label><input type="email" id="chartererEmail"></div>
|
||
<div class="form-group"><label>Telefono Cliente</label><input type="text" id="chartererPhone"></div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Fecha y Hora *</label><input type="datetime-local" id="charterStart" required></div>
|
||
<div class="form-group"><label>Horas * (minimo 4)</label><input type="number" id="charterHours" step="0.5" min="4" value="4" required onchange="updateCharterTotal()"></div>
|
||
</div>
|
||
<div class="total-preview" id="charterTotalPreview" style="display:none;">
|
||
<strong>Total estimado:</strong> $<span id="charterTotalValue">0</span>
|
||
<br><small id="charterTotalBreakdown" style="color:#666;"></small>
|
||
</div>
|
||
<hr style="margin:16px 0;border-color:#eee;">
|
||
<h4 style="color:#0a2a3a;margin:0 0 12px;font-size:14px;">Capitán y Seguro</h4>
|
||
<div class="form-group">
|
||
<label>Capitán (opcional)</label>
|
||
<select id="charterCaptainId">
|
||
<option value="">-- Sin capitán asignado --</option>
|
||
</select>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||
<div class="form-group">
|
||
<label>N° Rider de Seguro</label>
|
||
<input type="text" id="charterRiderNumber" placeholder="ej. MRK-2026-00142">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Aseguradora</label>
|
||
<input type="text" id="charterInsurer" placeholder="ej. Markel Marine">
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||
<div class="form-group">
|
||
<label>Suma Asegurada ($)</label>
|
||
<input type="number" id="charterCoverage" placeholder="500000">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Damage Waiver ($)</label>
|
||
<input type="number" id="charterDamageWaiver" placeholder="0" value="0">
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn btn-gold" style="margin-top:15px;">Guardar Charter</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Completar Charter (Voucher) -->
|
||
<div id="completeModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3>Completar Charter - Generar Voucher</h3>
|
||
<span class="close" onclick="closeModal('completeModal')">×</span>
|
||
</div>
|
||
<form id="completeForm">
|
||
<input type="hidden" id="completeCharterId">
|
||
<p id="completeCharterInfo" style="background:#f7f9fa;padding:12px;border-radius:5px;margin-bottom:15px;font-size:13px;"></p>
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Combustible consumido (gallons)</label><input type="number" id="completeFuel" step="1" min="0" value="0"></div>
|
||
<div class="form-group"><label>Cargo por desviacion ($)</label><input type="number" id="completeDeviation" step="10" min="0" value="0"></div>
|
||
</div>
|
||
<div class="form-group" style="max-width:200px;"><label>Propina (% del total)</label>
|
||
<select id="completeTipPct">
|
||
<option value="15">15%</option>
|
||
<option value="18" selected>18%</option>
|
||
<option value="20">20%</option>
|
||
</select>
|
||
</div>
|
||
<div class="total-preview" id="voucherPreview"></div>
|
||
<button type="submit" class="btn btn-success" style="margin-top:15px;">Confirmar y Generar Voucher</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Asiento Contable Manual -->
|
||
<div id="entryModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3>Nuevo Asiento Contable</h3>
|
||
<span class="close" onclick="closeModal('entryModal')">×</span>
|
||
</div>
|
||
<form id="entryForm">
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Embarcacion *</label><select id="entryVesselId" required></select></div>
|
||
<div class="form-group"><label>Fecha *</label><input type="date" id="entryDate" required></div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Tipo *</label>
|
||
<select id="entryType" onchange="updateEntryCategories()" required>
|
||
<option value="income">Ingreso</option>
|
||
<option value="expense">Gasto</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group"><label>Categoria *</label>
|
||
<select id="entryCategory" required></select>
|
||
</div>
|
||
</div>
|
||
<div class="form-group"><label>Descripcion *</label><input type="text" id="entryDescription" required></div>
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Monto ($) *</label><input type="number" id="entryAmount" step="0.01" min="0.01" required></div>
|
||
<div class="form-group"><label>N. Invoice / Referencia</label><input type="text" id="entryInvoice" placeholder="Ej: INV-2026-001"></div>
|
||
</div>
|
||
<div class="form-group"><label>Notas</label><textarea id="entryNotes" rows="2"></textarea></div>
|
||
<button type="submit" class="btn btn-gold">Guardar Asiento</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Registro de Combustible -->
|
||
<div id="fuelModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3>Registro de Combustible</h3>
|
||
<span class="close" onclick="closeModal('fuelModal')">×</span>
|
||
</div>
|
||
<form id="fuelForm">
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Embarcacion *</label><select id="fuelVesselId" required></select></div>
|
||
<div class="form-group"><label>Fecha *</label><input type="date" id="fuelDate" required></div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Gallons</label><input type="number" id="fuelLiters" step="0.1" min="0" oninput="calcFuelTotal()"></div>
|
||
<div class="form-group"><label>Precio por galon ($)</label><input type="number" id="fuelPpl" step="0.01" min="0" oninput="calcFuelTotal()"></div>
|
||
</div>
|
||
<div class="form-group" style="max-width:200px;"><label>Total ($) *</label><input type="number" id="fuelTotal" step="0.01" min="0.01" required></div>
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Proveedor / Marina</label><input type="text" id="fuelSupplier" placeholder="Ej: Miami Marine Fuel"></div>
|
||
<div class="form-group"><label>N. Invoice / Recibo</label><input type="text" id="fuelInvoice" placeholder="Ej: REC-2026-042"></div>
|
||
</div>
|
||
<div class="form-group"><label>Charter relacionado (opcional)</label><select id="fuelCharterId"><option value="">-- Ninguno --</option></select></div>
|
||
<div class="form-group"><label>Notas</label><textarea id="fuelNotes" rows="2"></textarea></div>
|
||
<button type="submit" class="btn btn-gold">Guardar Registro</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Enviar Contratos -->
|
||
<div id="sendContractsModal" class="modal">
|
||
<div class="modal-content" style="max-width:460px;">
|
||
<div class="modal-header">
|
||
<h3>Enviar Contratos por Email</h3>
|
||
<span class="close" onclick="closeModal('sendContractsModal')">×</span>
|
||
</div>
|
||
<div id="sendContractsInfo" style="background:#f0f4f8;border-radius:8px;padding:12px;margin-bottom:16px;font-size:13px;color:#0a2a3a;"></div>
|
||
<p style="font-size:13px;color:#555;margin:0 0 12px;">Documentos a enviar:</p>
|
||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:18px;">
|
||
<label style="display:flex;align-items:center;gap:9px;font-size:13px;cursor:pointer;">
|
||
<input type="checkbox" id="sendToCharterer" checked> 📄 Contrato de Charter → al Arrendatario
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:9px;font-size:13px;cursor:pointer;">
|
||
<input type="checkbox" id="sendToOwner" checked> 🏠 Copia + Resumen financiero → al Dueno del Bote
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:9px;font-size:13px;cursor:pointer;">
|
||
<input type="checkbox" id="sendToCaptain" checked> 👤 Contrato de Capitan (su alcance) → al Capitan
|
||
</label>
|
||
</div>
|
||
<div id="sendContractsStatus" style="display:none;padding:12px;border-radius:8px;font-size:13px;margin-bottom:14px;"></div>
|
||
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
||
<button class="btn btn-sm" onclick="closeModal('sendContractsModal')">Cancelar</button>
|
||
<button class="btn btn-sm btn-success" id="sendContractsBtn" onclick="sendContracts()">Enviar Emails</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Solicitar Rider de Seguro -->
|
||
<div id="requestInsuranceModal" class="modal">
|
||
<div class="modal-content" style="max-width:460px;">
|
||
<div class="modal-header">
|
||
<h3>Solicitar Rider de Seguro</h3>
|
||
<span class="close" onclick="closeModal('requestInsuranceModal')">×</span>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Aseguradora</label>
|
||
<select id="insurerSelect" onchange="updateInsurerEmail()">
|
||
<option value="">-- Seleccione --</option>
|
||
<option value="Markel Marine|underwriting@markel.com">Markel Marine</option>
|
||
<option value="BoatUS Marine|marine@boatus.com">BoatUS / GEICO Marine</option>
|
||
<option value="Progressive Marine|marine@progressive.com">Progressive Marine</option>
|
||
<option value="Pantaenius|info@pantaenius.com">Pantaenius</option>
|
||
<option value="custom|">Otra (ingresar email manualmente)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Email de la Aseguradora *</label>
|
||
<input type="email" id="insurerEmail" placeholder="agente@aseguradora.com">
|
||
</div>
|
||
<p style="font-size:12px;color:#888;margin:0 0 14px;">
|
||
Se enviara un email con datos completos del bote, arrendatario y periodo exacto de cobertura (por horas), adjuntando el formulario de solicitud de rider en PDF.
|
||
</p>
|
||
<div id="insuranceRequestStatus" style="display:none;padding:10px;border-radius:6px;font-size:13px;margin-bottom:12px;"></div>
|
||
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
||
<button class="btn btn-sm" onclick="closeModal('requestInsuranceModal')">Cancelar</button>
|
||
<button class="btn btn-sm btn-gold" onclick="sendInsuranceRequest()">Enviar Solicitud</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Notificacion -->
|
||
<div id="notifyModal" class="modal notify-modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3 id="notifyTitle">Notificar al Dueno</h3>
|
||
<span class="close" onclick="closeModal('notifyModal')">×</span>
|
||
</div>
|
||
<div id="notifyPriorityBanner" style="display:none;margin-bottom:15px;padding:10px 14px;border-radius:6px;font-weight:600;font-size:13px;"></div>
|
||
<p style="font-size:13px;color:#666;margin-bottom:12px;">
|
||
Seleccione el canal para enviar la notificacion a <strong id="notifyOwnerName"></strong>:
|
||
</p>
|
||
<div class="notify-channels">
|
||
<a id="notifyWaBtn" href="#" target="_blank" class="btn-wa" onclick="markNotified()">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>
|
||
WhatsApp
|
||
</a>
|
||
<a id="notifyEmailBtn" href="#" class="btn-email" onclick="markNotified()">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 7l10 7 10-7"/></svg>
|
||
Enviar Email
|
||
</a>
|
||
</div>
|
||
<p style="font-size:12px;color:#888;margin-bottom:8px;">Vista previa del mensaje:</p>
|
||
<div class="msg-preview" id="notifyMsgPreview"></div>
|
||
<p style="font-size:11px;color:#aaa;margin-top:10px;">* WhatsApp abre la app con el mensaje listo para enviar. Email abre su cliente de correo.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Nueva Work Order -->
|
||
<div id="workorderModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3>Nueva Work Order</h3>
|
||
<span class="close" onclick="closeModal('workorderModal')">×</span>
|
||
</div>
|
||
<form id="workorderForm">
|
||
<div class="form-group"><label>Embarcacion *</label><select id="woVesselId" required></select></div>
|
||
<div class="form-group"><label>Descripcion del trabajo *</label><textarea id="woDescription" placeholder="Ej: Cambio de aceite, revision de motores..." required></textarea></div>
|
||
<div class="form-grid">
|
||
<div class="form-group"><label>Costo Estimado ($)</label><input type="number" id="woCost" step="10" min="0"></div>
|
||
<div class="form-group"><label>Prioridad</label>
|
||
<select id="woPriority" onchange="updateWoPriorityHint()">
|
||
<option value="normal">Normal</option>
|
||
<option value="urgente">Urgente</option>
|
||
<option value="emergencia">Emergencia</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div id="woPriorityHint" style="display:none;padding:10px 14px;border-radius:6px;font-size:13px;margin-bottom:12px;"></div>
|
||
<button type="submit" class="btn btn-gold">Crear Work Order</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ============ NAVIGATION ============
|
||
let allVessels = [];
|
||
|
||
function showTab(tabId, btnEl) {
|
||
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
document.getElementById(tabId).classList.add('active');
|
||
if (btnEl) btnEl.classList.add('active');
|
||
if (tabId === 'history') loadVesselSelect();
|
||
if (tabId === 'accounting') loadFuelEntries();
|
||
}
|
||
|
||
function goToLedger(vesselId) {
|
||
if (vesselId) {
|
||
window.location.href = `/admin/vessel/${vesselId}/accounting`;
|
||
} else {
|
||
// No vesselId: just switch to accounting tab
|
||
const btn = document.querySelector('.tab-btn[onclick*="accounting"]');
|
||
showTab('accounting', btn);
|
||
}
|
||
}
|
||
|
||
function showMessage(msg, type) {
|
||
let el = document.getElementById('message');
|
||
el.textContent = msg;
|
||
el.className = 'message-bar ' + type;
|
||
el.style.display = 'block';
|
||
setTimeout(() => el.style.display = 'none', 4000);
|
||
}
|
||
|
||
// ============ MODALS ============
|
||
function openModal(id) {
|
||
loadSelects();
|
||
document.getElementById(id).style.display = 'flex';
|
||
}
|
||
|
||
function closeModal(id) {
|
||
document.getElementById(id).style.display = 'none';
|
||
}
|
||
|
||
// Close modal on background click
|
||
document.querySelectorAll('.modal').forEach(m => {
|
||
m.addEventListener('click', (e) => {
|
||
if (e.target === m) m.style.display = 'none';
|
||
});
|
||
});
|
||
|
||
// ============ SELECTS ============
|
||
async function loadSelects() {
|
||
try {
|
||
let [vessels, owners, captains] = await Promise.all([
|
||
fetch('/api/vessels').then(r => r.json()),
|
||
fetch('/api/owners').then(r => r.json()),
|
||
fetch('/api/captains').then(r => r.json())
|
||
]);
|
||
allVessels = vessels;
|
||
const vesselOpts = '<option value="">-- Seleccione --</option>' + vessels.map(v => `<option value="${v.id}" data-rate="${v.base_rate_4h}" data-extra="${v.hourly_rate_extra}">${v.name}</option>`).join('');
|
||
const ownerOpts = '<option value="">-- Seleccione --</option>' + owners.map(o => `<option value="${o.id}">${o.name}</option>`).join('');
|
||
document.getElementById('charterVesselId').innerHTML = vesselOpts;
|
||
document.getElementById('woVesselId').innerHTML = vesselOpts;
|
||
document.getElementById('vesselOwnerId').innerHTML = ownerOpts;
|
||
document.getElementById('charterCaptainId').innerHTML =
|
||
'<option value="">-- Sin capitán asignado --</option>' +
|
||
captains.map(c => `<option value="${c.id}">${c.name} (${c.license_type || 'privado'})</option>`).join('');
|
||
} catch (e) { console.error(e); }
|
||
}
|
||
|
||
async function loadVesselSelect() {
|
||
if (allVessels.length === 0) {
|
||
allVessels = await fetch('/api/vessels').then(r => r.json());
|
||
}
|
||
document.getElementById('historyVesselSelect').innerHTML =
|
||
'<option value="">-- Seleccione un bote --</option>' +
|
||
allVessels.map(v => `<option value="${v.id}">${v.name} (${v.owner_name})</option>`).join('');
|
||
}
|
||
|
||
// ============ CHARTER TOTAL PREVIEW ============
|
||
function updateCharterTotal() {
|
||
const sel = document.getElementById('charterVesselId');
|
||
const opt = sel.options[sel.selectedIndex];
|
||
const hours = parseFloat(document.getElementById('charterHours').value) || 4;
|
||
if (!opt || !opt.value) {
|
||
document.getElementById('charterTotalPreview').style.display = 'none';
|
||
return;
|
||
}
|
||
const base = parseFloat(opt.dataset.rate) || 0;
|
||
const extra = parseFloat(opt.dataset.extra) || 0;
|
||
let total = base;
|
||
let breakdown = `Base 4h: $${base}`;
|
||
if (hours > 4) {
|
||
const extraCost = (hours - 4) * extra;
|
||
total += extraCost;
|
||
breakdown += ` + ${hours - 4}h extra x $${extra} = $${extraCost.toFixed(2)}`;
|
||
}
|
||
document.getElementById('charterTotalValue').textContent = total.toFixed(2);
|
||
document.getElementById('charterTotalBreakdown').textContent = breakdown;
|
||
document.getElementById('charterTotalPreview').style.display = 'block';
|
||
}
|
||
|
||
// ============ COMPLETE CHARTER ============
|
||
function openCompleteCharter(id, vesselName, chartererName, total) {
|
||
document.getElementById('completeCharterId').value = id;
|
||
document.getElementById('completeCharterInfo').innerHTML =
|
||
`<strong>${vesselName}</strong> — Cliente: ${chartererName} — Total: <strong>$${total}</strong>`;
|
||
updateVoucherPreview(total);
|
||
document.getElementById('completeModal').style.display = 'flex';
|
||
|
||
document.getElementById('completeTipPct').onchange = () => updateVoucherPreview(total);
|
||
}
|
||
|
||
function updateVoucherPreview(total) {
|
||
const tipPct = parseFloat(document.getElementById('completeTipPct').value);
|
||
const tip = (total * tipPct / 100).toFixed(2);
|
||
document.getElementById('voucherPreview').innerHTML =
|
||
`<strong>Resumen del voucher:</strong><br>
|
||
Charter: $${total} |
|
||
Propina sugerida (${tipPct}%): $${tip}<br>
|
||
<small style="color:#888;">La propina la paga el cliente directamente al capitan.</small>`;
|
||
}
|
||
|
||
document.getElementById('completeForm').onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const id = document.getElementById('completeCharterId').value;
|
||
const body = {
|
||
fuel_liters: parseFloat(document.getElementById('completeFuel').value) || 0,
|
||
deviation_charged: parseFloat(document.getElementById('completeDeviation').value) || 0,
|
||
tip_percentage: parseFloat(document.getElementById('completeTipPct').value)
|
||
};
|
||
const res = await fetch(`/api/charters/${id}/complete`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(body)
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
closeModal('completeModal');
|
||
loadData();
|
||
showMessage('Charter completado. Voucher #' + data.voucher_id + ' generado.', 'success');
|
||
} else {
|
||
showMessage('Error al completar el charter', 'error');
|
||
}
|
||
};
|
||
|
||
// ============ HISTORY ============
|
||
async function loadHistory() {
|
||
const id = document.getElementById('historyVesselSelect').value;
|
||
if (!id) { document.getElementById('historyContent').innerHTML = ''; return; }
|
||
try {
|
||
const data = await fetch(`/api/vessels/${id}/history`).then(r => r.json());
|
||
let html = `<h4 style="color:#0a2a3a;margin:15px 0 10px;">${data.vessel_name}</h4>`;
|
||
|
||
if (data.charters && data.charters.length) {
|
||
html += `<div class="section-title">Charters (${data.charters.length})</div>`;
|
||
data.charters.forEach(ch => {
|
||
html += `<div class="history-item">
|
||
<strong>${ch.start_datetime}</strong> — Cliente: ${ch.charterer_name} ${ch.charterer_phone ? '(' + ch.charterer_phone + ')' : ''}
|
||
<br>Horas: ${ch.hours} | Total: $${ch.total_base_rate} | Owner recibe: $${ch.owner_earnings || 0}
|
||
<br>Estado: <span class="badge badge-${ch.status}">${ch.status}</span>
|
||
</div>`;
|
||
});
|
||
} else {
|
||
html += `<p style="color:#999;padding:10px 0;">Sin charters registrados.</p>`;
|
||
}
|
||
|
||
if (data.workorders && data.workorders.length) {
|
||
html += `<div class="section-title">Mantenimiento (${data.workorders.length})</div>`;
|
||
data.workorders.forEach(wo => {
|
||
html += `<div class="history-item">
|
||
<strong>${wo.created_at}</strong> — ${wo.description}
|
||
<br>Estimado: $${wo.estimated_cost || 0} ${wo.actual_cost ? '| Real: $' + wo.actual_cost : ''}
|
||
<br>Estado: <span class="badge badge-${wo.status}">${wo.status}</span>
|
||
</div>`;
|
||
});
|
||
} else {
|
||
html += `<p style="color:#999;padding:10px 0;">Sin work orders registradas.</p>`;
|
||
}
|
||
|
||
document.getElementById('historyContent').innerHTML = html;
|
||
} catch (e) { console.error(e); }
|
||
}
|
||
|
||
function goToHistory(vesselId) {
|
||
const btn = document.querySelector('.tab-btn[onclick*="history"]');
|
||
showTab('history', btn);
|
||
loadVesselSelect().then(() => {
|
||
document.getElementById('historyVesselSelect').value = vesselId;
|
||
loadHistory();
|
||
});
|
||
}
|
||
|
||
function programarCharter(vesselId, vesselName) {
|
||
const btn = document.querySelector('.tab-btn[onclick*="charters"]');
|
||
showTab('charters', btn);
|
||
openModal('charterModal');
|
||
setTimeout(() => {
|
||
document.getElementById('charterModalTitle').textContent = 'Nuevo Charter - ' + vesselName;
|
||
document.getElementById('charterVesselId').value = vesselId;
|
||
updateCharterTotal();
|
||
}, 300);
|
||
}
|
||
|
||
// ============ OWNERS WITH VESSELS ============
|
||
async function loadOwnersWithVessels() {
|
||
try {
|
||
const [owners, vessels] = await Promise.all([
|
||
fetch('/api/owners').then(r => r.json()),
|
||
fetch('/api/vessels').then(r => r.json())
|
||
]);
|
||
allVessels = vessels;
|
||
let html = '';
|
||
for (let owner of owners) {
|
||
const ownerVessels = vessels.filter(v => v.owner_id === owner.id);
|
||
html += `<div class="owner-card">
|
||
<div class="owner-header" onclick="toggleVessels(${owner.id})">
|
||
<span>👤 ${owner.name}</span>
|
||
<span style="font-size:13px;">${ownerVessels.length} embarcacion${ownerVessels.length !== 1 ? 'es' : ''} ▼</span>
|
||
</div>
|
||
<div class="owner-meta">
|
||
<span>📧 ${owner.email || 'Sin email'}</span>
|
||
<span>📞 ${owner.phone || 'Sin telefono'}</span>
|
||
</div>
|
||
<div class="owner-vessels" id="vessels-${owner.id}">`;
|
||
|
||
if (ownerVessels.length === 0) {
|
||
html += `<p style="color:#999;padding:10px 0;font-size:13px;">Sin embarcaciones registradas.</p>`;
|
||
} else {
|
||
for (let v of ownerVessels) {
|
||
html += `<div class="vessel-detail">
|
||
<div class="vessel-name">🚤 ${v.name}</div>
|
||
<div class="vessel-specs">
|
||
<div class="spec-item"><span class="spec-label">Marca</span><span class="spec-value">${v.make || 'N/A'}</span></div>
|
||
<div class="spec-item"><span class="spec-label">Modelo</span><span class="spec-value">${v.model || 'N/A'}</span></div>
|
||
<div class="spec-item"><span class="spec-label">Motores</span><span class="spec-value">${v.engines || 'N/A'}</span></div>
|
||
<div class="spec-item"><span class="spec-label">Eslora</span><span class="spec-value">${v.length || 'N/A'} ft</span></div>
|
||
<div class="spec-item"><span class="spec-label">Consumo 14kn</span><span class="spec-value">${v.fuel_consumption || 'N/A'} Gal/h</span></div>
|
||
<div class="spec-item"><span class="spec-label">Tarifa 4h</span><span class="spec-value">$${v.base_rate_4h || 'N/A'}</span></div>
|
||
<div class="spec-item"><span class="spec-label">Hora extra</span><span class="spec-value">$${v.hourly_rate_extra || 'N/A'}</span></div>
|
||
<div class="spec-item"><span class="spec-label">Max. Personas</span><span class="spec-value" style="font-weight:bold;color:#e74c3c;">${v.max_passengers || 12} personas (incl. capitan)</span></div>
|
||
<div class="spec-item"><span class="spec-label">Plan</span><span class="spec-value">${v.plan_name || 'N/A'}</span></div>
|
||
</div>
|
||
<div>
|
||
<button class="btn btn-sm" onclick="goToHistory(${v.id})">Historial</button>
|
||
<button class="btn btn-sm btn-gold" onclick="programarCharter(${v.id}, '${v.name.replace(/'/g, "\\'")}')">Programar Charter</button>
|
||
<a class="btn btn-sm btn-success" href="/admin/vessel/${v.id}/accounting">Contabilidad</a>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
html += `</div></div>`;
|
||
}
|
||
if (!html) html = '<p style="color:#999;">No hay duenos registrados. Haga clic en "+ Nuevo Dueno" para comenzar.</p>';
|
||
document.getElementById('ownersWithVessels').innerHTML = html;
|
||
} catch (e) { console.error('loadOwnersWithVessels', e); }
|
||
}
|
||
|
||
function toggleVessels(ownerId) {
|
||
document.getElementById(`vessels-${ownerId}`).classList.toggle('show');
|
||
}
|
||
|
||
// ============ LOAD ALL DATA ============
|
||
async function loadData() {
|
||
try {
|
||
const [stats, captains, charters, workorders, pnl] = await Promise.all([
|
||
fetch('/api/stats').then(r => r.json()),
|
||
fetch('/api/captains').then(r => r.json()),
|
||
fetch('/api/charters').then(r => r.json()),
|
||
fetch('/api/workorders').then(r => r.json()),
|
||
fetch('/api/accounting/pnl').then(r => r.json()).catch(() => [])
|
||
]);
|
||
|
||
// Stats
|
||
document.getElementById('statOwners').textContent = stats.owners || 0;
|
||
document.getElementById('statVessels').textContent = stats.vessels || 0;
|
||
document.getElementById('statCaptains').textContent = stats.captains || 0;
|
||
document.getElementById('statCharters').textContent = stats.charters || 0;
|
||
document.getElementById('statRevenue').textContent = '$' + (stats.revenue || 0).toLocaleString('en-US', {minimumFractionDigits: 0, maximumFractionDigits: 0});
|
||
|
||
// Captains
|
||
document.getElementById('captainsList').innerHTML = captains.length
|
||
? captains.map(c => `<tr><td>${c.name}</td><td>${c.phone || '-'}</td><td>${c.license_number || '-'}</td><td>$${c.hourly_rate}/h</td></tr>`).join('')
|
||
: '<tr><td colspan="4" style="color:#999;text-align:center;">Sin capitanes registrados</td></tr>';
|
||
|
||
// Charters
|
||
const statusBadge = s => `<span class="badge badge-${s}">${s}</span>`;
|
||
document.getElementById('chartersList').innerHTML = charters.length
|
||
? charters.map(ch => `<tr>
|
||
<td>${ch.start_datetime}</td>
|
||
<td>${ch.vessel_name}</td>
|
||
<td>${ch.owner_name}</td>
|
||
<td>${ch.charterer_name}<br><small style="color:#999;">${ch.charterer_phone}</small></td>
|
||
<td>${ch.captain_name || '—'}</td>
|
||
<td>${ch.hours}h</td>
|
||
<td>$${ch.total_base_rate}</td>
|
||
<td>
|
||
${statusBadge(ch.status)}
|
||
${(ch.status === 'draft' || ch.status === 'signed')
|
||
? ch.insurance_rider_number
|
||
? `<span style="background:#27ae60;color:white;padding:2px 7px;border-radius:10px;font-size:10px;font-weight:bold;display:inline-block;margin-top:3px;">✅ Poliza OK</span>`
|
||
: `<span style="background:#e74c3c;color:white;padding:2px 7px;border-radius:10px;font-size:10px;font-weight:bold;display:inline-block;margin-top:3px;">🔴 SIN POLIZA</span>`
|
||
: ''}
|
||
</td>
|
||
<td>
|
||
<button class="btn-notify" onclick="openNotify(${ch.id},'charter')" title="Notificar dueno del bote">📲</button>
|
||
${ch.status !== 'completed' && ch.status !== 'paid'
|
||
? ch.insurance_rider_number
|
||
? `<button class="btn btn-sm btn-success" onclick="openCompleteCharter(${ch.id},'${ch.vessel_name.replace(/'/g,"\\'")}','${ch.charterer_name.replace(/'/g,"\\'")}',${ch.total_base_rate})">Completar</button>`
|
||
: `<button class="btn btn-sm" style="background:#bbb;color:white;cursor:not-allowed;font-size:11px;" disabled title="Requiere poliza de seguro">🔒 Sin Poliza</button>`
|
||
: ''}
|
||
<a href="/api/charters/${ch.id}/contract" target="_blank" class="btn btn-sm" style="font-size:10px;padding:3px 7px;" title="Contrato Charter">📄</a>
|
||
<a href="/api/charters/${ch.id}/captain-contract" target="_blank" class="btn btn-sm" style="font-size:10px;padding:3px 7px;background:#27ae60;color:white;" title="Contrato Capitan">👤</a>
|
||
<a href="/api/charters/${ch.id}/insurance-rider" target="_blank" class="btn btn-sm" style="font-size:10px;padding:3px 7px;background:#8e44ad;color:white;" title="Rider Seguro">${ch.insurance_rider_number ? '' : '🚨'}🛡️</a>
|
||
<button class="btn btn-sm" style="background:#2980b9;color:white;font-size:10px;padding:3px 7px;" onclick="openSendContracts(${ch.id},'${ch.charterer_name.replace(/'/g,"\\'")}')">✉️</button>
|
||
<button class="btn btn-sm" style="background:#e67e22;color:white;font-size:10px;padding:3px 7px;" onclick="openRequestInsurance(${ch.id})">📋</button>
|
||
</td>
|
||
</tr>`).join('')
|
||
: '<tr><td colspan="9" style="color:#999;text-align:center;">Sin charters registrados</td></tr>';
|
||
|
||
// Work Orders
|
||
const woStatusBadge = s => `<span class="badge badge-${s}">${s}</span>`;
|
||
const priBadge = p => {
|
||
if (p === 'emergencia') return '<span class="badge badge-emergencia">🚨 EMERGENCIA</span>';
|
||
if (p === 'urgente') return '<span class="badge badge-urgente">⚠️ Urgente</span>';
|
||
return '<span class="badge badge-normal">Normal</span>';
|
||
};
|
||
document.getElementById('workordersList').innerHTML = workorders.length
|
||
? workorders.map(wo => `<tr class="${wo.priority === 'emergencia' ? 'priority-emergency-row' : wo.priority === 'urgente' ? 'priority-urgent-row' : ''}">
|
||
<td>${wo.created_at}</td>
|
||
<td><strong>${wo.vessel_name}</strong></td>
|
||
<td>${wo.owner_name}<br><small style="color:#999;">${wo.owner_phone || ''}</small></td>
|
||
<td>${wo.description}</td>
|
||
<td>$${wo.estimated_cost || 0}</td>
|
||
<td>${priBadge(wo.priority)}</td>
|
||
<td>${woStatusBadge(wo.status)}</td>
|
||
<td>
|
||
${wo.status === 'pending' || wo.status === 'approved' ? `
|
||
<button class="btn-notify" onclick="openNotify(${wo.id},'wo')" title="Notificar al dueno">
|
||
${wo.notified_at ? '✓ Notificado' : '📲 Notificar'}
|
||
</button>` : ''}
|
||
${wo.status === 'pending' ? `<button class="btn btn-sm btn-success" onclick="approveWO(${wo.id})">Aprobar</button>` : ''}
|
||
${wo.status === 'pending' ? `<button class="btn btn-sm btn-danger" onclick="rejectWO(${wo.id})">Rechazar</button>` : ''}
|
||
${wo.status === 'approved' ? `<button class="btn btn-sm btn-warning" onclick="doneWO(${wo.id})">Marcar Hecho</button>` : ''}
|
||
</td>
|
||
</tr>`).join('')
|
||
: '<tr><td colspan="8" style="color:#999;text-align:center;">Sin work orders</td></tr>';
|
||
|
||
// P&L
|
||
let rev = 0, exp = 0;
|
||
pnl.forEach(p => { rev += p.revenue; exp += p.expenses; });
|
||
document.getElementById('accRevenue').textContent = '$' + rev.toLocaleString();
|
||
document.getElementById('accExpenses').textContent = '$' + exp.toLocaleString();
|
||
document.getElementById('accProfit').textContent = '$' + (rev - exp).toLocaleString();
|
||
document.getElementById('pnlBody').innerHTML = pnl.length
|
||
? pnl.map(p => `<tr>
|
||
<td>${p.vessel_name}</td>
|
||
<td>${p.owner_name}</td>
|
||
<td style="color:#27ae60;">$${(p.charter_revenue||0).toLocaleString()}</td>
|
||
<td style="color:#27ae60;">$${(p.plan_revenue||0).toLocaleString()}</td>
|
||
<td style="color:#e74c3c;">$${(p.expenses||0).toLocaleString()}</td>
|
||
<td style="color:#e74c3c;">$${(p.fuel_cost||0).toLocaleString()}</td>
|
||
<td style="font-weight:bold;color:${(p.profit||0) >= 0 ? '#27ae60' : '#e74c3c'}">$${(p.profit||0).toLocaleString()}</td>
|
||
<td><a href="/admin/vessel/${p.vessel_id}/accounting" class="btn btn-sm" style="font-size:11px;padding:3px 8px;text-decoration:none;">Diario</a></td>
|
||
</tr>`).join('')
|
||
: '<tr><td colspan="8" style="color:#999;text-align:center;">Sin datos — use "Sincronizar" para generar</td></tr>';
|
||
|
||
// Render vessel accounting cards
|
||
const cardContainer = document.getElementById('vesselAccountingCards');
|
||
if (pnl.length) {
|
||
cardContainer.innerHTML = pnl.map(p => {
|
||
const profit = p.profit || 0;
|
||
const borderColor = profit >= 0 ? '#27ae60' : '#e74c3c';
|
||
const profitColor = profit >= 0 ? '#27ae60' : '#e74c3c';
|
||
return `<div style="background:#fff;border-radius:10px;border:1px solid #e0e0e0;padding:18px;border-top:4px solid ${borderColor};display:flex;flex-direction:column;gap:12px;">
|
||
<div>
|
||
<div style="font-size:15px;font-weight:700;color:#0a2a3a;">🚤 ${p.vessel_name}</div>
|
||
<div style="font-size:12px;color:#888;margin-top:2px;">Propietario: ${p.owner_name}</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;text-align:center;">
|
||
<div style="background:#f0faf4;border-radius:6px;padding:8px;">
|
||
<div style="font-size:9px;color:#666;text-transform:uppercase;letter-spacing:.5px;">Ingresos</div>
|
||
<div style="font-size:14px;font-weight:700;color:#27ae60;">$${(p.revenue||p.charter_revenue||0).toLocaleString()}</div>
|
||
</div>
|
||
<div style="background:#fdf5f5;border-radius:6px;padding:8px;">
|
||
<div style="font-size:9px;color:#666;text-transform:uppercase;letter-spacing:.5px;">Gastos</div>
|
||
<div style="font-size:14px;font-weight:700;color:#e74c3c;">$${(p.expenses||0).toLocaleString()}</div>
|
||
</div>
|
||
<div style="background:#f7f9fa;border-radius:6px;padding:8px;">
|
||
<div style="font-size:9px;color:#666;text-transform:uppercase;letter-spacing:.5px;">Utilidad</div>
|
||
<div style="font-size:14px;font-weight:700;color:${profitColor};">$${profit.toLocaleString()}</div>
|
||
</div>
|
||
</div>
|
||
<a href="/admin/vessel/${p.vessel_id}/accounting"
|
||
style="display:block;text-align:center;background:#0a2a3a;color:#c4a747;text-decoration:none;padding:9px 14px;border-radius:6px;font-weight:600;font-size:13px;letter-spacing:.3px;">
|
||
Ver Diario Contable →
|
||
</a>
|
||
</div>`;
|
||
}).join('');
|
||
} else if (allVessels.length) {
|
||
// Vessels exist but no P&L data yet
|
||
cardContainer.innerHTML = allVessels.map(v => `
|
||
<div style="background:#fff;border-radius:10px;border:1px solid #e0e0e0;padding:18px;border-top:4px solid #c4a747;display:flex;flex-direction:column;gap:12px;">
|
||
<div>
|
||
<div style="font-size:15px;font-weight:700;color:#0a2a3a;">🚤 ${v.name}</div>
|
||
<div style="font-size:12px;color:#888;margin-top:2px;">${v.owner_name||''}</div>
|
||
</div>
|
||
<div style="font-size:12px;color:#999;background:#f8f9fa;padding:10px;border-radius:6px;">
|
||
Sin datos contables aun. Use "Sincronizar" para importar charters y work orders.
|
||
</div>
|
||
<a href="/admin/vessel/${v.id}/accounting"
|
||
style="display:block;text-align:center;background:#0a2a3a;color:#c4a747;text-decoration:none;padding:9px 14px;border-radius:6px;font-weight:600;font-size:13px;">
|
||
Ver Diario Contable →
|
||
</a>
|
||
</div>`).join('');
|
||
} else {
|
||
cardContainer.innerHTML = '<p style="color:#999;text-align:center;grid-column:1/-1;padding:30px;">No hay embarcaciones registradas.</p>';
|
||
}
|
||
|
||
// Populate entry vessel select
|
||
document.getElementById('entryVesselId').innerHTML =
|
||
'<option value="">-- Seleccione --</option>' +
|
||
allVessels.map(v => `<option value="${v.id}">${v.name}</option>`).join('');
|
||
|
||
// Load recent fuel entries
|
||
loadFuelEntries();
|
||
|
||
// Owners/Vessels section
|
||
loadOwnersWithVessels();
|
||
|
||
} catch (e) { console.error('loadData error', e); }
|
||
}
|
||
|
||
// ============ PRIORITY HINT ============
|
||
function updateWoPriorityHint() {
|
||
const val = document.getElementById('woPriority').value;
|
||
const hint = document.getElementById('woPriorityHint');
|
||
const hints = {
|
||
normal: { bg: '#e9ecef', color: '#495057', text: 'Normal: trabajo de mantenimiento rutinario. El bote puede seguir chárterando.' },
|
||
urgente: { bg: '#fff3cd', color: '#856404', text: '⚠️ Urgente: requiere atención pronta. El propietario debe aprobar lo antes posible.' },
|
||
emergencia: { bg: '#f8d7da', color: '#721c24', text: '🚨 Emergencia: el bote NO puede ir a charter por razones de seguridad hasta ser reparado.' }
|
||
};
|
||
if (val === 'normal') { hint.style.display = 'none'; return; }
|
||
const h = hints[val];
|
||
hint.style.cssText = `display:block; background:${h.bg}; color:${h.color}; padding:10px 14px; border-radius:6px; font-size:13px; margin-bottom:12px; font-weight:600;`;
|
||
hint.textContent = h.text;
|
||
}
|
||
|
||
// ============ NOTIFICATIONS ============
|
||
let currentNotifyId = null;
|
||
let currentNotifyType = null;
|
||
|
||
async function openNotify(id, type) {
|
||
currentNotifyId = id;
|
||
currentNotifyType = type;
|
||
try {
|
||
const url = type === 'wo'
|
||
? `/api/workorders/${id}/notify-message`
|
||
: `/api/charters/${id}/notify-message`;
|
||
const data = await fetch(url).then(r => r.json());
|
||
|
||
document.getElementById('notifyTitle').textContent =
|
||
type === 'wo' ? 'Notificar al Dueno — Work Order' : 'Notificar al Dueno — Charter';
|
||
document.getElementById('notifyOwnerName').textContent = data.owner_name;
|
||
document.getElementById('notifyMsgPreview').textContent = data.body;
|
||
|
||
// Priority banner
|
||
const banner = document.getElementById('notifyPriorityBanner');
|
||
if (data.priority === 'emergencia') {
|
||
banner.style.cssText = 'display:block; background:#f8d7da; color:#721c24; padding:10px 14px; border-radius:6px; font-weight:600; font-size:13px; margin-bottom:15px;';
|
||
banner.textContent = '🚨 EMERGENCIA — El bote NO puede ir a charter hasta que se apruebe y ejecute esta work order.';
|
||
} else if (data.priority === 'urgente') {
|
||
banner.style.cssText = 'display:block; background:#fff3cd; color:#856404; padding:10px 14px; border-radius:6px; font-weight:600; font-size:13px; margin-bottom:15px;';
|
||
banner.textContent = '⚠️ URGENTE — Se requiere aprobación inmediata del propietario.';
|
||
} else {
|
||
banner.style.display = 'none';
|
||
}
|
||
|
||
// WhatsApp link
|
||
const waPhone = data.wa_phone || '';
|
||
const waText = encodeURIComponent(data.body);
|
||
document.getElementById('notifyWaBtn').href =
|
||
waPhone ? `https://wa.me/${waPhone}?text=${waText}` : `https://wa.me/?text=${waText}`;
|
||
|
||
// Email link
|
||
const emailBody = encodeURIComponent(data.body);
|
||
const emailSubject = encodeURIComponent(data.subject);
|
||
document.getElementById('notifyEmailBtn').href =
|
||
`mailto:${data.owner_email}?subject=${emailSubject}&body=${emailBody}`;
|
||
|
||
document.getElementById('notifyModal').style.display = 'flex';
|
||
} catch(e) {
|
||
showMessage('Error al generar mensaje', 'error');
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
async function markNotified() {
|
||
if (currentNotifyType === 'wo' && currentNotifyId) {
|
||
await fetch(`/api/workorders/${currentNotifyId}/mark-notified`, {method: 'POST'});
|
||
setTimeout(() => { loadData(); }, 800);
|
||
}
|
||
}
|
||
|
||
// ============ API ACTIONS ============
|
||
async function approveWO(id) {
|
||
await fetch(`/api/workorders/${id}/approve`, {method: 'POST'});
|
||
loadData();
|
||
showMessage('Work Order aprobada', 'success');
|
||
}
|
||
|
||
async function rejectWO(id) {
|
||
if (!confirm('Rechazar esta Work Order?')) return;
|
||
await fetch(`/api/workorders/${id}/reject`, {method: 'POST'});
|
||
loadData();
|
||
showMessage('Work Order rechazada', 'success');
|
||
}
|
||
|
||
async function doneWO(id) {
|
||
const cost = prompt('Costo real del trabajo ($):');
|
||
if (cost === null) return;
|
||
await fetch(`/api/workorders/${id}/done`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({actual_cost: parseFloat(cost) || 0})
|
||
});
|
||
loadData();
|
||
showMessage('Work Order marcada como completada', 'success');
|
||
}
|
||
|
||
// ============ FORM SUBMISSIONS ============
|
||
document.getElementById('ownerForm').onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
await fetch('/api/owners', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
name: document.getElementById('ownerName').value,
|
||
email: document.getElementById('ownerEmail').value,
|
||
phone: document.getElementById('ownerPhone').value
|
||
})
|
||
});
|
||
closeModal('ownerModal');
|
||
document.getElementById('ownerForm').reset();
|
||
loadData();
|
||
showMessage('Dueno creado exitosamente', 'success');
|
||
};
|
||
|
||
document.getElementById('vesselForm').onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const id = document.getElementById('vesselId').value;
|
||
const payload = {
|
||
name: document.getElementById('vesselName').value,
|
||
make: document.getElementById('vesselMake').value,
|
||
model: document.getElementById('vesselModel').value,
|
||
engines: document.getElementById('vesselEngines').value,
|
||
owner_company_id: document.getElementById('vesselOwnerId').value,
|
||
length: document.getElementById('vesselLength').value || 0,
|
||
fuel_consumption: document.getElementById('vesselFuel').value || 0,
|
||
base_rate_4h: document.getElementById('vesselRate').value || 0,
|
||
hourly_rate_extra: document.getElementById('vesselExtraRate').value || 0,
|
||
max_passengers: document.getElementById('vesselMaxPax').value || 12,
|
||
charter_percentage: document.getElementById('vesselPct').value || 25,
|
||
plan_id: document.getElementById('vesselPlan').value
|
||
};
|
||
if (id) {
|
||
await fetch(`/api/vessels/${id}`, {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)});
|
||
} else {
|
||
await fetch('/api/vessels', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)});
|
||
}
|
||
closeModal('vesselModal');
|
||
document.getElementById('vesselForm').reset();
|
||
document.getElementById('vesselId').value = '';
|
||
document.getElementById('vesselModalTitle').textContent = 'Nueva Embarcacion';
|
||
loadData();
|
||
showMessage('Embarcacion guardada exitosamente', 'success');
|
||
};
|
||
|
||
document.getElementById('captainForm').onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
await fetch('/api/captains', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
name: document.getElementById('captainName').value,
|
||
phone: document.getElementById('captainPhone').value,
|
||
license_number: document.getElementById('captainLicense').value,
|
||
hourly_rate: document.getElementById('captainRate').value || 0
|
||
})
|
||
});
|
||
closeModal('captainModal');
|
||
document.getElementById('captainForm').reset();
|
||
loadData();
|
||
showMessage('Capitan creado exitosamente', 'success');
|
||
};
|
||
|
||
document.getElementById('charterForm').onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const res = await fetch('/api/charters', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
vessel_id: document.getElementById('charterVesselId').value,
|
||
charterer_name: document.getElementById('chartererName').value,
|
||
charterer_email: document.getElementById('chartererEmail').value,
|
||
charterer_phone: document.getElementById('chartererPhone').value,
|
||
start_datetime: document.getElementById('charterStart').value,
|
||
hours: document.getElementById('charterHours').value,
|
||
captain_id: document.getElementById('charterCaptainId').value || null,
|
||
insurance_rider_number: document.getElementById('charterRiderNumber').value,
|
||
insurer_name: document.getElementById('charterInsurer').value,
|
||
coverage_amount: parseFloat(document.getElementById('charterCoverage').value) || null,
|
||
damage_waiver: parseFloat(document.getElementById('charterDamageWaiver').value) || 0
|
||
})
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
closeModal('charterModal');
|
||
document.getElementById('charterForm').reset();
|
||
document.getElementById('charterModalTitle').textContent = 'Nuevo Charter';
|
||
document.getElementById('charterTotalPreview').style.display = 'none';
|
||
document.getElementById('charterCaptainId').value = '';
|
||
document.getElementById('charterRiderNumber').value = '';
|
||
document.getElementById('charterInsurer').value = '';
|
||
document.getElementById('charterCoverage').value = '';
|
||
document.getElementById('charterDamageWaiver').value = '0';
|
||
loadData();
|
||
showMessage(`Charter creado. Total: $${data.total}`, 'success');
|
||
} else {
|
||
showMessage('Error al crear el charter', 'error');
|
||
}
|
||
};
|
||
|
||
document.getElementById('workorderForm').onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const priority = document.getElementById('woPriority').value;
|
||
const res = await fetch('/api/workorders', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
vessel_id: document.getElementById('woVesselId').value,
|
||
description: document.getElementById('woDescription').value,
|
||
estimated_cost: document.getElementById('woCost').value || 0,
|
||
priority: priority
|
||
})
|
||
});
|
||
const data = await res.json();
|
||
closeModal('workorderModal');
|
||
document.getElementById('workorderForm').reset();
|
||
document.getElementById('woPriorityHint').style.display = 'none';
|
||
loadData();
|
||
const msg = priority === 'emergencia'
|
||
? '🚨 Work Order de EMERGENCIA creada. Notifique al dueno de inmediato.'
|
||
: priority === 'urgente'
|
||
? '⚠️ Work Order URGENTE creada. Recuerde notificar al dueno.'
|
||
: 'Work Order creada. Pendiente de aprobacion del dueno.';
|
||
showMessage(msg, priority === 'emergencia' ? 'error' : 'success');
|
||
// Auto-abrir notificacion si es emergencia
|
||
if (priority === 'emergencia' && data.id) {
|
||
setTimeout(() => openNotify(data.id, 'wo'), 600);
|
||
}
|
||
};
|
||
|
||
// ============ ACCOUNTING ============
|
||
const CATEGORIES = {
|
||
income: ['charter','plan_subscription','other_income'],
|
||
expense: ['work_order','fuel','cleaning','detailing','teak','marina','insurance','storage','other_expense']
|
||
};
|
||
const CAT_LABELS = {
|
||
charter: 'Charter', plan_subscription: 'Suscripcion Plan',
|
||
other_income: 'Otro Ingreso', work_order: 'Work Order / Mant.',
|
||
fuel: 'Combustible', cleaning: 'Limpieza', detailing: 'Detailing',
|
||
teak: 'Limpieza Teca', marina: 'Marina / Muelle',
|
||
insurance: 'Seguro', storage: 'Almacenaje', other_expense: 'Otro Gasto'
|
||
};
|
||
|
||
function updateEntryCategories() {
|
||
const type = document.getElementById('entryType').value;
|
||
const sel = document.getElementById('entryCategory');
|
||
sel.innerHTML = CATEGORIES[type].map(c => `<option value="${c}">${CAT_LABELS[c]}</option>`).join('');
|
||
}
|
||
updateEntryCategories();
|
||
|
||
function calcFuelTotal() {
|
||
const l = parseFloat(document.getElementById('fuelLiters').value) || 0;
|
||
const p = parseFloat(document.getElementById('fuelPpl').value) || 0;
|
||
if (l > 0 && p > 0) document.getElementById('fuelTotal').value = (l * p).toFixed(2);
|
||
}
|
||
|
||
async function openFuelModal() {
|
||
await loadSelects();
|
||
// Populate fuel vessel select
|
||
const vessels = allVessels;
|
||
document.getElementById('fuelVesselId').innerHTML =
|
||
'<option value="">-- Seleccione --</option>' +
|
||
vessels.map(v => `<option value="${v.id}">${v.name}</option>`).join('');
|
||
// Populate charters select
|
||
const charters = await fetch('/api/charters').then(r => r.json());
|
||
document.getElementById('fuelCharterId').innerHTML =
|
||
'<option value="">-- Ninguno --</option>' +
|
||
charters.map(c => `<option value="${c.id}">${c.start_datetime} – ${c.vessel_name} – ${c.charterer_name}</option>`).join('');
|
||
// Default date to today
|
||
document.getElementById('fuelDate').value = new Date().toISOString().split('T')[0];
|
||
document.getElementById('fuelModal').style.display = 'flex';
|
||
}
|
||
|
||
// loadLedger() moved to /admin/vessel/<id>/accounting dedicated page
|
||
|
||
async function syncAllAccounting() {
|
||
const res = await fetch('/api/accounting/sync-all', {method: 'POST'});
|
||
const data = await res.json();
|
||
showMessage(`Sincronizacion completada: ${data.entries_created} asientos generados`, 'success');
|
||
loadData();
|
||
}
|
||
|
||
async function deleteEntry(id) {
|
||
if (!confirm('Eliminar este asiento contable?')) return;
|
||
await fetch(`/api/accounting/entries/${id}`, {method: 'DELETE'});
|
||
loadData();
|
||
}
|
||
|
||
async function loadFuelEntries() {
|
||
const data = await fetch('/api/fuel-entries').then(r => r.json());
|
||
document.getElementById('fuelBody').innerHTML = data.length
|
||
? data.map(f => `<tr>
|
||
<td>${f.date}</td>
|
||
<td>${f.vessel_name}</td>
|
||
<td>${f.liters ? f.liters + ' gal' : '-'}</td>
|
||
<td>${f.price_per_liter ? '$'+f.price_per_liter : '-'}</td>
|
||
<td style="font-weight:bold;">$${f.total_cost.toLocaleString()}</td>
|
||
<td>${f.supplier||'-'}</td>
|
||
<td style="font-size:12px;color:#666;">${f.invoice_number||'-'}</td>
|
||
<td>${f.charter_id ? 'CHR-'+String(f.charter_id).padStart(4,'0') : '-'}</td>
|
||
<td><button class="btn btn-sm btn-danger" style="padding:3px 7px;font-size:11px;" onclick="deleteFuel(${f.id})">✕</button></td>
|
||
</tr>`).join('')
|
||
: '<tr><td colspan="9" style="color:#999;text-align:center;">Sin registros de combustible</td></tr>';
|
||
}
|
||
|
||
async function deleteFuel(id) {
|
||
if (!confirm('Eliminar este registro de combustible?')) return;
|
||
await fetch(`/api/fuel-entries/${id}`, {method: 'DELETE'});
|
||
loadFuelEntries();
|
||
loadData();
|
||
}
|
||
|
||
// Form: asiento manual
|
||
document.getElementById('entryForm').onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
await fetch('/api/accounting/entries', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
vessel_id: document.getElementById('entryVesselId').value,
|
||
date: document.getElementById('entryDate').value,
|
||
entry_type: document.getElementById('entryType').value,
|
||
category: document.getElementById('entryCategory').value,
|
||
description: document.getElementById('entryDescription').value,
|
||
amount: document.getElementById('entryAmount').value,
|
||
invoice_number: document.getElementById('entryInvoice').value,
|
||
notes: document.getElementById('entryNotes').value
|
||
})
|
||
});
|
||
closeModal('entryModal');
|
||
document.getElementById('entryForm').reset();
|
||
updateEntryCategories();
|
||
loadData();
|
||
showMessage('Asiento contable guardado', 'success');
|
||
};
|
||
|
||
// Form: combustible
|
||
document.getElementById('fuelForm').onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
await fetch('/api/fuel-entries', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
vessel_id: document.getElementById('fuelVesselId').value,
|
||
date: document.getElementById('fuelDate').value,
|
||
liters: document.getElementById('fuelLiters').value || 0,
|
||
price_per_liter: document.getElementById('fuelPpl').value || 0,
|
||
total_cost: document.getElementById('fuelTotal').value,
|
||
supplier: document.getElementById('fuelSupplier').value,
|
||
invoice_number: document.getElementById('fuelInvoice').value,
|
||
charter_id: document.getElementById('fuelCharterId').value || null,
|
||
notes: document.getElementById('fuelNotes').value
|
||
})
|
||
});
|
||
closeModal('fuelModal');
|
||
document.getElementById('fuelForm').reset();
|
||
loadData();
|
||
showMessage('Registro de combustible guardado y asiento generado', 'success');
|
||
};
|
||
|
||
// ============ SEND CONTRACTS ============
|
||
let _sendCharterIdContracts = null;
|
||
|
||
function openSendContracts(charterId, chartererName) {
|
||
_sendCharterIdContracts = charterId;
|
||
document.getElementById('sendContractsInfo').innerHTML =
|
||
`<strong>Charter #${String(charterId).padStart(4,'0')}</strong> — Arrendatario: <strong>${chartererName}</strong>`;
|
||
document.getElementById('sendContractsStatus').style.display = 'none';
|
||
const btn = document.getElementById('sendContractsBtn');
|
||
btn.disabled = false;
|
||
btn.textContent = 'Enviar Emails';
|
||
document.getElementById('sendContractsModal').style.display = 'flex';
|
||
}
|
||
|
||
async function sendContracts() {
|
||
const btn = document.getElementById('sendContractsBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Enviando...';
|
||
const statusEl = document.getElementById('sendContractsStatus');
|
||
try {
|
||
const res = await fetch(`/api/charters/${_sendCharterIdContracts}/send-contracts`, {method:'POST'});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
const r = data.results;
|
||
const lines = [];
|
||
const label = (key, name) => {
|
||
if (r[key] === 'sent') lines.push(`✅ ${name}: enviado`);
|
||
else if (r[key] === 'failed') lines.push(`❌ ${name}: error al enviar`);
|
||
else if (r[key] === 'no_email') lines.push(`⚠️ ${name}: sin email registrado`);
|
||
else if (r[key] === 'no_captain') lines.push(`ℹ️ Capitan: no asignado al charter`);
|
||
};
|
||
label('charterer', 'Arrendatario');
|
||
label('owner', 'Dueno del bote');
|
||
label('captain', 'Capitan');
|
||
statusEl.innerHTML = lines.join('<br>');
|
||
statusEl.style.cssText = 'display:block;background:#f0faf4;border:1px solid #c3e6cb;color:#155724;padding:12px;border-radius:8px;font-size:13px;margin-bottom:14px;';
|
||
} else {
|
||
statusEl.innerHTML = '❌ Error: ' + (data.error || 'Verifique configuracion SMTP en .env');
|
||
statusEl.style.cssText = 'display:block;background:#fdf5f5;border:1px solid #f5c6cb;color:#721c24;padding:12px;border-radius:8px;font-size:13px;margin-bottom:14px;';
|
||
}
|
||
} catch(e) {
|
||
statusEl.innerHTML = '❌ Error de conexion';
|
||
statusEl.style.cssText = 'display:block;background:#fdf5f5;color:#721c24;padding:12px;border-radius:8px;font-size:13px;margin-bottom:14px;';
|
||
}
|
||
btn.disabled = false;
|
||
btn.textContent = 'Enviar Emails';
|
||
}
|
||
|
||
// ============ REQUEST INSURANCE ============
|
||
let _requestInsuranceCharterId = null;
|
||
|
||
function openRequestInsurance(charterId) {
|
||
_requestInsuranceCharterId = charterId;
|
||
document.getElementById('insurerSelect').value = '';
|
||
document.getElementById('insurerEmail').value = '';
|
||
document.getElementById('insuranceRequestStatus').style.display = 'none';
|
||
document.getElementById('requestInsuranceModal').style.display = 'flex';
|
||
}
|
||
|
||
function updateInsurerEmail() {
|
||
const val = document.getElementById('insurerSelect').value;
|
||
if (!val || val.startsWith('custom')) {
|
||
document.getElementById('insurerEmail').value = '';
|
||
document.getElementById('insurerEmail').focus();
|
||
return;
|
||
}
|
||
document.getElementById('insurerEmail').value = val.split('|')[1] || '';
|
||
}
|
||
|
||
async function sendInsuranceRequest() {
|
||
const email = document.getElementById('insurerEmail').value.trim();
|
||
if (!email) { alert('Ingrese el email de la aseguradora'); return; }
|
||
const sel = document.getElementById('insurerSelect').value;
|
||
const name = (sel && !sel.startsWith('custom')) ? sel.split('|')[0] : 'Aseguradora';
|
||
const statusEl = document.getElementById('insuranceRequestStatus');
|
||
statusEl.innerHTML = 'Enviando solicitud...';
|
||
statusEl.style.cssText = 'display:block;background:#fff3cd;color:#856404;padding:10px;border-radius:6px;font-size:13px;';
|
||
try {
|
||
const res = await fetch(`/api/charters/${_requestInsuranceCharterId}/request-insurance`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({insurer_email: email, insurer_name: name})
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
statusEl.innerHTML = `✅ Solicitud enviada a <strong>${name}</strong>. Espere el numero de rider por respuesta.`;
|
||
statusEl.style.cssText = 'display:block;background:#f0faf4;border:1px solid #c3e6cb;color:#155724;padding:10px;border-radius:6px;font-size:13px;';
|
||
setTimeout(() => loadData(), 1000);
|
||
} else {
|
||
statusEl.innerHTML = `❌ ${data.message || 'Error al enviar'}<br><small>Configure MAIL_USERNAME y MAIL_PASSWORD en el archivo .env</small>`;
|
||
statusEl.style.cssText = 'display:block;background:#fdf5f5;color:#721c24;padding:10px;border-radius:6px;font-size:13px;';
|
||
}
|
||
} catch(e) {
|
||
statusEl.innerHTML = '❌ Error de conexion';
|
||
statusEl.style.cssText = 'display:block;background:#fdf5f5;color:#721c24;padding:10px;border-radius:6px;font-size:13px;';
|
||
}
|
||
}
|
||
|
||
// ============ INIT ============
|
||
loadData();
|
||
</script>
|
||
</body>
|
||
</html>
|