67a0e674ca
Marine maintenance management: work orders with photos, ISM/SWP procedures, MSDS, inventory, RFQ/purchases, vessel history, bilingual PDF reports. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
334 lines
16 KiB
HTML
334 lines
16 KiB
HTML
{% extends 'base.html' %}
|
|
{% block title %}Historial — {{ vessel.name }}{% endblock %}
|
|
{% block page_title %}{{ vessel.name }}{% endblock %}
|
|
{% block topbar_actions %}
|
|
<a href="{{ url_for('work_order_new') }}?vessel={{ vessel.id }}" class="btn btn-primary">+ Nueva Orden</a>
|
|
<a href="{{ url_for('vessel_edit', vid=vessel.id) }}" class="btn btn-secondary">✏️ Editar</a>
|
|
<a href="{{ url_for('vessels') }}" class="btn btn-secondary">← Volver</a>
|
|
{% endblock %}
|
|
{% block content %}
|
|
|
|
<!-- STATS -->
|
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px">
|
|
<div class="stat-card">
|
|
<div class="stat-label">🚢 Tipo</div>
|
|
<div style="font-size:15px;font-weight:600;color:var(--white);margin-top:4px">{{ vessel.vessel_type or '—' }}</div>
|
|
<div class="stat-sub">{{ vessel.make or '' }} {{ vessel.model or '' }} {{ vessel.year or '' }}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">⚙️ Horas Motor</div>
|
|
<div class="stat-value">{{ vessel.engine_hours or 0 }}</div>
|
|
<div class="stat-sub">horas acumuladas</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">📋 Total WOs</div>
|
|
<div class="stat-value">{{ orders|length }}</div>
|
|
<div class="stat-sub">órdenes registradas</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">💰 Costo Total</div>
|
|
<div class="stat-value" style="font-size:22px">${{ "%.0f"|format(total_cost) }}</div>
|
|
<div class="stat-sub">histórico acumulado</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CONTACTOS -->
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px">
|
|
<div class="card" style="padding:14px 18px">
|
|
<div style="font-size:10px;color:var(--gray);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:8px">👤 Propietario</div>
|
|
<div style="font-size:14px;font-weight:600">{{ vessel.owner_name or '—' }}</div>
|
|
<div style="font-size:12px;color:var(--gray)">{{ vessel.owner_phone or '' }}{% if vessel.owner_email %} · {{ vessel.owner_email }}{% endif %}</div>
|
|
</div>
|
|
<div class="card" style="padding:14px 18px">
|
|
<div style="font-size:10px;color:var(--cyan);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:8px">⚓ Capitán</div>
|
|
<div style="font-size:14px;font-weight:600">{{ vessel.captain_name or '—' }}</div>
|
|
<div style="font-size:12px;color:var(--gray)">{{ vessel.captain_phone or '' }}{% if vessel.captain_email %} · {{ vessel.captain_email }}{% endif %}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══ ÓRDENES DE TRABAJO ═══ -->
|
|
<div class="card mb-4">
|
|
<div class="card-header flex justify-between">
|
|
<span>📋 Órdenes de Trabajo</span>
|
|
<a href="{{ url_for('work_order_new') }}?vessel={{ vessel.id }}" class="btn btn-sm btn-primary">+ Nueva Orden</a>
|
|
</div>
|
|
{% if orders %}
|
|
<!-- Búsqueda -->
|
|
<div style="margin-bottom:12px;display:flex;gap:10px">
|
|
<input type="text" id="histSearch"
|
|
placeholder="🔍 Buscar por scope, fecha, técnico..."
|
|
style="flex:1;padding:8px 12px;font-size:13px;border-radius:6px;
|
|
background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.2);color:var(--white);"
|
|
oninput="filterHistory(this.value)">
|
|
<select id="statusFilter" onchange="filterHistory(document.getElementById('histSearch').value)"
|
|
style="padding:8px 12px;border-radius:6px;background:rgba(255,255,255,0.06);
|
|
border:1px solid rgba(0,180,216,0.2);color:var(--white);font-size:13px">
|
|
<option value="">Todos</option>
|
|
<option value="open">Abiertas</option>
|
|
<option value="in_progress">En Progreso</option>
|
|
<option value="completed">Completadas</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Tabla desktop -->
|
|
<div class="table-wrap" id="histTable">
|
|
<table>
|
|
<thead>
|
|
<tr><th>Orden</th><th>Fecha</th><th>Sistema</th><th>Scope</th><th>Técnico</th><th>Horas</th><th>Costo</th><th>Estado</th><th></th></tr>
|
|
</thead>
|
|
<tbody id="woTableBody">
|
|
{% for o in orders %}
|
|
<tr class="history-row"
|
|
data-search="{{ (o.scope or '' ~ ' ' ~ (o.description or '') ~ ' ' ~ (o.technician or '') ~ ' ' ~ (o.start_date or ''))|lower }}"
|
|
data-status="{{ o.status }}">
|
|
<td class="text-cyan" style="white-space:nowrap;font-size:12px">{{ o.order_number }}</td>
|
|
<td class="text-gray" style="white-space:nowrap">{{ o.start_date or o.created_at[:10] }}</td>
|
|
<td class="text-gray" style="font-size:12px">{{ o.system_name or '—' }}</td>
|
|
<td style="max-width:280px">
|
|
<div style="font-weight:600;font-size:13px">{{ o.scope or '—' }}</div>
|
|
{% if o.description and o.scope and o.description != o.scope %}
|
|
<div style="font-size:11px;color:var(--gray)">{{ o.description[:80] }}{% if o.description|length > 80 %}...{% endif %}</div>
|
|
{% endif %}
|
|
</td>
|
|
<td style="font-size:12px">{{ o.technician or '—' }}</td>
|
|
<td style="font-size:12px">{{ o.labor_hours or 0 }} h</td>
|
|
<td class="text-cyan" style="font-size:12px;white-space:nowrap">
|
|
${{ "%.0f"|format((o.calc_labor_cost or 0) + (o.total_parts_cost or 0)) }}
|
|
</td>
|
|
<td><span class="badge badge-{{ o.status }}" style="font-size:10px">{{ o.status.replace('_',' ') }}</span></td>
|
|
<td style="white-space:nowrap">
|
|
<a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-secondary">Ver</a>
|
|
{% if o.status != 'completed' %}
|
|
<a href="{{ url_for('work_order_edit', woid=o.id) }}" class="btn btn-sm btn-secondary">✏️</a>
|
|
{% endif %}
|
|
<a href="{{ url_for('work_order_pdf', woid=o.id) }}" target="_blank" class="btn btn-sm btn-primary">📄</a>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Tarjetas móvil -->
|
|
<div id="histCards">
|
|
{% for o in orders %}
|
|
<div class="hist-card history-row"
|
|
data-search="{{ (o.scope or '' ~ ' ' ~ (o.description or '') ~ ' ' ~ (o.technician or '') ~ ' ' ~ (o.start_date or ''))|lower }}"
|
|
data-status="{{ o.status }}"
|
|
style="background:var(--navy2);border:1px solid rgba(0,180,216,0.12);border-radius:10px;margin-bottom:10px;overflow:hidden;">
|
|
<div style="padding:10px 14px;background:rgba(0,0,0,0.2);border-bottom:1px solid rgba(255,255,255,0.05);display:flex;justify-content:space-between;align-items:center">
|
|
<span style="font-size:12px;color:var(--cyan);font-family:monospace;font-weight:600">{{ o.order_number }}</span>
|
|
<span class="badge badge-{{ o.status }}" style="font-size:11px">{{ o.status.replace('_',' ') }}</span>
|
|
</div>
|
|
<div style="padding:10px 14px">
|
|
<div style="font-weight:600;font-size:14px;color:var(--white);margin-bottom:4px">{{ o.scope or '—' }}</div>
|
|
<div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px;color:var(--gray);margin-bottom:6px">
|
|
{% if o.system_name %}<span>🔩 {{ o.system_name }}</span>{% endif %}
|
|
{% if o.technician %}<span>👤 {{ o.technician }}</span>{% endif %}
|
|
<span>📅 {{ o.start_date or o.created_at[:10] }}</span>
|
|
</div>
|
|
<div style="display:flex;gap:16px;font-size:13px">
|
|
<span style="color:var(--gray)">{{ o.labor_hours or 0 }} h</span>
|
|
<span style="color:var(--cyan);font-weight:600">${{ "%.0f"|format((o.calc_labor_cost or 0) + (o.total_parts_cost or 0)) }}</span>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;gap:8px;padding:8px 14px;border-top:1px solid rgba(255,255,255,0.05);background:rgba(0,0,0,0.1)">
|
|
<a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-primary" style="flex:1;text-align:center">Ver detalle</a>
|
|
{% if o.status != 'completed' %}
|
|
<a href="{{ url_for('work_order_edit', woid=o.id) }}" class="btn btn-sm btn-secondary">✏️</a>
|
|
{% endif %}
|
|
<a href="{{ url_for('work_order_pdf', woid=o.id) }}" target="_blank" class="btn btn-sm btn-secondary">📄</a>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<div id="noHistResults" style="display:none;text-align:center;padding:20px;color:var(--gray)">
|
|
No se encontraron órdenes.
|
|
</div>
|
|
{% else %}
|
|
<div style="text-align:center;padding:40px;color:var(--gray)">
|
|
<div style="font-size:36px;margin-bottom:10px">📋</div>
|
|
<div style="margin-bottom:14px">Sin órdenes de trabajo para esta embarcación.</div>
|
|
<a href="{{ url_for('work_order_new') }}?vessel={{ vessel.id }}" class="btn btn-primary">+ Crear Primera Orden</a>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- EQUIPOS -->
|
|
<div class="card mb-4">
|
|
<div class="card-header flex justify-between">
|
|
<span>⚙️ Equipos Registrados</span>
|
|
<a href="{{ url_for('equipment_new', vid=vessel.id) }}" class="btn btn-sm btn-primary">+ Agregar Equipo</a>
|
|
</div>
|
|
{% if equipment %}
|
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px">
|
|
{% for e in equipment %}
|
|
<div style="background:rgba(0,0,0,0.2);border:1px solid rgba(0,180,216,0.12);border-radius:8px;padding:12px 14px">
|
|
<div style="font-size:13px;font-weight:600;color:var(--white);margin-bottom:3px">{{ e.name }}</div>
|
|
<div style="font-size:11px;color:var(--gray);margin-bottom:5px">
|
|
{{ e.make or '' }} {{ e.model or '' }}
|
|
{% if e.position %}· <span style="color:var(--cyan)">{{ e.position }}</span>{% endif %}
|
|
</div>
|
|
{% if e.serial_number %}
|
|
<div style="font-size:11px;background:rgba(0,180,216,0.08);border:1px solid rgba(0,180,216,0.2);
|
|
border-radius:4px;padding:3px 8px;color:var(--cyan);font-family:monospace;margin-bottom:5px">
|
|
S/N: {{ e.serial_number }}
|
|
</div>
|
|
{% endif %}
|
|
<div style="font-size:11px;color:var(--gray)">{{ e.engine_hours or 0 }} h</div>
|
|
<a href="{{ url_for('equipment_edit', vid=vessel.id, eid=e.id) }}"
|
|
class="btn btn-sm btn-secondary" style="margin-top:8px;font-size:11px">✏️</a>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
<p class="text-gray" style="font-size:13px">Sin equipos. Agrega motores, generadores, etc.</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- DOCUMENTOS -->
|
|
<div class="card mb-4">
|
|
<div class="card-header flex justify-between">
|
|
<span>📁 Documentos y Manuales</span>
|
|
<button onclick="document.getElementById('docModal').style.display='flex'" class="btn btn-sm btn-primary">+ Adjuntar</button>
|
|
</div>
|
|
{% if documents %}
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead><tr><th>Título</th><th>Tipo</th><th>Equipo</th><th>Tamaño</th><th>Fecha</th><th></th></tr></thead>
|
|
<tbody>
|
|
{% for d in documents %}
|
|
<tr>
|
|
<td>
|
|
<span style="font-size:15px;margin-right:6px">
|
|
{% if d.filename.endswith('.pdf') %}📄
|
|
{% elif d.filename.endswith(('.doc','docx')) %}📝
|
|
{% elif d.filename.endswith(('.xls','xlsx')) %}📊
|
|
{% elif d.filename.endswith(('.jpg','jpeg','png','gif')) %}🖼️
|
|
{% else %}📎{% endif %}
|
|
</span>
|
|
<strong>{{ d.title }}</strong>
|
|
{% if d.description %}<br><span class="text-gray" style="font-size:11px">{{ d.description }}</span>{% endif %}
|
|
</td>
|
|
<td><span class="badge badge-open" style="font-size:10px">{{ d.doc_type }}</span></td>
|
|
<td class="text-gray">{{ d.equipment_name or '—' }}</td>
|
|
<td class="text-gray" style="font-size:12px">{% if d.file_size %}{{ "%.1f"|format(d.file_size/1024) }} KB{% else %}—{% endif %}</td>
|
|
<td class="text-gray" style="font-size:12px">{{ d.created_at[:10] }}</td>
|
|
<td class="flex gap-2">
|
|
<a href="{{ url_for('document_download', doc_id=d.id) }}" class="btn btn-sm btn-primary">⬇️</a>
|
|
<button onclick="deleteDoc({{ d.id }}, this.closest('tr'))" class="btn btn-sm btn-danger">🗑️</button>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<p class="text-gray" style="font-size:13px">Sin documentos adjuntos.</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- MODAL DOCUMENTO -->
|
|
<div id="docModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:1000;align-items:center;justify-content:center">
|
|
<div class="card" style="width:480px;max-width:95vw">
|
|
<div class="card-header">📁 Adjuntar Documento</div>
|
|
<div class="form-group mb-3">
|
|
<label>Título *</label>
|
|
<input type="text" id="docTitle" placeholder="Ej: Manual MTU 12V2000">
|
|
</div>
|
|
<div class="form-grid mb-3">
|
|
<div class="form-group">
|
|
<label>Tipo</label>
|
|
<select id="docType">
|
|
<option value="manual">Manual</option>
|
|
<option value="certificate">Certificado</option>
|
|
<option value="warranty">Garantía</option>
|
|
<option value="inspection">Inspección</option>
|
|
<option value="drawing">Plano/Diagrama</option>
|
|
<option value="other">Otro</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Equipo (opcional)</label>
|
|
<select id="docEquipment">
|
|
<option value="">— General —</option>
|
|
{% for e in equipment %}
|
|
<option value="{{ e.id }}">{{ e.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-group mb-3">
|
|
<label>Descripción (opcional)</label>
|
|
<input type="text" id="docDesc">
|
|
</div>
|
|
<div class="form-group mb-3">
|
|
<label>Archivo</label>
|
|
<input type="file" id="docFile" accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.png,.jpg,.jpeg" style="padding:8px">
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<button onclick="uploadDoc()" class="btn btn-primary">📤 Subir</button>
|
|
<button onclick="document.getElementById('docModal').style.display='none'" class="btn btn-secondary">Cancelar</button>
|
|
</div>
|
|
<div id="docStatus" style="margin-top:10px;font-size:13px"></div>
|
|
</div>
|
|
</div>
|
|
|
|
{% endblock %}
|
|
{% block head %}
|
|
<style>
|
|
@media (max-width:768px) {
|
|
#histTable { display:none; }
|
|
#histCards { display:block; }
|
|
}
|
|
@media (min-width:769px) {
|
|
#histTable { display:block; }
|
|
#histCards { display:none; }
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
{% block scripts %}
|
|
<script>
|
|
const VID = {{ vessel.id }};
|
|
|
|
function filterHistory(q) {
|
|
q = (q || '').toLowerCase().trim();
|
|
const status = document.getElementById('statusFilter').value;
|
|
const rows = document.querySelectorAll('.history-row');
|
|
let visible = 0;
|
|
rows.forEach(r => {
|
|
const show = (!q || r.dataset.search.includes(q)) && (!status || r.dataset.status === status);
|
|
r.style.display = show ? '' : 'none';
|
|
if (show) visible++;
|
|
});
|
|
document.getElementById('noHistResults').style.display = visible === 0 ? 'block' : 'none';
|
|
}
|
|
|
|
function uploadDoc() {
|
|
const file = document.getElementById('docFile').files[0];
|
|
const title = document.getElementById('docTitle').value.trim();
|
|
if (!file) { alert('Selecciona un archivo'); return; }
|
|
if (!title) { alert('Ingresa un título'); return; }
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
fd.append('title', title);
|
|
fd.append('doc_type', document.getElementById('docType').value);
|
|
fd.append('equipment_id', document.getElementById('docEquipment').value);
|
|
fd.append('description', document.getElementById('docDesc').value);
|
|
document.getElementById('docStatus').textContent = 'Subiendo...';
|
|
fetch(`/vessels/${VID}/documents/upload`, { method:'POST', body: fd })
|
|
.then(r => r.json()).then(d => {
|
|
if (d.ok) location.reload();
|
|
else document.getElementById('docStatus').textContent = 'Error: ' + d.error;
|
|
});
|
|
}
|
|
|
|
function deleteDoc(id, row) {
|
|
if (!confirm('¿Eliminar este documento?')) return;
|
|
fetch(`/documents/${id}/delete`, {method:'DELETE'})
|
|
.then(() => row.remove());
|
|
}
|
|
</script>
|
|
{% endblock %}
|