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>
704 lines
36 KiB
HTML
704 lines
36 KiB
HTML
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>Contabilidad — {{ vessel.name }}</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: 14px 30px; display: flex; justify-content: space-between; align-items: center; border-bottom: 3px solid #c4a747; }
|
||
.header-left { display: flex; align-items: center; gap: 18px; }
|
||
.back-btn { background: rgba(255,255,255,0.15); color: white; padding: 7px 14px; text-decoration: none; border-radius: 5px; font-size: 13px; border: 1px solid rgba(255,255,255,0.3); }
|
||
.back-btn:hover { background: #c4a747; color: #0a2a3a; border-color: #c4a747; }
|
||
.header h1 { font-size: 20px; }
|
||
.header h1 span { color: #c4a747; }
|
||
.header-right { display: flex; gap: 10px; }
|
||
.logout-btn { background: #c4a747; color: #0a2a3a; padding: 7px 16px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 13px; }
|
||
|
||
.vessel-banner { background: #0a2a3a; color: white; padding: 20px 30px; border-bottom: 2px solid #c4a747; }
|
||
.vessel-banner h2 { font-size: 24px; color: #c4a747; margin-bottom: 6px; }
|
||
.vessel-meta { display: flex; gap: 25px; flex-wrap: wrap; font-size: 13px; color: rgba(255,255,255,0.75); }
|
||
.vessel-meta strong { color: white; }
|
||
|
||
.container { padding: 25px 30px; max-width: 1400px; margin: 0 auto; }
|
||
|
||
/* KPI cards */
|
||
.kpi-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 22px; }
|
||
.kpi { background: white; border-radius: 10px; padding: 18px 14px; text-align: center; box-shadow: 0 1px 5px rgba(0,0,0,0.07); }
|
||
.kpi.income { border-top: 4px solid #27ae60; }
|
||
.kpi.expense { border-top: 4px solid #e74c3c; }
|
||
.kpi.profit { border-top: 4px solid #c4a747; }
|
||
.kpi.charters { border-top: 4px solid #3498db; }
|
||
.kpi.fuel { border-top: 4px solid #9b59b6; }
|
||
.kpi h4 { font-size: 11px; text-transform: uppercase; color: #888; letter-spacing: 0.5px; margin-bottom: 10px; }
|
||
.kpi .val { font-size: 26px; font-weight: bold; color: #0a2a3a; }
|
||
.kpi .sub { font-size: 11px; color: #aaa; margin-top: 4px; }
|
||
|
||
/* Filters bar */
|
||
.filters { background: white; border-radius: 10px; padding: 16px 20px; margin-bottom: 20px; display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; box-shadow: 0 1px 5px rgba(0,0,0,0.07); }
|
||
.filters label { font-size: 11px; font-weight: 700; color: #0a2a3a; text-transform: uppercase; display: block; margin-bottom: 4px; }
|
||
.filters select, .filters input[type=date] { padding: 8px 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 13px; }
|
||
.filters select:focus, .filters input:focus { outline: none; border-color: #c4a747; }
|
||
|
||
/* Cards */
|
||
.card { background: white; border-radius: 10px; padding: 22px; margin-bottom: 20px; box-shadow: 0 1px 5px rgba(0,0,0,0.07); border-left: 4px solid #c4a747; }
|
||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 2px solid #f0e8cc; }
|
||
.card-header h3 { color: #0a2a3a; font-size: 16px; }
|
||
.card-header .card-total { font-size: 14px; font-weight: bold; }
|
||
|
||
/* Journal table */
|
||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #f0f0f0; }
|
||
th { background: #0a2a3a; color: white; font-weight: 600; font-size: 12px; }
|
||
tr:hover td { background: #fafaf8; }
|
||
.col-debit { text-align: right; color: #e74c3c; font-weight: 600; }
|
||
.col-credit { text-align: right; color: #27ae60; font-weight: 600; }
|
||
.col-balance { text-align: right; font-weight: bold; }
|
||
.col-num { text-align: right; }
|
||
|
||
/* Badges */
|
||
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||
.badge-charter { background: #d4edda; color: #155724; }
|
||
.badge-fuel { background: #e8d5f5; color: #6c3483; }
|
||
.badge-work_order { background: #ffeeba; color: #856404; }
|
||
.badge-cleaning { background: #d1ecf1; color: #0c5460; }
|
||
.badge-other { background: #e9ecef; color: #495057; }
|
||
.badge-plan_subscription { background: #cce5ff; color: #004085; }
|
||
|
||
/* Buttons */
|
||
.btn { background: #0a2a3a; color: white; padding: 8px 16px; border: none; border-radius: 5px; cursor: pointer; font-size: 13px; font-weight: 600; text-decoration: none; display: inline-block; margin: 2px; transition: background 0.2s; }
|
||
.btn:hover { background: #c4a747; color: #0a2a3a; }
|
||
.btn-gold { background: #c4a747; color: #0a2a3a; }
|
||
.btn-gold:hover { background: #d4b757; }
|
||
.btn-green { background: #27ae60; color: white; }
|
||
.btn-green:hover { background: #2ecc71; color: white; }
|
||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||
.btn-danger { background: #e74c3c; color: white; }
|
||
.btn-danger:hover { background: #c0392b; color: white; }
|
||
|
||
/* Modals */
|
||
.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: 50px; overflow-y: auto; }
|
||
.modal-content { background: white; padding: 28px; border-radius: 10px; width: 560px; max-width: 95%; margin-bottom: 40px; }
|
||
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; border-bottom: 2px solid #f0e8cc; padding-bottom: 10px; }
|
||
.modal-header h3 { color: #0a2a3a; font-size: 16px; }
|
||
.close { cursor: pointer; font-size: 24px; color: #999; }
|
||
.close:hover { color: #0a2a3a; }
|
||
.form-group { margin-bottom: 13px; }
|
||
.form-group label { display: block; margin-bottom: 4px; color: #0a2a3a; font-weight: 600; font-size: 12px; text-transform: uppercase; }
|
||
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 9px 11px; border: 1px solid #ddd; border-radius: 5px; font-size: 13px; }
|
||
.form-group input:focus, .form-group select: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 14px; }
|
||
.form-group textarea { min-height: 70px; resize: vertical; }
|
||
|
||
.msg-bar { padding: 10px 14px; border-radius: 5px; margin-bottom: 15px; display: none; font-weight: 600; font-size: 13px; }
|
||
.msg-bar.success { background: #d4edda; color: #155724; }
|
||
.msg-bar.error { background: #f8d7da; color: #721c24; }
|
||
|
||
.running-balance { font-family: 'Courier New', monospace; }
|
||
|
||
@media (max-width: 900px) { .kpi-grid { grid-template-columns: repeat(3, 1fr); } }
|
||
@media (max-width: 600px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } .form-grid { grid-template-columns: 1fr; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="header">
|
||
<div class="header-left">
|
||
<a href="/admin/dashboard" class="back-btn">← Volver al Dashboard</a>
|
||
<h1>Fleet <span>Management</span> — Contabilidad</h1>
|
||
</div>
|
||
<div class="header-right">
|
||
<a href="{{ url_for('auth.logout') }}" class="logout-btn">Cerrar Sesion</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="vessel-banner">
|
||
<h2>🚤 {{ vessel.name }}</h2>
|
||
<div class="vessel-meta">
|
||
<span><strong>Dueno:</strong> {{ owner.name if owner else 'N/A' }}</span>
|
||
<span><strong>Plan:</strong> {{ plan_name }}</span>
|
||
<span><strong>Marca/Modelo:</strong> {{ vessel.make or '' }} {{ vessel.model or '' }}</span>
|
||
<span><strong>Eslora:</strong> {{ vessel.length or 'N/A' }} ft</span>
|
||
<span><strong>Motores:</strong> {{ vessel.engines or 'N/A' }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="container">
|
||
<div id="msgBar" class="msg-bar"></div>
|
||
|
||
<!-- KPIs -->
|
||
<div class="kpi-grid">
|
||
<div class="kpi income">
|
||
<h4>Ingresos Totales</h4>
|
||
<div class="val" id="kpiIncome">$0</div>
|
||
<div class="sub" id="kpiIncomeCount">0 transacciones</div>
|
||
</div>
|
||
<div class="kpi expense">
|
||
<h4>Gastos Totales</h4>
|
||
<div class="val" id="kpiExpenses">$0</div>
|
||
<div class="sub" id="kpiExpCount">0 transacciones</div>
|
||
</div>
|
||
<div class="kpi profit">
|
||
<h4>Utilidad Neta</h4>
|
||
<div class="val" id="kpiProfit">$0</div>
|
||
<div class="sub">Periodo seleccionado</div>
|
||
</div>
|
||
<div class="kpi charters">
|
||
<h4>Ing. Charters</h4>
|
||
<div class="val" id="kpiCharters">$0</div>
|
||
<div class="sub" id="kpiChartersCount">0 charters</div>
|
||
</div>
|
||
<div class="kpi fuel">
|
||
<h4>Gasto Combustible</h4>
|
||
<div class="val" id="kpiFuel">$0</div>
|
||
<div class="sub" id="kpiFuelCount">0 recargas</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Filtros -->
|
||
<div class="filters">
|
||
<div>
|
||
<label>Año</label>
|
||
<select id="fYear" onchange="loadAll()">
|
||
<option value="">Todos</option>
|
||
<option value="2024">2024</option>
|
||
<option value="2025">2025</option>
|
||
<option value="2026" selected>2026</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label>Mes</label>
|
||
<select id="fMonth" onchange="loadAll()">
|
||
<option value="">Todos</option>
|
||
<option value="1">Enero</option><option value="2">Febrero</option>
|
||
<option value="3">Marzo</option><option value="4">Abril</option>
|
||
<option value="5">Mayo</option><option value="6">Junio</option>
|
||
<option value="7">Julio</option><option value="8">Agosto</option>
|
||
<option value="9">Septiembre</option><option value="10">Octubre</option>
|
||
<option value="11">Noviembre</option><option value="12">Diciembre</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label>Tipo</label>
|
||
<select id="fType" onchange="renderJournal()">
|
||
<option value="">Todos</option>
|
||
<option value="income">Solo Ingresos</option>
|
||
<option value="expense">Solo Gastos</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label>Categoria</label>
|
||
<select id="fCategory" onchange="renderJournal()">
|
||
<option value="">Todas</option>
|
||
<option value="charter">Charter</option>
|
||
<option value="fuel">Combustible</option>
|
||
<option value="work_order">Work Order / Mant.</option>
|
||
<option value="plan_subscription">Plan</option>
|
||
<option value="cleaning">Limpieza</option>
|
||
<option value="detailing">Detailing</option>
|
||
<option value="other_income">Otro Ingreso</option>
|
||
<option value="other_expense">Otro Gasto</option>
|
||
</select>
|
||
</div>
|
||
<div style="margin-left:auto; display:flex; gap:8px; align-items:flex-end;">
|
||
<button class="btn btn-gold" onclick="openModal('entryModal')">+ Asiento Manual</button>
|
||
<button class="btn btn-green" onclick="openModal('fuelModal')">+ Combustible</button>
|
||
<button class="btn" onclick="syncVessel()" title="Importar charters y WOs a la contabilidad">↻ Sincronizar</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- DIARIO / JOURNAL (main) -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3>📝 Diario Contable</h3>
|
||
<div class="card-total">
|
||
Saldo del periodo:
|
||
<span id="periodBalance" style="color:#0a2a3a;font-size:16px;">$0</span>
|
||
</div>
|
||
</div>
|
||
<div style="overflow-x:auto;">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th style="width:90px;">#</th>
|
||
<th style="width:100px;">Fecha</th>
|
||
<th>Descripcion</th>
|
||
<th style="width:110px;">Categoria</th>
|
||
<th style="width:130px;">N. Invoice / Ref</th>
|
||
<th style="width:110px;text-align:right;">Ingreso</th>
|
||
<th style="width:110px;text-align:right;">Gasto</th>
|
||
<th style="width:120px;text-align:right;">Saldo Acum.</th>
|
||
<th style="width:60px;"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="journalBody">
|
||
<tr><td colspan="9" style="text-align:center;color:#999;padding:30px;">Cargando...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CHARTERS (income) -->
|
||
<div class="card" style="border-left-color:#27ae60;">
|
||
<div class="card-header">
|
||
<h3 style="color:#27ae60;">📅 Ingresos por Charters</h3>
|
||
<div class="card-total" style="color:#27ae60;">Total: <span id="charterTotal">$0</span></div>
|
||
</div>
|
||
<table>
|
||
<thead>
|
||
<tr><th>Fecha</th><th>Cliente</th><th>Horas</th><th>Total Charter</th><th>Owner (75%)</th><th>Mgmt (25%)</th><th>Estado</th><th>Invoice</th></tr>
|
||
</thead>
|
||
<tbody id="chartersBody">
|
||
<tr><td colspan="8" style="color:#999;text-align:center;">Cargando...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- MANTENIMIENTO (expenses) -->
|
||
<div class="card" style="border-left-color:#e67e22;">
|
||
<div class="card-header">
|
||
<h3 style="color:#e67e22;">🔧 Gastos de Mantenimiento (Work Orders)</h3>
|
||
<div class="card-total" style="color:#e67e22;">Total: <span id="woTotal">$0</span></div>
|
||
</div>
|
||
<table>
|
||
<thead>
|
||
<tr><th>Fecha</th><th>Descripcion</th><th>Prioridad</th><th>Costo Est.</th><th>Costo Real</th><th>Estado</th><th>Invoice</th></tr>
|
||
</thead>
|
||
<tbody id="woBody">
|
||
<tr><td colspan="7" style="color:#999;text-align:center;">Cargando...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- COMBUSTIBLE -->
|
||
<div class="card" style="border-left-color:#9b59b6;">
|
||
<div class="card-header">
|
||
<h3 style="color:#9b59b6;">⛽ Registro de Combustible</h3>
|
||
<div class="card-total" style="color:#9b59b6;">Total: <span id="fuelTotal">$0</span></div>
|
||
</div>
|
||
<table>
|
||
<thead>
|
||
<tr><th>Fecha</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="8" style="color:#999;text-align:center;">Sin registros de combustible</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Asiento 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>Tipo *</label>
|
||
<select id="entryType" onchange="updateCats()" required>
|
||
<option value="income">Ingreso</option>
|
||
<option value="expense">Gasto</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Fecha *</label>
|
||
<input type="date" id="entryDate" required>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Categoria *</label>
|
||
<select id="entryCat" required></select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Descripcion *</label>
|
||
<input type="text" id="entryDesc" required placeholder="Ej: Ingreso adicional por servicio de catering">
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label>Monto ($) *</label>
|
||
<input type="number" id="entryAmt" step="0.01" min="0.01" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>N. Invoice / Referencia</label>
|
||
<input type="text" id="entryInv" placeholder="Ej: INV-2026-042">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Notas</label>
|
||
<textarea id="entryNotes" placeholder="Detalles adicionales..."></textarea>
|
||
</div>
|
||
<button type="submit" class="btn btn-gold" style="width:100%;">Guardar Asiento</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: 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-group">
|
||
<label>Fecha *</label>
|
||
<input type="date" id="fuelDate" required>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label>Gallons</label>
|
||
<input type="number" id="fuelLiters" step="0.1" min="0" oninput="calcFuel()">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Precio / Galon ($)</label>
|
||
<input type="number" id="fuelPpl" step="0.01" min="0" oninput="calcFuel()">
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="max-width:200px;">
|
||
<label>Total ($) *</label>
|
||
<input type="number" id="fuelTotalAmt" 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="fuelInv" placeholder="Ej: REC-042">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Charter relacionado (opcional)</label>
|
||
<select id="fuelCharter"><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" style="width:100%;">Guardar y Generar Asiento</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const VESSEL_ID = {{ vessel.id }};
|
||
|
||
// ─── Categories ────────────────────────────────────────────
|
||
const CATS = {
|
||
income: {charter:'Charter', plan_subscription:'Suscripcion Plan', other_income:'Otro Ingreso'},
|
||
expense: {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 updateCats() {
|
||
const type = document.getElementById('entryType').value;
|
||
document.getElementById('entryCat').innerHTML =
|
||
Object.entries(CATS[type]).map(([v,l]) => `<option value="${v}">${l}</option>`).join('');
|
||
}
|
||
updateCats();
|
||
|
||
function calcFuel() {
|
||
const l = parseFloat(document.getElementById('fuelLiters').value) || 0;
|
||
const p = parseFloat(document.getElementById('fuelPpl').value) || 0;
|
||
if (l > 0 && p > 0) document.getElementById('fuelTotalAmt').value = (l * p).toFixed(2);
|
||
}
|
||
|
||
// ─── Modal helpers ─────────────────────────────────────────
|
||
function openModal(id) {
|
||
const today = new Date().toISOString().split('T')[0];
|
||
if (id === 'entryModal') document.getElementById('entryDate').value = today;
|
||
if (id === 'fuelModal') {
|
||
document.getElementById('fuelDate').value = today;
|
||
loadCharterSelect();
|
||
}
|
||
document.getElementById(id).style.display = 'flex';
|
||
}
|
||
function closeModal(id) { document.getElementById(id).style.display = 'none'; }
|
||
document.querySelectorAll('.modal').forEach(m => {
|
||
m.addEventListener('click', e => { if (e.target === m) m.style.display = 'none'; });
|
||
});
|
||
|
||
async function loadCharterSelect() {
|
||
const data = await fetch(`/api/charters`).then(r => r.json());
|
||
const mine = data.filter(c => c.vessel_id == VESSEL_ID);
|
||
document.getElementById('fuelCharter').innerHTML =
|
||
'<option value="">-- Ninguno --</option>' +
|
||
mine.map(c => `<option value="${c.id}">${c.start_datetime} – ${c.charterer_name}</option>`).join('');
|
||
}
|
||
|
||
// ─── Message bar ───────────────────────────────────────────
|
||
function showMsg(msg, type) {
|
||
const el = document.getElementById('msgBar');
|
||
el.textContent = msg;
|
||
el.className = 'msg-bar ' + type;
|
||
el.style.display = 'block';
|
||
setTimeout(() => el.style.display = 'none', 4000);
|
||
}
|
||
|
||
// ─── Cached data ───────────────────────────────────────────
|
||
let cachedAcc = null;
|
||
let cachedCharters = null;
|
||
let cachedWO = null;
|
||
let cachedFuel = null;
|
||
|
||
async function loadAll() {
|
||
const year = document.getElementById('fYear').value;
|
||
const month = document.getElementById('fMonth').value;
|
||
let url = `/api/accounting/vessel/${VESSEL_ID}`;
|
||
const params = [];
|
||
if (year) params.push(`year=${year}`);
|
||
if (month) params.push(`month=${month}`);
|
||
if (params.length) url += '?' + params.join('&');
|
||
|
||
const [acc, fuel] = await Promise.all([
|
||
fetch(url).then(r => r.json()),
|
||
fetch(`/api/fuel-entries?vessel_id=${VESSEL_ID}`).then(r => r.json())
|
||
]);
|
||
cachedAcc = acc;
|
||
cachedFuel = fuel;
|
||
|
||
// Also load charters and WOs for dedicated sections
|
||
const [allCharters, allWO] = await Promise.all([
|
||
fetch('/api/charters').then(r => r.json()),
|
||
fetch('/api/workorders').then(r => r.json())
|
||
]);
|
||
cachedCharters = allCharters.filter(c => c.vessel_id == VESSEL_ID);
|
||
cachedWO = allWO.filter(w => w.vessel_name === '{{ vessel.name }}');
|
||
|
||
renderKPIs(acc, fuel);
|
||
renderJournal();
|
||
renderCharters();
|
||
renderWO();
|
||
renderFuel(fuel);
|
||
}
|
||
|
||
function renderKPIs(acc, fuel) {
|
||
const income = acc.entries.filter(e => e.entry_type === 'income');
|
||
const expenses = acc.entries.filter(e => e.entry_type === 'expense');
|
||
const totalIn = income.reduce((s, e) => s + e.amount, 0);
|
||
const totalEx = expenses.reduce((s, e) => s + e.amount, 0);
|
||
const charterIn = income.filter(e => e.category === 'charter').reduce((s, e) => s + e.amount, 0);
|
||
const fuelEx = expenses.filter(e => e.category === 'fuel').reduce((s, e) => s + e.amount, 0);
|
||
|
||
document.getElementById('kpiIncome').textContent = '$' + totalIn.toLocaleString('en-US', {maximumFractionDigits:0});
|
||
document.getElementById('kpiIncomeCount').textContent = income.length + ' transacciones';
|
||
document.getElementById('kpiExpenses').textContent = '$' + totalEx.toLocaleString('en-US', {maximumFractionDigits:0});
|
||
document.getElementById('kpiExpCount').textContent = expenses.length + ' transacciones';
|
||
const profit = totalIn - totalEx;
|
||
const kpiP = document.getElementById('kpiProfit');
|
||
kpiP.textContent = '$' + profit.toLocaleString('en-US', {maximumFractionDigits:0});
|
||
kpiP.style.color = profit >= 0 ? '#27ae60' : '#e74c3c';
|
||
document.getElementById('kpiCharters').textContent = '$' + charterIn.toLocaleString('en-US', {maximumFractionDigits:0});
|
||
document.getElementById('kpiChartersCount').textContent = income.filter(e=>e.category==='charter').length + ' charters';
|
||
document.getElementById('kpiFuel').textContent = '$' + fuelEx.toLocaleString('en-US', {maximumFractionDigits:0});
|
||
document.getElementById('kpiFuelCount').textContent = fuel.length + ' recargas';
|
||
}
|
||
|
||
function catBadge(cat) {
|
||
const labels = {
|
||
charter:'Charter', fuel:'Combustible', work_order:'Mant.', plan_subscription:'Plan',
|
||
cleaning:'Limpieza', detailing:'Detailing', teak:'Teca', marina:'Marina',
|
||
insurance:'Seguro', storage:'Almacenaje', other_income:'Otro +', other_expense:'Otro -'
|
||
};
|
||
const cls = ['charter','fuel','work_order','plan_subscription'].includes(cat) ? cat : 'other';
|
||
return `<span class="badge badge-${cls}">${labels[cat]||cat}</span>`;
|
||
}
|
||
|
||
function renderJournal() {
|
||
if (!cachedAcc) return;
|
||
const typeF = document.getElementById('fType').value;
|
||
const catF = document.getElementById('fCategory').value;
|
||
|
||
let entries = [...cachedAcc.entries];
|
||
if (typeF) entries = entries.filter(e => e.entry_type === typeF);
|
||
if (catF) entries = entries.filter(e => e.category === catF);
|
||
|
||
// Sort oldest→newest for running balance
|
||
entries.sort((a, b) => a.date.localeCompare(b.date));
|
||
|
||
let balance = 0;
|
||
const rows = entries.map((e, i) => {
|
||
const credit = e.entry_type === 'income' ? e.amount : 0;
|
||
const debit = e.entry_type === 'expense' ? e.amount : 0;
|
||
balance += (credit - debit);
|
||
const balColor = balance >= 0 ? '#27ae60' : '#e74c3c';
|
||
return `<tr>
|
||
<td style="font-size:11px;color:#999;">${e.invoice_number || '-'}</td>
|
||
<td>${e.date}</td>
|
||
<td>${e.description}</td>
|
||
<td>${catBadge(e.category)}</td>
|
||
<td style="font-size:11px;color:#666;">${e.reference_type ? e.reference_type.replace('_',' ').toUpperCase() + ' #' + String(e.reference_id||'').padStart(4,'0') : '-'}</td>
|
||
<td class="col-credit">${credit > 0 ? '$' + credit.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2}) : ''}</td>
|
||
<td class="col-debit">${debit > 0 ? '$' + debit.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2}) : ''}</td>
|
||
<td class="col-balance running-balance" style="color:${balColor}">$${balance.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}</td>
|
||
<td><button class="btn btn-danger btn-sm" onclick="deleteEntry(${e.id})">✕</button></td>
|
||
</tr>`;
|
||
});
|
||
|
||
const totalIn = entries.filter(e=>e.entry_type==='income').reduce((s,e)=>s+e.amount,0);
|
||
const totalEx = entries.filter(e=>e.entry_type==='expense').reduce((s,e)=>s+e.amount,0);
|
||
const net = totalIn - totalEx;
|
||
document.getElementById('periodBalance').textContent = '$' + net.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2});
|
||
document.getElementById('periodBalance').style.color = net >= 0 ? '#27ae60' : '#e74c3c';
|
||
|
||
document.getElementById('journalBody').innerHTML = rows.length
|
||
? rows.join('')
|
||
: '<tr><td colspan="9" style="text-align:center;color:#999;padding:25px;">Sin asientos para el periodo / filtros seleccionados</td></tr>';
|
||
}
|
||
|
||
function renderCharters() {
|
||
if (!cachedCharters) return;
|
||
const charters = cachedCharters;
|
||
const totalIncome = charters.filter(c=>c.status==='completed').reduce((s,c)=>s+(c.owner_earnings||0),0);
|
||
document.getElementById('charterTotal').textContent = '$' + totalIncome.toLocaleString('en-US',{maximumFractionDigits:0});
|
||
|
||
const statusBadge = s => {
|
||
const map = {draft:'Borrador',signed:'Firmado',completed:'Completado',paid:'Pagado'};
|
||
const cls = {draft:'#e9ecef',signed:'#cce5ff',completed:'#d4edda',paid:'#c4a747'};
|
||
const tc = {draft:'#495057',signed:'#004085',completed:'#155724',paid:'#0a2a3a'};
|
||
return `<span style="background:${cls[s]||'#eee'};color:${tc[s]||'#333'};padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;">${map[s]||s}</span>`;
|
||
};
|
||
|
||
document.getElementById('chartersBody').innerHTML = charters.length
|
||
? charters.map(c => `<tr>
|
||
<td>${c.start_datetime}</td>
|
||
<td>${c.charterer_name}<br><small style="color:#999;">${c.charterer_phone||''}</small></td>
|
||
<td>${c.hours}h</td>
|
||
<td style="font-weight:600;">$${c.total_base_rate}</td>
|
||
<td style="color:#27ae60;font-weight:600;">$${c.owner_earnings||0}</td>
|
||
<td style="color:#e67e22;font-weight:600;">$${c.management_earnings||0}</td>
|
||
<td>${statusBadge(c.status)}</td>
|
||
<td style="font-size:11px;color:#666;">CHR-${String(c.id).padStart(4,'0')}</td>
|
||
</tr>`).join('')
|
||
: '<tr><td colspan="8" style="color:#999;text-align:center;">Sin charters registrados</td></tr>';
|
||
}
|
||
|
||
function renderWO() {
|
||
if (!cachedWO) return;
|
||
const wos = cachedWO;
|
||
const totalCost = wos.filter(w=>w.status==='done').reduce((s,w)=>s+(w.actual_cost||w.estimated_cost||0),0);
|
||
document.getElementById('woTotal').textContent = '$' + totalCost.toLocaleString('en-US',{maximumFractionDigits:0});
|
||
|
||
const priBadge = p => {
|
||
if (p==='emergencia') return '<span style="background:#f8d7da;color:#721c24;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;">🚨 Emergencia</span>';
|
||
if (p==='urgente') return '<span style="background:#ffeeba;color:#856404;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;">⚠ Urgente</span>';
|
||
return '<span style="background:#e9ecef;color:#495057;padding:2px 8px;border-radius:10px;font-size:11px;">Normal</span>';
|
||
};
|
||
const stBadge = s => {
|
||
const map = {pending:'Pendiente',approved:'Aprobado',done:'Completado',rejected:'Rechazado'};
|
||
const cls = {pending:'#fff3cd',approved:'#cce5ff',done:'#d4edda',rejected:'#f8d7da'};
|
||
const tc = {pending:'#856404',approved:'#004085',done:'#155724',rejected:'#721c24'};
|
||
return `<span style="background:${cls[s]||'#eee'};color:${tc[s]||'#333'};padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;">${map[s]||s}</span>`;
|
||
};
|
||
|
||
document.getElementById('woBody').innerHTML = wos.length
|
||
? wos.map(w => `<tr>
|
||
<td>${w.created_at}</td>
|
||
<td>${w.description}</td>
|
||
<td>${priBadge(w.priority)}</td>
|
||
<td>$${w.estimated_cost||0}</td>
|
||
<td style="font-weight:600;color:#e74c3c;">${w.actual_cost ? '$'+w.actual_cost : '-'}</td>
|
||
<td>${stBadge(w.status)}</td>
|
||
<td style="font-size:11px;color:#666;">WO-${String(w.id).padStart(4,'0')}</td>
|
||
</tr>`).join('')
|
||
: '<tr><td colspan="7" style="color:#999;text-align:center;">Sin work orders</td></tr>';
|
||
}
|
||
|
||
function renderFuel(fuel) {
|
||
const totalFuel = fuel.reduce((s,f) => s + (f.total_cost||0), 0);
|
||
document.getElementById('fuelTotal').textContent = '$' + totalFuel.toLocaleString('en-US',{maximumFractionDigits:0});
|
||
|
||
document.getElementById('fuelBody').innerHTML = fuel.length
|
||
? fuel.map(f => `<tr>
|
||
<td>${f.date}</td>
|
||
<td>${f.liters ? f.liters + ' gal' : '-'}</td>
|
||
<td>${f.price_per_liter ? '$' + f.price_per_liter + '/gal' : '-'}</td>
|
||
<td style="font-weight:600;color:#9b59b6;">$${f.total_cost}</td>
|
||
<td>${f.supplier||'-'}</td>
|
||
<td style="font-size:11px;color:#666;">${f.invoice_number||'-'}</td>
|
||
<td style="font-size:11px;">${f.charter_id ? 'CHR-'+String(f.charter_id).padStart(4,'0') : '-'}</td>
|
||
<td><button class="btn btn-danger btn-sm" onclick="deleteFuel(${f.id})">×</button></td>
|
||
</tr>`).join('')
|
||
: '<tr><td colspan="8" style="color:#999;text-align:center;">Sin registros de combustible</td></tr>';
|
||
}
|
||
|
||
// ─── Actions ───────────────────────────────────────────────
|
||
async function deleteEntry(id) {
|
||
if (!confirm('Eliminar este asiento?')) return;
|
||
await fetch(`/api/accounting/entries/${id}`, {method: 'DELETE'});
|
||
showMsg('Asiento eliminado', 'success');
|
||
loadAll();
|
||
}
|
||
|
||
async function deleteFuel(id) {
|
||
if (!confirm('Eliminar este registro de combustible?')) return;
|
||
await fetch(`/api/fuel-entries/${id}`, {method: 'DELETE'});
|
||
showMsg('Registro eliminado', 'success');
|
||
loadAll();
|
||
}
|
||
|
||
async function syncVessel() {
|
||
const res = await fetch(`/api/accounting/sync-vessel/${VESSEL_ID}`, {method: 'POST'});
|
||
const data = await res.json();
|
||
showMsg(`Sincronizacion completada: ${data.entries_created} asientos nuevos generados`, 'success');
|
||
loadAll();
|
||
}
|
||
|
||
// ─── Form submissions ─────────────────────────────────────
|
||
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: VESSEL_ID,
|
||
date: document.getElementById('entryDate').value,
|
||
entry_type: document.getElementById('entryType').value,
|
||
category: document.getElementById('entryCat').value,
|
||
description: document.getElementById('entryDesc').value,
|
||
amount: document.getElementById('entryAmt').value,
|
||
invoice_number: document.getElementById('entryInv').value,
|
||
notes: document.getElementById('entryNotes').value
|
||
})
|
||
});
|
||
closeModal('entryModal');
|
||
document.getElementById('entryForm').reset();
|
||
updateCats();
|
||
showMsg('Asiento contable guardado', 'success');
|
||
loadAll();
|
||
};
|
||
|
||
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: VESSEL_ID,
|
||
date: document.getElementById('fuelDate').value,
|
||
liters: document.getElementById('fuelLiters').value || 0,
|
||
price_per_liter: document.getElementById('fuelPpl').value || 0,
|
||
total_cost: document.getElementById('fuelTotalAmt').value,
|
||
supplier: document.getElementById('fuelSupplier').value,
|
||
invoice_number: document.getElementById('fuelInv').value,
|
||
charter_id: document.getElementById('fuelCharter').value || null,
|
||
notes: document.getElementById('fuelNotes').value
|
||
})
|
||
});
|
||
closeModal('fuelModal');
|
||
document.getElementById('fuelForm').reset();
|
||
showMsg('Combustible registrado y asiento generado automaticamente', 'success');
|
||
loadAll();
|
||
};
|
||
|
||
// ─── Init ──────────────────────────────────────────────────
|
||
loadAll();
|
||
</script>
|
||
</body>
|
||
</html>
|