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>
This commit is contained in:
2026-05-05 02:54:10 -04:00
parent 0b976a1ec0
commit 5b7b41aa50
23 changed files with 6375 additions and 0 deletions
+453
View File
@@ -0,0 +1,453 @@
<!DOCTYPE html>
<html>
<head>
<title>Companies & Users — Fleet Management</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; }
.header-actions { display: flex; gap: 12px; align-items: center; }
.back-btn { background: transparent; color: #c4a747; padding: 8px 18px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 14px; border: 1px solid #c4a747; }
.back-btn:hover { background: #c4a747; color: #0a2a3a; }
.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; }
.container { padding: 25px 30px; max-width: 1100px; margin: 0 auto; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 22px; flex-wrap: wrap; gap: 10px; }
.page-header h2 { color: #0a2a3a; font-size: 22px; }
.quota-badge { background: white; border: 2px solid #c4a747; border-radius: 20px; padding: 6px 16px; font-size: 13px; font-weight: 700; color: #0a2a3a; }
.quota-badge span { color: #c4a747; }
.company-block { background: white; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 1px 6px rgba(0,0,0,0.08); overflow: hidden; }
.company-header { background: #0a2a3a; color: white; padding: 14px 20px; display: flex; justify-content: space-between; align-items: center; }
.company-header h3 { font-size: 16px; }
.company-header h3 .company-email { font-size: 12px; color: #aac4d0; font-weight: 400; margin-left: 10px; }
.company-meta { font-size: 12px; color: #aac4d0; margin-top: 2px; }
.company-actions { display: flex; gap: 8px; }
.users-section { padding: 18px 20px; }
.users-section h4 { font-size: 13px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; font-weight: 600; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { background: #f4f6f8; color: #0a2a3a; padding: 9px 12px; text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; border-bottom: 2px solid #e8e8e8; }
td { padding: 10px 12px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafaf8; }
.btn { background: #0a2a3a; color: white; padding: 7px 14px; border: none; border-radius: 5px; cursor: pointer; font-size: 12px; font-weight: 600; text-decoration: none; display: inline-block; transition: background 0.2s; }
.btn:hover { background: #c4a747; color: #0a2a3a; }
.btn-sm { padding: 4px 10px; font-size: 11px; }
.btn-gold { background: #c4a747; color: #0a2a3a; }
.btn-gold:hover { background: #d4b757; }
.btn-danger { background: #e74c3c; color: white; }
.btn-danger:hover { background: #c0392b; }
.btn-success { background: #27ae60; color: white; }
.btn-success:hover { background: #219a52; }
.badge { display: inline-block; padding: 2px 9px; border-radius: 10px; font-size: 11px; font-weight: 700; }
.badge-super { background: #c4a747; color: #0a2a3a; }
.badge-admin { background: #d4edda; color: #155724; }
.badge-active { background: #d4edda; color: #155724; }
.badge-inactive { background: #f8d7da; color: #721c24; }
.vessel-count { background: #f0e8cc; color: #0a2a3a; border-radius: 10px; padding: 2px 10px; font-size: 12px; font-weight: 700; display: inline-block; }
.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: 60px; overflow-y: auto; }
.modal.open { display: flex; }
.modal-content { background: white; padding: 28px; border-radius: 10px; width: 520px; 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: 17px; }
.close-btn { cursor: pointer; font-size: 26px; color: #999; line-height: 1; background: none; border: none; }
.close-btn:hover { color: #0a2a3a; }
.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 { width: 100%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
.form-group input:focus, .form-group select:focus { outline: none; border-color: #c4a747; }
.hint { font-size: 11px; color: #888; margin-top: 3px; }
.section-sep { font-size: 11px; font-weight: 700; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin: 16px 0 10px; padding-bottom: 5px; border-bottom: 1px dashed #e0d8b0; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.msg-bar { padding: 10px 16px; border-radius: 6px; font-size: 14px; font-weight: 600; margin-bottom: 16px; display: none; }
.msg-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; display: block; }
.msg-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; display: block; }
.empty-users { color: #999; font-size: 13px; font-style: italic; padding: 10px 0; }
</style>
</head>
<body>
<div class="header">
<h1>Fleet <span>Management</span></h1>
<div class="header-actions">
<a href="/admin/dashboard" class="back-btn">&#8592; Dashboard</a>
<a href="{{ url_for('auth.logout') }}" class="logout-btn">Cerrar Sesion</a>
</div>
</div>
<div class="container">
<div class="page-header">
<h2>&#9776; Companies &amp; Users</h2>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<div class="quota-badge">Companies: <span>{{ companies|length }}</span> / 10</div>
{% if companies|length < 10 %}
<button class="btn btn-gold" onclick="openCreateCompany()">+ New Company</button>
{% endif %}
</div>
</div>
<div id="msgBar" class="msg-bar"></div>
{% for c in companies %}
{% set vessel_count = c.managed_vessels | length %}
<div class="company-block">
<div class="company-header">
<div>
<h3>
{{ c.name }}
<span class="company-email">{{ c.email or '' }}</span>
</h3>
<div class="company-meta">
{% if c.phone %}&#128222; {{ c.phone }} &nbsp;&bull;&nbsp;{% endif %}
<span class="vessel-count">{{ vessel_count }} vessel{{ 's' if vessel_count != 1 else '' }}</span>
&nbsp;&bull;&nbsp; {{ c.users | length }} user{{ 's' if c.users|length != 1 else '' }}
</div>
</div>
<div class="company-actions">
<button class="btn btn-sm" onclick="openAddUser({{ c.id }}, {{ c.name | tojson }})">+ Add User</button>
<button class="btn btn-sm btn-gold" onclick='openEditCompany({{ c.id }}, {{ c.name|tojson }}, {{ (c.email or "")|tojson }}, {{ (c.phone or "")|tojson }})'>Edit</button>
</div>
</div>
<div class="users-section">
<h4>Users</h4>
{% if c.users %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for u in c.users %}
<tr>
<td>
<strong>{{ u.name }}</strong>
{% if u.is_super_admin %}<span class="badge badge-super">&#9733; Super Admin</span>{% endif %}
</td>
<td>{{ u.email }}</td>
<td><span class="badge badge-admin">{{ u.role | capitalize }}</span></td>
<td>
{% if u.is_active %}
<span class="badge badge-active">Active</span>
{% else %}
<span class="badge badge-inactive">Inactive</span>
{% endif %}
</td>
<td>
<button class="btn btn-sm" onclick='openEditUser({{ u.id }}, {{ c.id }}, {{ u.name|tojson }}, {{ u.email|tojson }}, {{ u.role|tojson }}, {{ u.is_super_admin|tojson }}, {{ u.is_active|tojson }})'>Edit</button>
{% if not u.is_super_admin %}
<button class="btn btn-sm btn-danger" onclick="deleteUser({{ u.id }}, {{ c.id }})">Delete</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-users">No users assigned to this company yet. Click "+ Add User" to create one.</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- MODAL: New Company -->
<div id="createCompanyModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>New Management Company</h3>
<button class="close-btn" onclick="closeModal('createCompanyModal')">&times;</button>
</div>
<div class="section-sep">Company Info</div>
<div class="form-group"><label>Company Name *</label><input type="text" id="cc_name" placeholder="Miami Marine Management LLC"></div>
<div class="form-row">
<div class="form-group"><label>Company Email</label><input type="email" id="cc_email" placeholder="info@company.com"></div>
<div class="form-group"><label>Phone</label><input type="text" id="cc_phone" placeholder="305-000-0000"></div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:18px;">
<button class="btn" onclick="closeModal('createCompanyModal')" style="background:#ccc;color:#333;">Cancel</button>
<button class="btn btn-gold" onclick="createCompany()">Create Company</button>
</div>
</div>
</div>
<!-- MODAL: Edit Company -->
<div id="editCompanyModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Edit Company</h3>
<button class="close-btn" onclick="closeModal('editCompanyModal')">&times;</button>
</div>
<input type="hidden" id="ec_id">
<div class="form-group"><label>Company Name *</label><input type="text" id="ec_name"></div>
<div class="form-row">
<div class="form-group"><label>Company Email</label><input type="email" id="ec_email"></div>
<div class="form-group"><label>Phone</label><input type="text" id="ec_phone"></div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:18px;">
<button class="btn" onclick="closeModal('editCompanyModal')" style="background:#ccc;color:#333;">Cancel</button>
<button class="btn btn-gold" onclick="updateCompany()">Save Changes</button>
</div>
</div>
</div>
<!-- MODAL: Add User -->
<div id="addUserModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Add User to <span id="addUserCompanyName" style="color:#c4a747;"></span></h3>
<button class="close-btn" onclick="closeModal('addUserModal')">&times;</button>
</div>
<input type="hidden" id="au_company_id">
<div class="form-row">
<div class="form-group"><label>Full Name *</label><input type="text" id="au_name" placeholder="John Smith"></div>
<div class="form-group"><label>Email *</label><input type="email" id="au_email" placeholder="user@company.com"></div>
</div>
<div class="form-row">
<div class="form-group">
<label>Password *</label>
<input type="password" id="au_password" placeholder="Min. 6 characters">
</div>
<div class="form-group">
<label>Role</label>
<select id="au_role">
<option value="admin">Admin</option>
<option value="operator">Operator</option>
</select>
</div>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:400;">
<input type="checkbox" id="au_super" style="width:auto;">
Grant Super Admin privileges (can manage all companies and users)
</label>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:18px;">
<button class="btn" onclick="closeModal('addUserModal')" style="background:#ccc;color:#333;">Cancel</button>
<button class="btn btn-success" onclick="addUser()">Add User</button>
</div>
</div>
</div>
<!-- MODAL: Edit User -->
<div id="editUserModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Edit User</h3>
<button class="close-btn" onclick="closeModal('editUserModal')">&times;</button>
</div>
<input type="hidden" id="eu_id">
<input type="hidden" id="eu_company_id">
<div class="form-row">
<div class="form-group"><label>Full Name *</label><input type="text" id="eu_name"></div>
<div class="form-group"><label>Email *</label><input type="email" id="eu_email"></div>
</div>
<div class="form-row">
<div class="form-group">
<label>New Password <span style="font-weight:400;color:#888;">(leave blank to keep)</span></label>
<input type="password" id="eu_password" placeholder="New password...">
</div>
<div class="form-group">
<label>Role</label>
<select id="eu_role">
<option value="admin">Admin</option>
<option value="operator">Operator</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:400;">
<input type="checkbox" id="eu_super" style="width:auto;">
Super Admin privileges
</label>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:400;">
<input type="checkbox" id="eu_active" style="width:auto;" checked>
Active account
</label>
</div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:18px;">
<button class="btn" onclick="closeModal('editUserModal')" style="background:#ccc;color:#333;">Cancel</button>
<button class="btn btn-gold" onclick="updateUser()">Save Changes</button>
</div>
</div>
</div>
<script>
function showMsg(msg, type) {
const bar = document.getElementById('msgBar');
bar.textContent = msg;
bar.className = 'msg-bar msg-' + type;
bar.scrollIntoView({behavior:'smooth', block:'nearest'});
setTimeout(() => { bar.className = 'msg-bar'; }, 4000);
}
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
document.querySelectorAll('.modal').forEach(m => {
m.addEventListener('click', e => { if (e.target === m) m.classList.remove('open'); });
});
// ---- COMPANY ----
function openCreateCompany() {
['cc_name','cc_email','cc_phone'].forEach(id => document.getElementById(id).value = '');
document.getElementById('createCompanyModal').classList.add('open');
}
function openEditCompany(id, name, email, phone) {
document.getElementById('ec_id').value = id;
document.getElementById('ec_name').value = name;
document.getElementById('ec_email').value = email;
document.getElementById('ec_phone').value = phone;
document.getElementById('editCompanyModal').classList.add('open');
}
async function createCompany() {
const name = document.getElementById('cc_name').value.trim();
if (!name) { showMsg('Company name is required.', 'error'); return; }
const res = await fetch('/api/management-companies', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
name,
company_email: document.getElementById('cc_email').value.trim(),
phone: document.getElementById('cc_phone').value.trim()
})
});
const data = await res.json();
if (data.success) {
showMsg('Company created. Reloading...', 'success');
closeModal('createCompanyModal');
setTimeout(() => location.reload(), 1200);
} else {
showMsg(data.error || 'Error creating company', 'error');
}
}
async function updateCompany() {
const id = document.getElementById('ec_id').value;
const res = await fetch('/api/management-companies/' + id, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
name: document.getElementById('ec_name').value.trim(),
company_email: document.getElementById('ec_email').value.trim(),
phone: document.getElementById('ec_phone').value.trim()
})
});
const data = await res.json();
if (data.success) {
showMsg('Company updated. Reloading...', 'success');
closeModal('editCompanyModal');
setTimeout(() => location.reload(), 1200);
} else {
showMsg(data.error || 'Error', 'error');
}
}
// ---- USERS ----
function openAddUser(companyId, companyName) {
document.getElementById('au_company_id').value = companyId;
document.getElementById('addUserCompanyName').textContent = companyName;
['au_name','au_email','au_password'].forEach(id => document.getElementById(id).value = '');
document.getElementById('au_role').value = 'admin';
document.getElementById('au_super').checked = false;
document.getElementById('addUserModal').classList.add('open');
}
function openEditUser(userId, companyId, name, email, role, isSuper, isActive) {
document.getElementById('eu_id').value = userId;
document.getElementById('eu_company_id').value = companyId;
document.getElementById('eu_name').value = name;
document.getElementById('eu_email').value = email;
document.getElementById('eu_password').value = '';
document.getElementById('eu_role').value = role;
document.getElementById('eu_super').checked = isSuper;
document.getElementById('eu_active').checked = isActive;
document.getElementById('editUserModal').classList.add('open');
}
async function addUser() {
const companyId = document.getElementById('au_company_id').value;
const name = document.getElementById('au_name').value.trim();
const email = document.getElementById('au_email').value.trim();
const pwd = document.getElementById('au_password').value;
if (!name || !email || !pwd) { showMsg('Name, email and password are required.', 'error'); return; }
const res = await fetch(`/api/management-companies/${companyId}/users`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
name, email, password: pwd,
role: document.getElementById('au_role').value,
is_super_admin: document.getElementById('au_super').checked
})
});
const data = await res.json();
if (data.success) {
showMsg('User added successfully. Reloading...', 'success');
closeModal('addUserModal');
setTimeout(() => location.reload(), 1200);
} else {
showMsg(data.error || 'Error adding user', 'error');
}
}
async function updateUser() {
const id = document.getElementById('eu_id').value;
const companyId = document.getElementById('eu_company_id').value;
const payload = {
name: document.getElementById('eu_name').value.trim(),
email: document.getElementById('eu_email').value.trim(),
role: document.getElementById('eu_role').value,
is_super_admin: document.getElementById('eu_super').checked,
is_active: document.getElementById('eu_active').checked
};
const pwd = document.getElementById('eu_password').value;
if (pwd) payload.password = pwd;
const res = await fetch(`/api/management-companies/${companyId}/users/${id}`, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.success) {
showMsg('User updated. Reloading...', 'success');
closeModal('editUserModal');
setTimeout(() => location.reload(), 1200);
} else {
showMsg(data.error || 'Error', 'error');
}
}
async function deleteUser(userId, companyId) {
if (!confirm('Delete this user? This action cannot be undone.')) return;
const res = await fetch(`/api/management-companies/${companyId}/users/${userId}`, {method: 'DELETE'});
const data = await res.json();
if (data.success) {
showMsg('User deleted. Reloading...', 'success');
setTimeout(() => location.reload(), 1000);
} else {
showMsg(data.error || 'Error', 'error');
}
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+703
View File
@@ -0,0 +1,703 @@
<!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>