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
+999
View File
@@ -0,0 +1,999 @@
{% extends 'base.html' %}
{% block title %}{{ order.order_number }}{% endblock %}
{% block page_title %}{{ order.order_number }}{% endblock %}
{% block topbar_actions %}
<div class="topbar-actions-desktop">
{% if order.status != 'completed' %}
<a href="{{ url_for('work_order_edit', woid=order.id) }}" class="btn btn-secondary btn-sm">✏️ Editar</a>
<button onclick="setStatus('in_progress')" class="btn btn-warning btn-sm">▶ En Progreso</button>
<button onclick="setStatus('completed')" class="btn btn-success btn-sm">✅ Completar</button>
<button onclick="deleteThisWO()" class="btn btn-danger btn-sm">🗑️</button>
{% else %}
<button onclick="confirmReopen()" class="btn btn-warning btn-sm">🔓 Reabrir WO</button>
{% endif %}
<button onclick="openAssignModal()" class="btn btn-secondary btn-sm">👤 Asignar</button>
<a href="{{ url_for('work_order_pdf', woid=order.id) }}?lang=es" target="_blank" class="btn btn-primary btn-sm">📄 ES</a>
<a href="{{ url_for('work_order_pdf', woid=order.id) }}?lang=en" target="_blank" class="btn btn-secondary btn-sm">📄 EN</a>
<button onclick="openShareModal()" class="btn btn-secondary btn-sm">📤 Enviar</button>
<a href="{{ url_for('work_orders') }}" class="btn btn-secondary btn-sm">← Volver</a>
</div>
{% endblock %}
{% block content %}
<!-- BARRA DE ACCIONES MÓVIL -->
<style>
.mobile-action-bar { display:none; }
.topbar-actions-desktop { display:flex; gap:8px; flex-wrap:wrap; }
@media (max-width:768px) {
.topbar-actions-desktop { display:none !important; }
.mobile-action-bar {
display:flex;position:fixed;bottom:0;left:0;right:0;
background:var(--navy2);border-top:2px solid rgba(0,180,216,0.4);
padding:8px 10px;gap:6px;z-index:950;
}
.mobile-action-bar .mab-btn {
flex:1;text-align:center;font-size:11px;padding:8px 4px;
border-radius:8px;border:none;cursor:pointer;min-width:0;
display:flex;flex-direction:column;align-items:center;gap:2px;
font-weight:600;
}
.mobile-action-bar .mab-btn .icon { font-size:18px;line-height:1; }
.mobile-action-bar .mab-btn .label { font-size:10px;line-height:1; }
.content { padding-bottom:80px !important; }
}
</style>
<div class="mobile-action-bar">
{% if order.status != 'completed' %}
<button onclick="setStatus('in_progress')" class="mab-btn btn-warning"
style="background:rgba(244,162,97,0.2);color:var(--warning)">
<span class="icon"></span><span class="label">Progreso</span>
</button>
<button onclick="setStatus('completed')" class="mab-btn btn-success"
style="background:rgba(46,196,182,0.2);color:var(--success)">
<span class="icon"></span><span class="label">Completar</span>
</button>
<a href="{{ url_for('work_order_edit', woid=order.id) }}" class="mab-btn"
style="background:rgba(255,255,255,0.08);color:var(--white);text-decoration:none">
<span class="icon">✏️</span><span class="label">Editar</span>
</a>
{% else %}
<button onclick="confirmReopen()" class="mab-btn"
style="background:rgba(244,162,97,0.2);color:var(--warning)">
<span class="icon">🔓</span><span class="label">Reabrir</span>
</button>
{% endif %}
<button onclick="openShareModal()" class="mab-btn"
style="background:rgba(255,255,255,0.08);color:var(--white)">
<span class="icon">📤</span><span class="label">Enviar</span>
</button>
<a href="{{ url_for('work_order_pdf', woid=order.id) }}?lang=es" target="_blank"
class="mab-btn" style="background:rgba(0,180,216,0.2);color:var(--cyan);text-decoration:none">
<span class="icon">📄</span><span class="label">PDF</span>
</a>
<a href="{{ url_for('work_orders') }}" class="mab-btn"
style="background:rgba(255,255,255,0.08);color:var(--white);text-decoration:none">
<span class="icon"></span><span class="label">Volver</span>
</a>
</div>
<div class="grid-2 mb-4">
<div class="card">
<div class="card-header">📋 Detalles</div>
<table style="font-size:13px">
<tr><td style="color:var(--gray);padding:6px 0;width:140px">Embarcación</td><td><a href="{{ url_for('vessel_history', vid=order.vessel_id) }}" class="text-cyan">{{ order.vessel_name }}</a></td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Sistema</td><td><span class="badge badge-open">{{ order.system_name or '—' }}</span></td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Scope</td><td><strong>{{ order.scope or '—' }}</strong></td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Tipo</td><td>{{ order.work_type or '—' }}</td></tr>
<tr>
<td style="color:var(--gray);padding:6px 0">Facturación</td>
<td>
{% if order.billing_type == 'lump_sum' %}
<span class="badge" style="background:rgba(244,162,97,0.15);color:var(--warning)">💰 A todo costo</span>
{% elif order.billing_type == 'labor_only' %}
<span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan)">🔧 Solo M.O.</span>
{% else %}
<span class="badge" style="background:rgba(46,196,182,0.15);color:var(--success)">📋 M.O. + Materiales</span>
{% endif %}
</td>
</tr>
<tr><td style="color:var(--gray);padding:6px 0">Técnico</td><td>{{ order.technician or '—' }}</td></tr>
{% if order.assigned_to %}
<tr>
<td style="color:var(--gray);padding:6px 0">Asignado a</td>
<td>
<span style="color:var(--cyan);font-size:12px">📨 {{ order.assigned_to }}</span>
{% if order.assigned_by %}<br><span style="font-size:11px;color:var(--gray)">por {{ order.assigned_by }}</span>{% endif %}
</td>
</tr>
{% endif %}
<tr><td style="color:var(--gray);padding:6px 0">Fecha inicio</td><td>{{ order.start_date or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Fecha fin</td><td>{{ order.end_date or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">H. Motor inicio</td><td>{{ order.engine_hours_start or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Estado</td><td><span class="badge badge-{{ order.status }}">{{ order.status.replace('_',' ') }}</span></td></tr>
</table>
<div style="margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.05);font-size:13px">
<div style="color:var(--gray);font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px">Descripción</div>
{{ order.description }}
</div>
</div>
<div class="card">
<div class="card-header">💰 Costos</div>
<table style="font-size:13px">
<tr><td style="color:var(--gray);padding:6px 0;width:160px">Horas Trabajadas</td><td>{{ order.labor_hours or 0 }} h</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Tarifa M.O.</td><td>${{ order.labor_rate or 0 }}/h</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Costo M.O.</td><td>${{ "%.2f"|format(order.total_labor_cost or 0) }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Costo Repuestos</td><td>${{ "%.2f"|format(order.total_parts_cost or 0) }}</td></tr>
<tr style="font-weight:600"><td style="color:var(--cyan);padding:8px 0">TOTAL</td><td class="text-cyan">${{ "%.2f"|format((order.total_labor_cost or 0) + (order.total_parts_cost or 0)) }}</td></tr>
</table>
<div class="mt-4" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<label style="font-size:12px;color:var(--gray)">Horas trabajadas:</label>
<input type="number" id="labor_hours" step="0.01" value="{{ order.labor_hours or 0 }}" style="width:80px;padding:6px">
<label style="font-size:12px;color:var(--gray)">Tarifa $/h:</label>
<input type="number" id="labor_rate" step="0.01" value="{{ order.labor_rate or 0 }}" style="width:80px;padding:6px">
<button onclick="updateHours()" class="btn btn-sm btn-primary">💾 Guardar Horas</button>
<span id="hoursStatus" style="font-size:12px;color:var(--success)"></span>
</div>
</div>
</div>
<!-- EQUIPOS TRABAJADOS EN ESTE WO -->
<div class="card mb-4">
<div class="card-header flex justify-between">
<span>⚙️ Equipos Trabajados</span>
{% if order.status != 'completed' %}
<button onclick="openEquipModal()" class="btn btn-sm btn-primary">+ Agregar Equipo</button>
{% endif %}
</div>
{% if wo_equipment %}
<div class="table-wrap">
<table>
<thead><tr><th>Equipo</th><th>S/N</th><th>Trabajo Realizado</th><th>Notas</th><th>Horas</th><th>Costo M.O.</th>{% if order.status != 'completed' %}<th></th>{% endif %}</tr></thead>
<tbody>
{% for e in wo_equipment %}
<tr>
<td><strong>{{ e.equip_name or '—' }}</strong><br><span class="text-gray" style="font-size:11px">{{ e.make or '' }} {{ e.model or '' }}</span></td>
<td class="text-gray" style="font-family:monospace;font-size:11px">{{ e.serial_number or '—' }}</td>
<td>{{ e.description or '—' }}</td>
<td class="text-gray" style="font-size:11px">{{ e.notes or '—' }}</td>
<td>{{ e.labor_hours or 0 }} h</td>
<td class="text-cyan">${{ "%.2f"|format((e.labor_hours or 0) * (e.labor_rate or 0)) }}</td>
{% if order.status != 'completed' %}
<td class="flex gap-2">
<button onclick="editWoEquip({{ e.id }}, '{{ e.equip_name or '' }}', {{ e.labor_hours or 0 }}, {{ e.labor_rate or 0 }}, '{{ e.description|replace("'","\\'")|replace('"','\\"') or '' }}', '{{ e.notes|replace("'","\\'")|replace('"','\\"') or '' }}')" class="btn btn-sm btn-secondary">✏️</button>
<button onclick="removeWoEquip({{ e.id }}, this.closest('tr'))" class="btn btn-sm btn-danger">🗑️</button>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr style="background:rgba(0,180,216,0.06);font-weight:600">
<td colspan="4" style="padding:10px 14px;color:var(--gray)">TOTAL MANO DE OBRA</td>
<td style="padding:10px 14px;color:var(--cyan)">{{ wo_equipment|sum(attribute='labor_hours') or 0 }} h</td>
<td style="padding:10px 14px;color:var(--cyan)">${{ "%.2f"|format(wo_equipment|sum(attribute='labor_cost') or 0) }}</td>
{% if order.status != 'completed' %}<td></td>{% endif %}
</tr>
</tfoot>
</table>
</div>
{% else %}
<p class="text-gray" style="font-size:13px">Sin equipos agregados.</p>
{% endif %}
</div>
<!-- MODAL AGREGAR/EDITAR EQUIPO WO -->
<div id="woEquipModal" 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:500px;max-width:95vw;position:relative">
<button onclick="closeEquipModal()"
style="position:absolute;top:10px;right:12px;background:none;border:none;
color:var(--gray);font-size:20px;cursor:pointer">×</button>
<div class="card-header" id="equipModalTitle">⚙️ Agregar Equipo a esta Orden</div>
<input type="hidden" id="woEquipEditId" value="">
<div class="form-group mb-3">
<label>Equipo de la Embarcación</label>
<select id="woEquipSelect">
<option value="">— Sin equipo registrado (manual) —</option>
{% for e in vessel_equipment %}
<option value="{{ e.id }}">{{ e.name }} {% if e.serial_number %}· S/N: {{ e.serial_number }}{% endif %}</option>
{% endfor %}
</select>
</div>
<div class="form-group mb-3">
<label>Trabajo Realizado en este Equipo *</label>
<textarea id="woEquipDesc" rows="3" placeholder="Ej: Cambio de filtros de aceite y combustible..."></textarea>
</div>
<div class="form-grid mb-3">
<div class="form-group">
<label>Horas Trabajadas</label>
<input type="number" id="woEquipHours" step="0.01" value="0" min="0">
</div>
<div class="form-group">
<label>Tarifa $/h</label>
<input type="number" id="woEquipRate" step="0.01" value="{{ order.labor_rate or 0 }}" min="0">
</div>
</div>
<div class="form-group mb-3">
<label>Notas adicionales</label>
<input type="text" id="woEquipNotes" placeholder="Observaciones, proximo servicio, etc.">
</div>
<div class="flex gap-3">
<button onclick="saveWoEquip()" class="btn btn-primary" id="btnSaveEquip">+ Agregar</button>
<button onclick="closeEquipModal()" class="btn btn-secondary">Cancelar</button>
</div>
<div id="woEquipStatus" style="margin-top:10px;font-size:13px"></div>
</div>
</div>
<!-- CAUSA TÉCNICA Y REPARACIONES -->
<div class="card mb-4">
<div class="card-header">⚠️ Causa Técnica y Reparaciones</div>
<div class="form-grid">
<div class="form-group full">
<label>Causa técnica de la falla</label>
<textarea id="root_cause" rows="3" placeholder="Describe la causa raíz del problema...">{{ order.root_cause or '' }}</textarea>
</div>
<div class="form-group full">
<label>Reparaciones realizadas</label>
<textarea id="repairs_done" rows="3" placeholder="Describe detalladamente las reparaciones efectuadas...">{{ order.repairs_done or '' }}</textarea>
</div>
</div>
<button onclick="saveTechFields()" class="btn btn-secondary btn-sm mt-4">💾 Guardar</button>
<span id="saveStatus" style="font-size:12px;color:var(--success);margin-left:10px"></span>
</div>
<!-- FOTOS -->
<div class="card mb-4">
<div class="card-header flex justify-between">
<span>📸 Evidencia Fotográfica</span>
{% if order.status != 'cancelled' %}
<button onclick="document.getElementById('photoModal').style.display='flex'" class="btn btn-sm btn-primary">+ Agregar Foto</button>
{% endif %}
</div>
{% set before_photos = photos|selectattr('photo_type','eq','before')|list %}
{% set after_photos = photos|selectattr('photo_type','eq','after')|list %}
{% if before_photos %}
<div style="margin-bottom:16px">
<div style="font-size:12px;color:var(--warn);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:10px;font-weight:600">⬅ Antes</div>
<div class="photo-grid">
{% for p in before_photos %}
<div class="photo-card before">
<img src="/static/uploads/photos/{{ p.filename }}" onclick="openPhoto(this.src)" style="cursor:pointer">
<div class="photo-label" style="display:flex;align-items:center;gap:4px">
<span style="font-size:11px;color:var(--gray)">Antes</span>
<input type="text" value="{{ p.caption or '' }}"
onchange="updateCaption({{ p.id }}, this.value)"
placeholder="Agregar descripción..."
style="flex:1;font-size:11px;background:transparent;border:none;
border-bottom:1px dashed rgba(255,255,255,0.2);color:var(--white);
padding:2px 4px;outline:none;min-width:0">
</div>
<button class="photo-del" onclick="deletePhoto({{ p.id }}, this.closest('.photo-card'))">×</button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if after_photos %}
<div>
<div style="font-size:12px;color:var(--success);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:10px;font-weight:600">➡ Después</div>
<div class="photo-grid">
{% for p in after_photos %}
<div class="photo-card after">
<img src="/static/uploads/photos/{{ p.filename }}" onclick="openPhoto(this.src)" style="cursor:pointer">
<div class="photo-label" style="display:flex;align-items:center;gap:4px">
<span style="font-size:11px;color:var(--gray)">Después</span>
<input type="text" value="{{ p.caption or '' }}"
onchange="updateCaption({{ p.id }}, this.value)"
placeholder="Agregar descripción..."
style="flex:1;font-size:11px;background:transparent;border:none;
border-bottom:1px dashed rgba(255,255,255,0.2);color:var(--white);
padding:2px 4px;outline:none;min-width:0">
</div>
<button class="photo-del" onclick="deletePhoto({{ p.id }}, this.closest('.photo-card'))">×</button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if not photos %}
<p class="text-gray" style="font-size:13px">Sin fotos aún. Agrega evidencia fotográfica del trabajo.</p>
{% endif %}
</div>
<!-- REPUESTOS USADOS -->
<div class="card">
<div class="card-header flex justify-between">
<span>🔩 Repuestos Utilizados</span>
{% if order.status != 'cancelled' %}
<button onclick="document.getElementById('partModal').style.display='flex'" class="btn btn-sm btn-primary">+ Agregar Repuesto</button>
{% endif %}
</div>
{% if parts_used %}
<div class="table-wrap">
<table>
<thead><tr><th>Repuesto</th><th>Descripción</th><th>Cantidad</th><th>Precio Unit.</th><th>Total</th></tr></thead>
<tbody>
{% for p in parts_used %}
<tr>
<td>{{ p.part_name or '—' }}{% if p.part_number %} <span class="text-gray">({{ p.part_number }})</span>{% endif %}</td>
<td class="text-gray">{{ p.description or '' }}</td>
<td>{{ p.quantity }}</td>
<td>${{ "%.2f"|format(p.unit_cost) }}</td>
<td class="text-cyan">${{ "%.2f"|format(p.total_cost) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray" style="font-size:13px">Sin repuestos registrados.</p>
{% endif %}
</div>
<!-- MODAL FOTO -->
<div id="photoModal" 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:420px;max-width:95vw">
<div class="card-header">📸 Agregar Foto</div>
<div class="form-group mb-4">
<label>Tipo</label>
<select id="photoType">
<option value="before">Antes</option>
<option value="after">Después</option>
</select>
</div>
<div class="form-group mb-4">
<label>Foto (cámara o galería)</label>
<input type="file" id="photoFile" accept="image/*" capture="environment" style="padding:8px">
</div>
<div class="form-group mb-4">
<label>Descripción (opcional)</label>
<input type="text" id="photoCaption" placeholder="Ej: Válvula hidráulica antes de cambio">
</div>
<div class="flex gap-3">
<button onclick="uploadPhoto()" class="btn btn-primary">📤 Subir</button>
<button onclick="document.getElementById('photoModal').style.display='none'" class="btn btn-secondary">Cancelar</button>
</div>
<div id="uploadStatus" style="margin-top:10px;font-size:13px"></div>
</div>
</div>
<!-- MODAL REPUESTO -->
<div id="partModal" 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:460px;max-width:95vw">
<div class="card-header">🔩 Agregar Repuesto</div>
<div class="form-group mb-4">
<label>Repuesto del Inventario</label>
<select id="partSelect" onchange="fillPartInfo()">
<option value="">-- Sin inventario (manual) --</option>
{% for p in available_parts %}
<option value="{{ p.id }}" data-price="{{ p.sale_price }}" data-stock="{{ p.quantity }}">
{{ p.name }} {% if p.part_number %}({{ p.part_number }}){% endif %} — Stock: {{ p.quantity }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group mb-4">
<label>Descripción (si es manual)</label>
<input type="text" id="partDesc" placeholder="Descripción del material">
</div>
<div class="form-grid mb-4">
<div class="form-group">
<label>Cantidad</label>
<input type="number" id="partQty" value="1" min="0.01" step="0.01">
</div>
<div class="form-group">
<label>Precio Unitario ($)</label>
<input type="number" id="partCost" value="0" step="0.01">
</div>
</div>
<div class="flex gap-3">
<button onclick="addPart()" class="btn btn-primary"> Agregar</button>
<button onclick="document.getElementById('partModal').style.display='none'" class="btn btn-secondary">Cancelar</button>
</div>
</div>
</div>
<!-- EMAIL LOG -->
{% if email_log %}
<div class="card mb-4">
<div class="card-header">📧 Historial de Envíos</div>
<div class="table-wrap">
<table>
<thead><tr><th>Fecha</th><th>Destinatario</th><th>Idioma</th><th>Estado</th><th>Enviado por</th><th>PDF</th></tr></thead>
<tbody>
{% for e in email_log %}
<tr>
<td class="text-gray" style="font-size:12px;white-space:nowrap">{{ e.sent_at[:16] }}</td>
<td>
<div style="font-weight:600;font-size:13px">{{ e.to_name or '—' }}</div>
<div style="font-size:11px;color:var(--gray)">{{ e.to_email }}</div>
</td>
<td><span class="badge badge-open" style="font-size:10px">{{ e.lang.upper() }}</span></td>
<td>
{% if e.status == 'sent' %}
<span class="badge badge-completed">✅ Enviado</span>
{% else %}
<span class="badge badge-danger" style="cursor:help"
title="{{ e.error_msg or 'Error desconocido' }}">❌ Fallido</span>
{% if e.error_msg %}
<div style="font-size:10px;color:var(--danger);max-width:200px;margin-top:2px">
{{ e.error_msg[:80] }}{% if e.error_msg|length > 80 %}...{% endif %}
</div>
{% endif %}
{% endif %}
</td>
<td class="text-gray" style="font-size:12px">{{ e.sent_by or '—' }}</td>
<td>
{% if e.pdf_filename %}
<a href="/static/uploads/pdfs/{{ e.pdf_filename }}" target="_blank" class="btn btn-sm btn-secondary">📄</a>
{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- FIRMAS -->
<div class="card mt-4">
<div class="card-header">✍️ Firmas</div>
<div class="grid-2">
<!-- Firma Técnico -->
<div>
<div style="font-size:11px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">
Técnico: {{ order.technician or '—' }}
</div>
{% if order.signature_tech %}
<img src="/static/uploads/signatures/{{ order.signature_tech }}"
style="background:white;border-radius:8px;border:1px solid rgba(0,180,216,0.2);max-width:100%;height:120px;object-fit:contain;display:block">
<button onclick="clearSignature('tech')" class="btn btn-sm btn-secondary" style="margin-top:8px">🗑 Borrar</button>
{% else %}
<canvas id="sigTech" width="340" height="120"
style="background:white;border-radius:8px;border:2px solid rgba(0,180,216,0.3);
touch-action:none;cursor:crosshair;display:block;max-width:100%"></canvas>
<div class="flex gap-2" style="margin-top:8px">
<button onclick="saveSignature('tech')" class="btn btn-sm btn-primary">💾 Guardar</button>
<button onclick="clearCanvas('sigTech')" class="btn btn-sm btn-secondary">🗑 Limpiar</button>
</div>
{% endif %}
</div>
<!-- Firma Cliente/Capitán -->
<div>
<div style="font-size:11px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">
Capitán / Cliente
</div>
{% if order.signature_client %}
<img src="/static/uploads/signatures/{{ order.signature_client }}"
style="background:white;border-radius:8px;border:1px solid rgba(0,180,216,0.2);max-width:100%;height:120px;object-fit:contain;display:block">
<button onclick="clearSignature('client')" class="btn btn-sm btn-secondary" style="margin-top:8px">🗑 Borrar</button>
{% else %}
<canvas id="sigClient" width="340" height="120"
style="background:white;border-radius:8px;border:2px solid rgba(0,180,216,0.3);
touch-action:none;cursor:crosshair;display:block;max-width:100%"></canvas>
<div class="flex gap-2" style="margin-top:8px">
<button onclick="saveSignature('client')" class="btn btn-sm btn-primary">💾 Guardar</button>
<button onclick="clearCanvas('sigClient')" class="btn btn-sm btn-secondary">🗑 Limpiar</button>
</div>
{% endif %}
</div>
</div>
</div>
<!-- SHARE MODAL -->
<div id="shareModal" 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">📤 Enviar Reporte</div>
<!-- Tabs -->
<div style="display:flex;gap:4px;margin-bottom:16px;background:rgba(0,0,0,0.2);border-radius:8px;padding:4px">
<button onclick="showTab('email')" id="tabEmail" class="btn btn-sm btn-primary" style="flex:1">✉️ Email</button>
<button onclick="showTab('share')" id="tabShare" class="btn btn-sm btn-secondary" style="flex:1">📱 WhatsApp / SMS</button>
</div>
<!-- EMAIL TAB -->
<div id="tabEmailContent">
<div class="form-group mb-3">
<label>Para (nombre)</label>
<input type="text" id="sendToName" value="{{ vessel_captain_name or vessel_owner_name or '' }}"
placeholder="Nombre del destinatario">
</div>
<div class="form-group mb-3">
<label>Email *</label>
<input type="email" id="sendToEmail"
value="{{ vessel_captain_email or vessel_owner_email or '' }}"
placeholder="email@ejemplo.com">
{% if vessel_captain_email or vessel_owner_email %}
<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap">
{% if vessel_captain_email %}
<button onclick="document.getElementById('sendToEmail').value='{{ vessel_captain_email }}';document.getElementById('sendToName').value='{{ vessel_captain_name }}'"
class="btn btn-sm btn-secondary" style="font-size:11px">⚓ {{ vessel_captain_name or 'Capitán' }}</button>
{% endif %}
{% if vessel_owner_email %}
<button onclick="document.getElementById('sendToEmail').value='{{ vessel_owner_email }}';document.getElementById('sendToName').value='{{ vessel_owner_name }}'"
class="btn btn-sm btn-secondary" style="font-size:11px">👤 {{ vessel_owner_name or 'Propietario' }}</button>
{% endif %}
</div>
{% endif %}
</div>
<div class="form-group mb-3">
<label>Idioma del Reporte</label>
<select id="sendLang" style="width:100%;padding:8px;border-radius:6px;background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.2);color:var(--white)">
<option value="es">Español</option>
<option value="en">English</option>
</select>
</div>
<div class="form-group mb-4">
<label>Asunto</label>
<input type="text" id="sendSubject"
value="Reporte de Mantenimiento — {{ order.order_number }}">
</div>
<div class="flex gap-3">
<button onclick="sendEmail()" class="btn btn-primary">📧 Enviar Email</button>
<button onclick="closeShareModal()" class="btn btn-secondary">Cancelar</button>
</div>
<div id="emailStatus" style="margin-top:10px;font-size:13px"></div>
</div>
<!-- SHARE TAB -->
<div id="tabShareContent" style="display:none">
<p style="font-size:13px;color:var(--gray);margin-bottom:16px">
Primero guardamos el PDF en el servidor para generar un link compartible.
</p>
<div id="shareLinks" style="display:none">
<div class="form-group mb-3">
<label>Link del PDF</label>
<input type="text" id="pdfLink" readonly
style="font-size:12px;cursor:pointer"
onclick="this.select();document.execCommand('copy')">
<div style="font-size:11px;color:var(--gray);margin-top:3px">Toca para copiar</div>
</div>
<div class="flex gap-3" style="flex-wrap:wrap">
<a id="waLink" href="#" target="_blank"
style="background:#25D366;color:white;padding:10px 20px;border-radius:8px;
text-decoration:none;font-weight:600;font-size:14px;display:flex;align-items:center;gap:8px">
<span style="font-size:20px">💬</span> WhatsApp
</a>
<a id="smsLink" href="#"
style="background:#0a84ff;color:white;padding:10px 20px;border-radius:8px;
text-decoration:none;font-weight:600;font-size:14px;display:flex;align-items:center;gap:8px">
<span style="font-size:20px">💬</span> SMS
</a>
</div>
</div>
<div id="shareLoading" style="font-size:13px;color:var(--gray)"></div>
<div class="flex gap-3 mt-4">
<button onclick="generateShareLinks()" class="btn btn-primary">🔗 Generar Links</button>
<button onclick="closeShareModal()" class="btn btn-secondary">Cancelar</button>
</div>
</div>
</div>
</div>
<!-- LIGHTBOX -->
<div id="lightbox" onclick="this.style.display='none'" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.95);z-index:2000;align-items:center;justify-content:center;cursor:zoom-out">
<img id="lightboxImg" style="max-width:95vw;max-height:95vh;border-radius:8px">
</div>
{% endblock %}
{% block scripts %}
<script>
const WO_ID = {{ order.id }};
function saveTechFields() {
fetch(`/work-orders/${WO_ID}/update-fields`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
root_cause: document.getElementById('root_cause').value,
repairs_done: document.getElementById('repairs_done').value
})
}).then(() => {
document.getElementById('saveStatus').textContent = '✓ Guardado';
setTimeout(() => document.getElementById('saveStatus').textContent = '', 2000);
});
}
function deleteThisWO() {
if (!confirm('¿Eliminar esta orden de trabajo? Esta acción no se puede deshacer.')) return;
fetch('/work-orders/' + WO_ID + '/delete', {method:'DELETE'})
.then(r => r.json())
.then(d => {
if (d.ok) window.location.href = '/work-orders';
else alert('Error: ' + d.error);
});
}
function updateHours() {
const h = parseFloat(document.getElementById('labor_hours').value) || 0;
const r = parseFloat(document.getElementById('labor_rate').value) || 0;
const st = document.getElementById('hoursStatus');
st.textContent = 'Guardando...';
st.style.color = 'var(--gray)';
fetch(`/api/update-labor/${WO_ID}`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({labor_hours: h, labor_rate: r})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
st.textContent = 'Guardado';
st.style.color = 'var(--success)';
setTimeout(() => location.reload(), 600);
} else {
st.textContent = 'Error';
st.style.color = 'var(--danger)';
}
})
.catch(e => { st.textContent = 'Error: ' + e; st.style.color = 'var(--danger)'; });
}
function uploadPhoto() {
const file = document.getElementById('photoFile').files[0];
if (!file) { alert('Selecciona una foto'); return; }
const fd = new FormData();
fd.append('photo', file);
fd.append('photo_type', document.getElementById('photoType').value);
fd.append('caption', document.getElementById('photoCaption').value);
document.getElementById('uploadStatus').textContent = 'Subiendo...';
fetch(`/work-orders/${WO_ID}/upload-photo`, { method:'POST', body: fd })
.then(r => r.json()).then(d => {
if (d.ok) location.reload();
else document.getElementById('uploadStatus').textContent = 'Error: ' + d.error;
});
}
function deletePhoto(id, el) {
if (!confirm('¿Eliminar esta foto?')) return;
fetch(`/api/delete-photo/${id}`, {method:'DELETE'})
.then(() => el.remove());
}
function updateCaption(id, caption) {
fetch(`/api/update-photo-caption/${id}`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({caption})
});
}
function fillPartInfo() {
const sel = document.getElementById('partSelect');
const opt = sel.options[sel.selectedIndex];
if (opt.value) {
document.getElementById('partCost').value = opt.dataset.price || 0;
}
}
function addPart() {
const data = {
part_id: document.getElementById('partSelect').value || null,
description: document.getElementById('partDesc').value,
quantity: document.getElementById('partQty').value,
unit_cost: document.getElementById('partCost').value
};
fetch(`/work-orders/${WO_ID}/add-part`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(data)
}).then(() => location.reload());
}
function openEquipModal() {
document.getElementById('woEquipEditId').value = '';
document.getElementById('woEquipSelect').value = '';
document.getElementById('woEquipDesc').value = '';
document.getElementById('woEquipHours').value = '0';
document.getElementById('woEquipNotes').value = '';
document.getElementById('equipModalTitle').textContent = '⚙️ Agregar Equipo a esta Orden';
document.getElementById('btnSaveEquip').textContent = '+ Agregar';
document.getElementById('woEquipStatus').textContent = '';
document.getElementById('woEquipModal').style.display = 'flex';
}
function closeEquipModal() {
document.getElementById('woEquipModal').style.display = 'none';
}
function editWoEquip(id, name, hours, rate, desc, notes) {
document.getElementById('woEquipEditId').value = id;
document.getElementById('woEquipDesc').value = desc;
document.getElementById('woEquipHours').value = hours;
document.getElementById('woEquipRate').value = rate;
document.getElementById('woEquipNotes').value = notes;
document.getElementById('equipModalTitle').textContent = '✏️ Editar Equipo: ' + name;
document.getElementById('btnSaveEquip').textContent = '💾 Guardar';
document.getElementById('woEquipStatus').textContent = '';
document.getElementById('woEquipModal').style.display = 'flex';
}
function saveWoEquip() {
const editId = document.getElementById('woEquipEditId').value;
const desc = document.getElementById('woEquipDesc').value.trim();
const statusEl = document.getElementById('woEquipStatus');
if (!desc) { alert('Describe el trabajo realizado'); return; }
const payload = {
equipment_id: document.getElementById('woEquipSelect').value || null,
description: desc,
notes: document.getElementById('woEquipNotes').value,
labor_hours: parseFloat(document.getElementById('woEquipHours').value) || 0,
labor_rate: parseFloat(document.getElementById('woEquipRate').value) || 0
};
statusEl.textContent = 'Guardando...';
statusEl.style.color = 'var(--gray)';
const url = editId
? '/work-orders/' + WO_ID + '/update-equipment/' + editId
: '/work-orders/' + WO_ID + '/add-equipment';
fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
})
.then(r => r.json())
.then(d => {
if (d.ok) { statusEl.textContent = '✓'; setTimeout(() => location.reload(), 400); }
else { statusEl.textContent = 'Error: ' + (d.error || 'desconocido'); statusEl.style.color = 'var(--danger)'; }
})
.catch(e => { statusEl.textContent = 'Error: ' + e; statusEl.style.color = 'var(--danger)'; });
}
function removeWoEquip(id, row) {
if (!confirm('Quitar este equipo de la orden?')) return;
fetch(`/work-orders/${WO_ID}/remove-equipment/${id}`, {method:'DELETE'})
.then(r => r.json())
.then(d => { if (d.ok) row.remove(); })
.catch(e => console.error(e));
}
// ── SHARE & EMAIL ─────────────────────────────────────────────────────────────
function openShareModal() {
document.getElementById('shareModal').style.display = 'flex';
showTab('email');
}
function closeShareModal() {
document.getElementById('shareModal').style.display = 'none';
}
function showTab(tab) {
document.getElementById('tabEmailContent').style.display = tab==='email' ? '' : 'none';
document.getElementById('tabShareContent').style.display = tab==='share' ? '' : 'none';
document.getElementById('tabEmail').className = 'btn btn-sm ' + (tab==='email' ? 'btn-primary' : 'btn-secondary');
document.getElementById('tabShare').className = 'btn btn-sm ' + (tab==='share' ? 'btn-primary' : 'btn-secondary');
document.getElementById('tabEmail').style.flex = '1';
document.getElementById('tabShare').style.flex = '1';
}
function sendEmail() {
const to = document.getElementById('sendToEmail').value.trim();
const name = document.getElementById('sendToName').value.trim();
const subj = document.getElementById('sendSubject').value.trim();
const lang = document.getElementById('sendLang').value;
const st = document.getElementById('emailStatus');
if (!to) { st.textContent = 'Ingresa un email.'; st.style.color='var(--danger)'; return; }
st.textContent = 'Generando PDF y enviando...'; st.style.color = 'var(--gray)';
fetch(`/work-orders/${WO_ID}/send`, {
method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded'},
body: new URLSearchParams({to_email:to, to_name:name, subject:subj, lang:lang})
}).then(r=>r.json()).then(d=>{
if (d.ok) {
st.textContent = '✅ Email enviado correctamente.';
st.style.color = 'var(--success)';
} else {
st.textContent = '❌ ' + (d.error || 'Error desconocido');
st.style.color = 'var(--danger)';
}
});
}
function generateShareLinks() {
const loading = document.getElementById('shareLoading');
loading.textContent = 'Guardando PDF...';
// First save PDF
fetch(`/work-orders/${WO_ID}/save-pdf`, {method:'POST'})
.then(r=>r.json()).then(saved => {
loading.textContent = 'Generando links...';
return fetch(`/work-orders/${WO_ID}/share`);
})
.then(r=>r.json()).then(d=>{
if (!d.ok) { loading.textContent = 'Error generando links.'; return; }
document.getElementById('pdfLink').value = d.pdf_url;
document.getElementById('waLink').href = d.wa_link;
document.getElementById('smsLink').href = d.sms_link;
document.getElementById('shareLinks').style.display = '';
loading.textContent = '';
});
}
openPhoto = function(src) {
document.getElementById('lightboxImg').src = src;
document.getElementById('lightbox').style.display = 'flex';
}
function initSignaturePad(canvasId) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
// Scale for retina
const rect = canvas.getBoundingClientRect();
let drawing = false;
let lastX = 0, lastY = 0;
function getPos(e) {
const r = canvas.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
return [(src.clientX - r.left) * (canvas.width / r.width),
(src.clientY - r.top) * (canvas.height / r.height)];
}
function start(e) { e.preventDefault(); drawing = true; [lastX, lastY] = getPos(e); }
function draw(e) {
if (!drawing) return; e.preventDefault();
const [x, y] = getPos(e);
ctx.beginPath(); ctx.moveTo(lastX, lastY); ctx.lineTo(x, y);
ctx.strokeStyle = '#0a1628'; ctx.lineWidth = 2.5;
ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke();
[lastX, lastY] = [x, y];
}
function stop() { drawing = false; }
canvas.addEventListener('mousedown', start);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stop);
canvas.addEventListener('mouseleave', stop);
canvas.addEventListener('touchstart', start, {passive:false});
canvas.addEventListener('touchmove', draw, {passive:false});
canvas.addEventListener('touchend', stop);
}
function clearCanvas(canvasId) {
const canvas = document.getElementById(canvasId);
if (canvas) canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
}
function saveSignature(who) {
const canvasId = who === 'tech' ? 'sigTech' : 'sigClient';
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const dataUrl = canvas.toDataURL('image/png');
fetch(`/work-orders/${WO_ID}/save-signature`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({who, dataUrl})
}).then(r => r.json()).then(d => {
if (d.ok) location.reload();
else alert('Error al guardar firma');
});
}
function clearSignature(who) {
if (!confirm('¿Borrar esta firma?')) return;
fetch(`/work-orders/${WO_ID}/clear-signature`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({who})
}).then(() => location.reload());
}
initSignaturePad('sigTech');
initSignaturePad('sigClient');
function openPhoto(src) {
document.getElementById('lightboxImg').src = src;
document.getElementById('lightbox').style.display = 'flex';
}
function addEmail(email) {
const input = document.getElementById('assignEmails');
const current = input.value.trim();
if (current && !current.includes(email)) {
input.value = current + '; ' + email;
} else if (!current) {
input.value = email;
}
}
// ── ASSIGN MODAL ─────────────────────────────────────────────────────────────
function openAssignModal() {
document.getElementById('assignModal').style.display = 'flex';
}
function closeAssignModal() {
document.getElementById('assignModal').style.display = 'none';
}
function sendAssignment() {
const emails = document.getElementById('assignEmails').value.trim();
const message = document.getElementById('assignMessage').value.trim();
if (!emails) { alert('Ingresa al menos un email'); return; }
const btn = document.getElementById('assignBtn');
btn.disabled = true; btn.textContent = 'Enviando...';
fetch('/work-orders/{{ order.id }}/assign', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({to_emails: emails, message: message})
})
.then(r => r.json())
.then(d => {
btn.disabled = false; btn.textContent = '📨 Asignar y Enviar';
if (d.ok) {
closeAssignModal();
alert('✅ WO asignada y enviada con PDF adjunto.');
location.reload();
} else {
alert('Error al enviar: ' + (d.error || 'desconocido'));
}
});
}
// Auto-notify on status change
function setStatus(newStatus) {
fetch(`/work-orders/${WO_ID}/update-status`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({status: newStatus})
}).then(() => {
{% if order.assigned_to %}
fetch('/work-orders/{{ order.id }}/notify-status', {method:'POST'});
{% endif %}
location.reload();
});
}
function confirmReopen() {
if (!confirm('¿Reabrir esta orden de trabajo? El estado volverá a "En Progreso" y podrás editarla nuevamente.')) return;
setStatus('in_progress');
}
</script>
<!-- ASSIGN MODAL -->
<div id="assignModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);
z-index:1000;align-items:center;justify-content:center">
<div style="background:var(--navy2);border:1px solid rgba(0,180,216,0.3);border-radius:12px;
padding:24px;width:100%;max-width:500px;margin:20px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0;color:var(--white)">👤 Asignar Orden de Trabajo</h3>
<button onclick="closeAssignModal()" style="background:none;border:none;color:var(--gray);font-size:20px;cursor:pointer"></button>
</div>
<div style="margin-bottom:8px;font-size:12px;color:var(--cyan)">{{ order.order_number }} — {{ order.vessel_name }}</div>
{% if order.assigned_to %}
<div style="background:rgba(0,180,216,0.08);border:1px solid rgba(0,180,216,0.2);border-radius:6px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:var(--gray)">
Actualmente asignado a: <strong style="color:var(--cyan)">{{ order.assigned_to }}</strong>
</div>
{% endif %}
<div class="form-group" style="margin-bottom:12px">
<label>Emails (separados por coma o punto y coma)</label>
<input type="text" id="assignEmails"
placeholder="tecnico@empresa.com; supervisor@empresa.com"
style="width:100%;padding:8px 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">
{% if users %}
<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap">
{% for u in users %}
<span onclick="addEmail('{{ u.email }}')"
style="background:rgba(0,180,216,0.1);border:1px solid rgba(0,180,216,0.2);
color:var(--cyan);padding:3px 8px;border-radius:12px;font-size:11px;
cursor:pointer" title="{{ u.full_name }}">
+ {{ u.full_name or u.username }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-group" style="margin-bottom:16px">
<label>Mensaje adicional (opcional)</label>
<textarea id="assignMessage" rows="3"
style="width:100%;padding:8px 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"
placeholder="Instrucciones especiales, prioridad, materiales necesarios..."></textarea>
</div>
<div style="font-size:11px;color:var(--gray);margin-bottom:12px">
📎 Se enviará el PDF de la WO adjunto al email
</div>
<div style="display:flex;gap:10px">
<button id="assignBtn" onclick="sendAssignment()" class="btn btn-primary" style="flex:1">📨 Asignar y Enviar</button>
<button onclick="closeAssignModal()" class="btn btn-secondary">Cancelar</button>
</div>
</div>
</div>
{% endblock %}