Files
fleet-management/app/templates/owner/dashboard.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

382 lines
21 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<title>Owner Portal - 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: 20px; }
.header h1 span { color: #c4a747; }
.header-right { display: flex; align-items: center; gap: 15px; }
.user-info { font-size: 13px; color: #c4a747; }
.logout-btn { background: #c4a747; color: #0a2a3a; padding: 8px 18px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 14px; }
.logout-btn:hover { background: #d4b757; }
.container { padding: 25px 30px; max-width: 1200px; margin: 0 auto; }
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; margin-bottom: 20px; }
.stat-card { background: white; border-radius: 10px; padding: 20px 15px; text-align: center; border-top: 4px solid #c4a747; box-shadow: 0 1px 6px rgba(0,0,0,0.07); }
.stat-card h4 { color: #666; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
.stat-card .value { font-size: 28px; font-weight: bold; color: #0a2a3a; }
.card { background: white; border-radius: 10px; padding: 25px; margin-bottom: 20px; box-shadow: 0 1px 6px rgba(0,0,0,0.07); border-left: 4px solid #c4a747; }
.card h3 { color: #0a2a3a; margin-bottom: 18px; border-bottom: 2px solid #f0e8cc; padding-bottom: 10px; font-size: 16px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { padding: 11px 12px; text-align: left; border-bottom: 1px solid #f0f0f0; }
th { background: #0a2a3a; color: white; font-weight: 600; }
tr:hover td { background: #fafaf8; }
.btn { padding: 7px 16px; border: none; border-radius: 5px; cursor: pointer; font-size: 13px; font-weight: 600; transition: background 0.2s; }
.btn-approve { background: #27ae60; color: white; }
.btn-approve:hover { background: #2ecc71; }
.btn-reject { background: #e74c3c; color: white; }
.btn-reject:hover { background: #c0392b; }
.badge { display: inline-block; padding: 3px 9px; border-radius: 12px; font-size: 11px; font-weight: 600; }
.badge-pending { background: #fff3cd; color: #856404; }
.badge-approved { background: #d4edda; color: #155724; }
.badge-rejected { background: #f8d7da; color: #721c24; }
.badge-done { background: #cce5ff; color: #004085; }
.badge-normal { background: #e9ecef; color: #495057; }
.badge-urgente { background: #ffeeba; color: #856404; border:1px solid #f39c12; }
.badge-emergencia{ background: #f8d7da; color: #721c24; border:1px solid #e74c3c; }
.message-bar { padding: 10px 15px; margin-bottom: 15px; border-radius: 5px; display: none; font-size: 14px; font-weight: 600; }
.message-bar.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.message-bar.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
/* Approval proof box */
.approval-proof { background: #f0faf4; border: 1px solid #b7dfc8; border-radius: 6px; padding: 10px 14px; font-size: 12px; color: #155724; margin-top: 6px; }
.approval-proof strong { display: block; margin-bottom:3px; }
.rejection-proof { background: #fdf5f5; border: 1px solid #f5c6cb; border-radius: 6px; padding: 10px 14px; font-size: 12px; color: #721c24; margin-top: 6px; }
/* Modal */
.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; }
.modal-content { background: white; padding: 30px; border-radius: 10px; width: 500px; max-width: 95%; }
.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; }
.close { cursor: pointer; font-size: 26px; color: #999; }
.close: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 textarea { width: 100%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; min-height: 80px; }
.pending-alert { background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px; padding: 12px 16px; margin-bottom: 18px; font-size: 14px; color: #856404; font-weight: 600; }
.emergency-alert { background: #f8d7da; border: 2px solid #e74c3c; border-radius: 8px; padding: 12px 16px; margin-bottom: 18px; font-size: 14px; color: #721c24; font-weight: 600; }
@media (max-width: 700px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<div class="header">
<h1>Fleet <span>Management</span> &mdash; Owner Portal</h1>
<div class="header-right">
<span class="user-info" id="welcomeUser"></span>
<a href="{{ url_for('auth.logout') }}" class="logout-btn">Cerrar Sesion</a>
</div>
</div>
<div class="container">
<div id="message" class="message-bar"></div>
<!-- Alertas de emergencia -->
<div id="alertsArea"></div>
<!-- KPIs -->
<div class="stats-grid">
<div class="stat-card"><h4>Mis Botes</h4><div class="value" id="myVessels">-</div></div>
<div class="stat-card"><h4>Ingresos Charters</h4><div class="value" id="myRevenue">$-</div></div>
<div class="stat-card"><h4>Gastos (Planes)</h4><div class="value" id="myExpenses">$-</div></div>
<div class="stat-card"><h4>Utilidad Neta</h4><div class="value" id="myProfit">$-</div></div>
</div>
<!-- Embarcaciones -->
<div class="card">
<h3>Mis Embarcaciones</h3>
<table>
<thead>
<tr>
<th>Nombre</th><th>Marca / Modelo</th><th>Eslora</th>
<th>Plan</th><th>Costo/mes</th><th>Charters</th>
<th>Ingresos</th><th>Utilidad</th>
</tr>
</thead>
<tbody id="vesselsBody">
<tr><td colspan="8" style="color:#999;text-align:center;">Cargando...</td></tr>
</tbody>
</table>
</div>
<!-- Work Orders: pendientes de aprobacion -->
<div class="card" id="pendingWoCard">
<h3>Work Orders Pendientes de Aprobacion</h3>
<p style="font-size:13px;color:#666;margin-bottom:14px;">
Su aprobacion es requerida para autorizar los trabajos de mantenimiento.
Cada aprobacion queda registrada con su nombre, fecha y hora como prueba.
</p>
<div id="pendingWoBody">
<p style="color:#999;font-size:13px;">Sin work orders pendientes.</p>
</div>
</div>
<!-- Historial completo de Work Orders -->
<div class="card">
<h3>Historial de Work Orders</h3>
<table>
<thead>
<tr>
<th>Fecha</th><th>Bote</th><th>Descripcion</th>
<th>Costo Est.</th><th>Costo Real</th><th>Prioridad</th>
<th>Estado</th><th>Registro de Aprobacion</th>
</tr>
</thead>
<tbody id="woHistoryBody">
<tr><td colspan="8" style="color:#999;text-align:center;">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Modal: Confirmar Aprobacion -->
<div id="approveModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Confirmar Aprobacion</h3>
<span class="close" onclick="closeModal('approveModal')">&times;</span>
</div>
<input type="hidden" id="approveWoId">
<div id="approveWoDetail" style="background:#f7f9fa;padding:14px;border-radius:6px;margin-bottom:18px;font-size:13px;"></div>
<p style="font-size:13px;color:#555;margin-bottom:18px;">
Al confirmar, usted autoriza la ejecucion de este trabajo.
Su nombre, fecha y hora quedaran registrados como prueba de aprobacion.
</p>
<div style="display:flex;gap:10px;">
<button class="btn btn-approve" style="flex:1;" onclick="confirmApprove()">Si, Aprobar Work Order</button>
<button class="btn" style="background:#e9ecef;color:#333;flex:1;" onclick="closeModal('approveModal')">Cancelar</button>
</div>
</div>
</div>
<!-- Modal: Confirmar Rechazo -->
<div id="rejectModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Rechazar Work Order</h3>
<span class="close" onclick="closeModal('rejectModal')">&times;</span>
</div>
<input type="hidden" id="rejectWoId">
<div id="rejectWoDetail" style="background:#fdf5f5;padding:14px;border-radius:6px;margin-bottom:18px;font-size:13px;"></div>
<div class="form-group">
<label>Motivo del rechazo (requerido)</label>
<textarea id="rejectReason" placeholder="Explique por que rechaza este trabajo..." required></textarea>
</div>
<p style="font-size:12px;color:#888;margin-bottom:14px;">El rechazo tambien queda registrado con su nombre y motivo.</p>
<div style="display:flex;gap:10px;">
<button class="btn btn-reject" style="flex:1;" onclick="confirmReject()">Confirmar Rechazo</button>
<button class="btn" style="background:#e9ecef;color:#333;flex:1;" onclick="closeModal('rejectModal')">Cancelar</button>
</div>
</div>
</div>
<script>
function showMessage(msg, type) {
const el = document.getElementById('message');
el.textContent = msg;
el.className = 'message-bar ' + type;
el.style.display = 'block';
setTimeout(() => el.style.display = 'none', 5000);
}
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'; });
});
const priBadge = p => {
if (p === 'emergencia') return '<span class="badge badge-emergencia">&#128680; Emergencia</span>';
if (p === 'urgente') return '<span class="badge badge-urgente">&#9888;&#65039; Urgente</span>';
return '<span class="badge badge-normal">Normal</span>';
};
const statusBadge = s => {
const map = {pending:'Pendiente', approved:'Aprobado', rejected:'Rechazado', done:'Completado'};
return `<span class="badge badge-${s}">${map[s]||s}</span>`;
};
async function loadData() {
try {
const data = await fetch('/api/owner/dashboard').then(r => r.json());
document.getElementById('welcomeUser').textContent = 'Bienvenido, ' + (data.owner_name || '');
document.getElementById('myVessels').textContent = data.vessels_count;
document.getElementById('myRevenue').textContent = '$' + data.total_revenue.toLocaleString();
document.getElementById('myExpenses').textContent = '$' + data.total_expenses.toLocaleString();
const profit = data.net_profit;
const profitEl = document.getElementById('myProfit');
profitEl.textContent = '$' + profit.toLocaleString();
profitEl.style.color = profit >= 0 ? '#27ae60' : '#e74c3c';
// Vessels
document.getElementById('vesselsBody').innerHTML = data.vessels.length
? data.vessels.map(v => `<tr>
<td><strong>${v.name}</strong></td>
<td>${v.make ? v.make + (v.model ? ' ' + v.model : '') : '-'}</td>
<td>${v.length ? v.length + ' ft' : '-'}</td>
<td>${v.plan}</td>
<td>$${v.plan_cost}</td>
<td>${v.charters_count || 0}</td>
<td style="color:#27ae60;font-weight:600;">$${v.charter_revenue}</td>
<td style="font-weight:bold;color:${v.net_profit >= 0 ? '#27ae60' : '#e74c3c'}">$${v.net_profit}</td>
</tr>`).join('')
: '<tr><td colspan="8" style="color:#999;text-align:center;">Sin embarcaciones</td></tr>';
// Split WOs: pending vs all
const pendingWos = data.workorders.filter(wo => wo.status === 'pending');
const allWos = data.workorders;
// Emergency alerts
const alerts = document.getElementById('alertsArea');
const emergencies = pendingWos.filter(wo => wo.priority === 'emergencia');
if (emergencies.length) {
alerts.innerHTML = emergencies.map(wo =>
`<div class="emergency-alert">
&#128680; EMERGENCIA — <strong>${wo.vessel_name}</strong>:
${wo.description} — El bote NO puede ir a charter.
Apruebe esta work order de inmediato.
</div>`
).join('');
} else {
alerts.innerHTML = '';
}
// Pending WOs — approval cards
if (pendingWos.length) {
document.getElementById('pendingWoBody').innerHTML = pendingWos.map(wo => `
<div style="border:1px solid ${wo.priority==='emergencia'?'#e74c3c':wo.priority==='urgente'?'#f39c12':'#ddd'};border-radius:8px;padding:16px;margin-bottom:14px;background:${wo.priority==='emergencia'?'#fff5f5':wo.priority==='urgente'?'#fffbf0':'#fafafa'};">
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;">
<div style="flex:1;min-width:250px;">
<div style="font-weight:bold;color:#0a2a3a;font-size:15px;margin-bottom:4px;">${wo.vessel_name}</div>
<div style="font-size:13px;color:#444;margin-bottom:8px;">${wo.description}</div>
<div style="font-size:12px;color:#666;">
Solicitado: ${wo.created_at} &nbsp;|&nbsp;
Costo estimado: <strong>$${wo.estimated_cost || 0}</strong>
&nbsp;|&nbsp; ${priBadge(wo.priority)}
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<button class="btn btn-approve" onclick="openApprove(${wo.id}, '${wo.vessel_name}', '${wo.description.replace(/'/g,"\\'")}', ${wo.estimated_cost||0})">
&#10003; Aprobar
</button>
<button class="btn btn-reject" onclick="openReject(${wo.id}, '${wo.vessel_name}', '${wo.description.replace(/'/g,"\\'")}')">
&#10007; Rechazar
</button>
</div>
</div>
</div>`).join('');
} else {
document.getElementById('pendingWoBody').innerHTML =
'<p style="color:#27ae60;font-size:14px;padding:10px 0;">&#10003; Sin work orders pendientes de aprobacion.</p>';
}
// Full WO history with approval proof
document.getElementById('woHistoryBody').innerHTML = allWos.length
? allWos.map(wo => {
let proofHtml = '';
if (wo.status === 'approved' || wo.status === 'done') {
proofHtml = `<div class="approval-proof">
<strong>&#10003; Aprobado por: ${wo.approved_by_name || 'Owner'}</strong>
Fecha: ${wo.approved_at || '-'}
</div>`;
} else if (wo.status === 'rejected') {
proofHtml = `<div class="rejection-proof">
<strong>&#10007; Rechazado por: ${wo.approved_by_name || 'Owner'}</strong>
Fecha: ${wo.rejected_at || '-'}<br>
${wo.rejection_reason ? 'Motivo: ' + wo.rejection_reason : ''}
</div>`;
} else {
proofHtml = '<span style="color:#999;font-size:12px;">Pendiente</span>';
}
return `<tr>
<td>${wo.created_at}</td>
<td>${wo.vessel_name}</td>
<td>${wo.description}</td>
<td>$${wo.estimated_cost || 0}</td>
<td>${wo.actual_cost ? '$'+wo.actual_cost : '-'}</td>
<td>${priBadge(wo.priority)}</td>
<td>${statusBadge(wo.status)}</td>
<td>${proofHtml}</td>
</tr>`;
}).join('')
: '<tr><td colspan="8" style="color:#999;text-align:center;">Sin work orders</td></tr>';
} catch(e) {
console.error(e);
showMessage('Error al cargar los datos', 'error');
}
}
// ─── Approve flow ───────────────────────────────────────
function openApprove(id, vessel, desc, cost) {
document.getElementById('approveWoId').value = id;
document.getElementById('approveWoDetail').innerHTML =
`<strong style="color:#0a2a3a;">${vessel}</strong><br>
${desc}<br>
<span style="color:#666;">Costo estimado: <strong>$${cost}</strong></span>`;
document.getElementById('approveModal').style.display = 'flex';
}
async function confirmApprove() {
const id = document.getElementById('approveWoId').value;
const res = await fetch(`/api/workorders/${id}/approve`, {method: 'POST'});
const data = await res.json();
if (data.success) {
closeModal('approveModal');
showMessage(`Work Order aprobada por ${data.approved_by} el ${data.approved_at}`, 'success');
loadData();
} else {
showMessage('Error al aprobar', 'error');
}
}
// ─── Reject flow ────────────────────────────────────────
function openReject(id, vessel, desc) {
document.getElementById('rejectWoId').value = id;
document.getElementById('rejectReason').value = '';
document.getElementById('rejectWoDetail').innerHTML =
`<strong style="color:#721c24;">${vessel}</strong><br>${desc}`;
document.getElementById('rejectModal').style.display = 'flex';
}
async function confirmReject() {
const id = document.getElementById('rejectWoId').value;
const reason = document.getElementById('rejectReason').value.trim();
if (!reason) { showMessage('Por favor indique el motivo del rechazo', 'error'); return; }
const res = await fetch(`/api/workorders/${id}/reject`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({reason})
});
const data = await res.json();
if (data.success) {
closeModal('rejectModal');
showMessage('Work Order rechazada. El equipo de gestion ha sido notificado.', 'success');
loadData();
} else {
showMessage('Error al rechazar', 'error');
}
}
loadData();
</script>
</body>
</html>