Initial commit — MarineMaintenance v1.0

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>
This commit is contained in:
2026-05-05 01:54:20 -04:00
commit 67a0e674ca
44 changed files with 8439 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
{% extends 'base.html' %}
{% block title %}Órdenes de Trabajo{% endblock %}
{% block page_title %}Órdenes de Trabajo{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('work_order_new') }}" class="btn btn-primary">+ Nueva Orden</a>
{% endblock %}
{% block head %}
<style>
/* ── Filtros ── */
.wo-filters {
display:flex;gap:8px;align-items:center;margin-bottom:14px;flex-wrap:wrap;
}
.wo-filters input {
flex:1;min-width:180px;padding:7px 12px;border-radius:6px;
background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.25);
color:var(--white);font-size:13px;
}
/* ── Tabla (desktop) ── */
.wo-table-wrap { display:block; }
.wo-cards-wrap { display:none; }
/* ── Tarjetas (móvil) ── */
@media (max-width: 768px) {
.wo-table-wrap { display:none; }
.wo-cards-wrap { display:block; }
}
.wo-card {
background:var(--navy2);
border:1px solid rgba(0,180,216,0.12);
border-radius:10px;
margin-bottom:10px;
overflow:hidden;
transition:border-color 0.2s;
}
.wo-card:active { border-color:rgba(0,180,216,0.4); }
.wo-card-header {
display:flex;justify-content:space-between;align-items:center;
padding:10px 14px;
background:rgba(0,0,0,0.2);
border-bottom:1px solid rgba(255,255,255,0.05);
}
.wo-card-num { font-size:12px;color:var(--cyan);font-weight:600;font-family:monospace; }
.wo-card-body { padding:10px 14px; }
.wo-card-vessel {
font-size:15px;font-weight:600;color:var(--white);margin-bottom:4px;
}
.wo-card-scope {
font-size:13px;color:var(--gray);margin-bottom:6px;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
}
.wo-card-meta {
display:flex;gap:10px;flex-wrap:wrap;font-size:12px;color:var(--gray);margin-bottom:10px;
}
.wo-card-meta span { display:flex;align-items:center;gap:4px; }
.wo-card-actions {
display:flex;gap:8px;padding:8px 14px;
border-top:1px solid rgba(255,255,255,0.05);
background:rgba(0,0,0,0.1);
}
.wo-card-actions a, .wo-card-actions button {
flex:1;text-align:center;font-size:13px;padding:8px 4px;
}
</style>
{% endblock %}
{% block content %}
<div class="wo-filters">
<a href="{{ url_for('work_orders') }}" class="btn btn-sm {% if not status_filter %}btn-primary{% else %}btn-secondary{% endif %}">Todas</a>
<a href="{{ url_for('work_orders', status='open') }}" class="btn btn-sm {% if status_filter=='open' %}btn-primary{% else %}btn-secondary{% endif %}">Abiertas</a>
<a href="{{ url_for('work_orders', status='in_progress') }}" class="btn btn-sm {% if status_filter=='in_progress' %}btn-warning{% else %}btn-secondary{% endif %}">En Progreso</a>
<a href="{{ url_for('work_orders', status='completed') }}" class="btn btn-sm {% if status_filter=='completed' %}btn-success{% else %}btn-secondary{% endif %}">Completadas</a>
<input type="text" id="woSearch" placeholder="🔍 Buscar embarcación, scope, técnico..."
oninput="filterWO(this.value)">
</div>
<!-- TABLA DESKTOP -->
<div class="wo-table-wrap card">
<div class="table-wrap">
<table>
<thead>
<tr><th>Orden</th><th>🚢 Embarcación</th><th>Tipo</th><th>Scope</th><th>Técnico</th><th>Fecha</th><th>Estado</th><th></th></tr>
</thead>
<tbody>
{% for o in orders %}
<tr class="wo-row" data-search="{{ (o.vessel_name ~ ' ' ~ (o.scope or '') ~ ' ' ~ (o.technician or '') ~ ' ' ~ o.order_number)|lower }}">
<td class="text-cyan" style="white-space:nowrap;font-family:monospace;font-size:12px">{{ o.order_number }}</td>
<td><a href="{{ url_for('vessel_history', vid=o.vessel_id) }}" class="text-cyan" style="font-weight:600">{{ o.vessel_name }}</a></td>
<td>{{ o.work_type or '—' }}</td>
<td style="max-width:240px">{{ o.scope or (o.description[:60] if o.description else '—') }}</td>
<td>{{ o.technician or '—' }}</td>
<td class="text-gray" style="white-space:nowrap">{{ o.start_date or o.created_at[:10] }}</td>
<td><span class="badge badge-{{ o.status }}">{{ o.status.replace('_',' ') }}</span></td>
<td class="flex gap-2" 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>
<button onclick="deleteWO({{ o.id }})" class="btn btn-sm btn-danger">🗑️</button>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-gray" style="text-align:center;padding:30px">No hay órdenes.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div id="noWOResultsTable" style="display:none;text-align:center;padding:20px;color:var(--gray)">
No se encontraron órdenes.
</div>
</div>
<!-- TARJETAS MÓVIL -->
<div class="wo-cards-wrap">
{% for o in orders %}
<div class="wo-card" id="card-{{ o.id }}"
data-search="{{ (o.vessel_name ~ ' ' ~ (o.scope or '') ~ ' ' ~ (o.technician or '') ~ ' ' ~ o.order_number)|lower }}">
<div class="wo-card-header">
<span class="wo-card-num">{{ o.order_number }}</span>
<span class="badge badge-{{ o.status }}">{{ o.status.replace('_',' ') }}</span>
</div>
<div class="wo-card-body">
<div class="wo-card-vessel">
<a href="{{ url_for('vessel_history', vid=o.vessel_id) }}" style="color:var(--cyan)">
🚢 {{ o.vessel_name }}
</a>
</div>
<div class="wo-card-scope">{{ o.scope or (o.description[:100] if o.description else 'Sin descripción') }}</div>
<div class="wo-card-meta">
{% if o.work_type %}<span>🔧 {{ o.work_type }}</span>{% endif %}
{% if o.technician %}<span>👤 {{ o.technician }}</span>{% endif %}
<span>📅 {{ o.start_date or o.created_at[:10] }}</span>
{% if o.billing_type == 'lump_sum' %}<span>💰 Todo costo</span>
{% elif o.billing_type == 'labor_only' %}<span>🔧 Solo M.O.</span>
{% else %}<span>📋 M.O.+Mat.</span>{% endif %}
</div>
</div>
<div class="wo-card-actions">
<a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-primary">Ver detalle</a>
{% if o.status != 'completed' %}
<a href="{{ url_for('work_order_edit', woid=o.id) }}" class="btn btn-sm btn-secondary">✏️ Editar</a>
<button onclick="deleteWO({{ o.id }})" class="btn btn-sm btn-danger">🗑️</button>
{% endif %}
</div>
</div>
{% else %}
<div style="text-align:center;padding:40px;color:var(--gray)">No hay órdenes.</div>
{% endfor %}
<div id="noWOResultsCards" style="display:none;text-align:center;padding:20px;color:var(--gray)">
No se encontraron órdenes.
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function filterWO(q) {
q = q.toLowerCase().trim();
let visibleT = 0, visibleC = 0;
document.querySelectorAll('.wo-row').forEach(r => {
const show = !q || r.dataset.search.includes(q);
r.style.display = show ? '' : 'none';
if (show) visibleT++;
});
document.querySelectorAll('.wo-card').forEach(c => {
const show = !q || c.dataset.search.includes(q);
c.style.display = show ? '' : 'none';
if (show) visibleC++;
});
document.getElementById('noWOResultsTable').style.display = visibleT === 0 ? 'block' : 'none';
document.getElementById('noWOResultsCards').style.display = visibleC === 0 ? 'block' : 'none';
}
function deleteWO(id) {
if (!confirm('¿Eliminar esta orden? Esta acción no se puede deshacer.')) return;
fetch('/work-orders/' + id + '/delete', {method:'DELETE'})
.then(r => r.json())
.then(d => {
if (d.ok) location.reload();
else alert('Error: ' + d.error);
});
}
</script>
{% endblock %}