Files
fleet-management/app/templates/admin/vessel_accounting.html
T
alro65 5b7b41aa50 Initial commit: Fleet Management app with security hardening and background launcher
- 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>
2026-05-05 02:54:10 -04:00

704 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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">&#8592; Volver al Dashboard</a>
<h1>Fleet <span>Management</span> &mdash; 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>&#128676; {{ 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">&#8635; Sincronizar</button>
</div>
</div>
<!-- DIARIO / JOURNAL (main) -->
<div class="card">
<div class="card-header">
<h3>&#128221; 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;">&#128197; 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;">&#128295; 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;">&#9981; 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')">&times;</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')">&times;</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;">&#128680; Emergencia</span>';
if (p==='urgente') return '<span style="background:#ffeeba;color:#856404;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;">&#9888; 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})">&#215;</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>