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
+378
View File
@@ -0,0 +1,378 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Marine Maintenance{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700&family=Barlow:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--navy: #0a1628;
--navy2: #0f2040;
--steel: #1a3a5c;
--ocean: #1e5f8a;
--cyan: #00b4d8;
--foam: #90e0ef;
--white: #f0f4f8;
--gray: #8a9bb0;
--warn: #f4a261;
--danger: #e63946;
--success: #2ec4b6;
--sidebar-w: 230px;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'Barlow', sans-serif;
background: var(--navy);
color: var(--white);
min-height: 100vh;
display: flex;
}
/* SIDEBAR */
.sidebar {
width: var(--sidebar-w);
min-height: 100vh;
background: var(--navy2);
border-right: 1px solid rgba(0,180,216,0.15);
display: flex;
flex-direction: column;
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 100;
}
.sidebar-logo {
padding: 24px 20px 20px;
border-bottom: 1px solid rgba(0,180,216,0.15);
}
.sidebar-logo .logo-icon {
font-size: 28px;
margin-bottom: 6px;
}
.sidebar-logo h1 {
font-family: 'Barlow Condensed', sans-serif;
font-size: 18px;
font-weight: 700;
color: var(--cyan);
letter-spacing: 1px;
line-height: 1.2;
}
.sidebar-logo span {
font-size: 11px;
color: var(--gray);
letter-spacing: 2px;
text-transform: uppercase;
}
.sidebar-nav { flex: 1; padding: 16px 0; }
.nav-section {
padding: 8px 16px 4px;
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--gray);
margin-top: 8px;
}
.nav-link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
color: var(--gray);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
border-left: 3px solid transparent;
}
.nav-link:hover { color: var(--white); background: rgba(0,180,216,0.08); }
.nav-link.active {
color: var(--cyan);
background: rgba(0,180,216,0.12);
border-left-color: var(--cyan);
}
.nav-link .icon { font-size: 18px; width: 22px; text-align: center; }
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid rgba(0,180,216,0.15);
font-size: 11px;
color: var(--gray);
}
/* MAIN */
.main {
margin-left: var(--sidebar-w);
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.topbar {
background: var(--navy2);
border-bottom: 1px solid rgba(0,180,216,0.15);
padding: 14px 28px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky; top: 0; z-index: 50;
}
.topbar h2 {
font-family: 'Barlow Condensed', sans-serif;
font-size: 22px;
font-weight: 600;
letter-spacing: 1px;
}
.topbar-actions { display: flex; gap: 10px; align-items: center; }
.content { padding: 28px; flex: 1; }
/* BUTTONS */
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 18px; border-radius: 6px; border: none;
font-family: 'Barlow', sans-serif; font-size: 13px; font-weight: 600;
cursor: pointer; text-decoration: none; transition: all 0.2s;
letter-spacing: 0.5px;
}
.btn-primary { background: var(--cyan); color: var(--navy); }
.btn-primary:hover { background: var(--foam); }
.btn-secondary { background: rgba(255,255,255,0.08); color: var(--white); border: 1px solid rgba(255,255,255,0.15); }
.btn-secondary:hover { background: rgba(255,255,255,0.14); }
.btn-danger { background: var(--danger); color: white; }
.btn-warning { background: var(--warn); color: var(--navy); }
.btn-success { background: var(--success); color: var(--navy); }
.btn-sm { padding: 5px 12px; font-size: 12px; }
/* CARDS */
.card {
background: var(--navy2);
border: 1px solid rgba(0,180,216,0.12);
border-radius: 10px;
padding: 20px;
}
.card-header {
font-family: 'Barlow Condensed', sans-serif;
font-size: 16px; font-weight: 600; letter-spacing: 1px;
color: var(--cyan); margin-bottom: 16px;
text-transform: uppercase;
}
/* STATS */
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
.stat-card {
background: var(--navy2);
border: 1px solid rgba(0,180,216,0.12);
border-radius: 10px; padding: 20px;
display: flex; flex-direction: column; gap: 4px;
}
.stat-label { font-size: 11px; color: var(--gray); text-transform: uppercase; letter-spacing: 1.5px; }
.stat-value { font-family: 'Barlow Condensed', sans-serif; font-size: 36px; font-weight: 700; color: var(--cyan); }
.stat-sub { font-size: 12px; color: var(--gray); }
/* TABLES */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 10px 14px; font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--gray); border-bottom: 1px solid rgba(0,180,216,0.15); font-weight: 600; }
td { padding: 11px 14px; border-bottom: 1px solid rgba(255,255,255,0.05); }
tr:hover td { background: rgba(0,180,216,0.04); }
tr:last-child td { border-bottom: none; }
/* BADGES */
.badge {
display: inline-block; padding: 2px 10px; border-radius: 20px;
font-size: 11px; font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase;
}
.badge-open { background: rgba(0,180,216,0.2); color: var(--cyan); }
.badge-in_progress { background: rgba(244,162,97,0.2); color: var(--warn); }
.badge-completed { background: rgba(46,196,182,0.2); color: var(--success); }
.badge-cancelled { background: rgba(255,255,255,0.1); color: var(--gray); }
.badge-warn { background: rgba(244,162,97,0.2); color: var(--warn); }
.badge-danger { background: rgba(230,57,70,0.2); color: var(--danger); }
/* FORMS */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-grid.cols-3 { grid-template-columns: 1fr 1fr 1fr; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group.full { grid-column: 1 / -1; }
label { font-size: 12px; color: var(--gray); text-transform: uppercase; letter-spacing: 1px; font-weight: 600; }
input, select, textarea {
background: rgba(255,255,255,0.06); border: 1px solid rgba(0,180,216,0.2);
border-radius: 6px; padding: 9px 12px; color: var(--white);
font-family: 'Barlow', sans-serif; font-size: 14px;
transition: border-color 0.2s;
}
input:focus, select:focus, textarea:focus {
outline: none; border-color: var(--cyan);
background: rgba(0,180,216,0.08);
}
select option { background: var(--navy2); }
textarea { resize: vertical; min-height: 80px; }
/* ALERTS */
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 13px; }
.alert-warn { background: rgba(244,162,97,0.15); border: 1px solid rgba(244,162,97,0.3); color: var(--warn); }
.alert-danger { background: rgba(230,57,70,0.15); border: 1px solid rgba(230,57,70,0.3); color: var(--danger); }
/* GRID LAYOUTS */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; }
/* PHOTO GRID */
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
.photo-card {
background: rgba(255,255,255,0.04); border-radius: 8px; overflow: hidden;
border: 1px solid rgba(0,180,216,0.15); position: relative;
}
.photo-card img { width: 100%; height: 130px; object-fit: cover; display: block; }
.photo-card .photo-label {
padding: 6px 10px; font-size: 11px; font-weight: 600;
letter-spacing: 1px; text-transform: uppercase;
}
.photo-card.before .photo-label { color: var(--warn); }
.photo-card.after .photo-label { color: var(--success); }
.photo-del {
position: absolute; top: 6px; right: 6px;
background: rgba(0,0,0,0.7); border: none; color: white;
border-radius: 50%; width: 24px; height: 24px;
cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center;
}
/* MISC */
.text-cyan { color: var(--cyan); }
.text-warn { color: var(--warn); }
.text-danger { color: var(--danger); }
.text-success { color: var(--success); }
.text-gray { color: var(--gray); }
.mt-4 { margin-top: 16px; }
.mt-6 { margin-top: 24px; }
.mb-4 { margin-bottom: 16px; }
.flex { display: flex; align-items: center; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.justify-between { justify-content: space-between; }
.section-title {
font-family: 'Barlow Condensed', sans-serif;
font-size: 18px; font-weight: 600; letter-spacing: 1px;
color: var(--cyan); margin-bottom: 14px; text-transform: uppercase;
}
/* Mobile */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
transition: transform 0.25s ease;
z-index: 999;
}
.sidebar.open { transform: translateX(0); }
.main { margin-left: 0; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.form-grid { grid-template-columns: 1fr; }
.grid-2, .grid-3 { grid-template-columns: 1fr; }
.hamburger {
display: flex !important;
align-items: center;
justify-content: center;
width: 38px; height: 38px;
background: rgba(0,180,216,0.15);
border: 1px solid rgba(0,180,216,0.3);
border-radius: 8px;
cursor: pointer;
font-size: 18px;
color: var(--cyan);
flex-shrink: 0;
}
.overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
z-index: 998;
}
.overlay.open { display: block; }
}
.hamburger { display: none; }
</style>
{% block head %}{% endblock %}
</head>
<body>
<nav class="sidebar">
<div class="sidebar-logo">
<div class="logo-icon"></div>
<h1>MARINE<br>MAINTENANCE</h1>
<span>v1.0</span>
</div>
<div class="sidebar-nav">
<div class="nav-section">Principal</div>
<a href="{{ url_for('dashboard') }}" class="nav-link {% if request.endpoint=='dashboard' %}active{% endif %}">
<span class="icon">📊</span> Dashboard
</a>
<div class="nav-section">Operaciones</div>
<a href="{{ url_for('vessels') }}" class="nav-link {% if 'vessel' in request.endpoint %}active{% endif %}">
<span class="icon">🚢</span> Embarcaciones
</a>
<a href="{{ url_for('work_orders') }}" class="nav-link {% if 'work_order' in request.endpoint %}active{% endif %}">
<span class="icon">🔧</span> Órdenes de Trabajo
</a>
<a href="{{ url_for('systems') }}" class="nav-link {% if 'system' in request.endpoint %}active{% endif %}">
<span class="icon">🔩</span> Sistemas
</a>
<a href="{{ url_for('ism_index') }}" class="nav-link {% if 'swp' in request.endpoint or 'msds' in request.endpoint or 'ism' in request.endpoint %}active{% endif %}">
<span class="icon">🛡️</span> ISM / SWP
</a>
<div class="nav-section">Inventario</div>
<a href="{{ url_for('inventory') }}" class="nav-link {% if 'inventory' in request.endpoint or 'part_' in request.endpoint %}active{% endif %}">
<span class="icon">📦</span> Repuestos
</a>
<a href="{{ url_for('purchases') }}" class="nav-link {% if 'purchase' in request.endpoint %}active{% endif %}">
<span class="icon">🛒</span> Compras
</a>
<a href="{{ url_for('suppliers') }}" class="nav-link {% if 'supplier' in request.endpoint %}active{% endif %}">
<span class="icon">🏪</span> Proveedores
</a>
{% if current_user and current_user.role in ('superadmin', 'admin') %}
<div class="nav-section">Administración</div>
<a href="{{ url_for('companies') }}" class="nav-link {% if 'compan' in request.endpoint %}active{% endif %}">
<span class="icon">🏢</span> Compañías
</a>
<a href="{{ url_for('users') }}" class="nav-link {% if 'user' in request.endpoint %}active{% endif %}">
<span class="icon">👥</span> Usuarios
</a>
<a href="{{ url_for('email_settings') }}" class="nav-link {% if 'email' in request.endpoint %}active{% endif %}">
<span class="icon">✉️</span> Config. Email
</a>
{% endif %}
</div>
<div class="sidebar-footer">
<div style="font-size:12px;color:var(--white);font-weight:600;margin-bottom:2px">
{{ current_user.full_name if current_user else '—' }}
</div>
<div style="font-size:11px;color:var(--cyan);margin-bottom:10px">
{% if current_user %}
{% if current_user.role == 'superadmin' %}⭐ Super Admin
{% elif current_user.role == 'admin' %}🔑 Admin
{% else %}🔧 Técnico{% endif %}
{% if current_user.company_name %} · {{ current_user.company_name }}{% endif %}
{% endif %}
</div>
<a href="{{ url_for('auth_logout') }}" class="btn btn-secondary btn-sm" style="width:100%;text-align:center">
🚪 Cerrar Sesión
</a>
</div>
</nav>
<div class="overlay" id="sideOverlay" onclick="closeSidebar()"></div>
<div class="main">
<div class="topbar">
<div style="display:flex;align-items:center;gap:10px">
<button class="hamburger" id="hamburger" onclick="toggleSidebar()"></button>
<h2>{% block page_title %}Dashboard{% endblock %}</h2>
</div>
<div class="topbar-actions">{% block topbar_actions %}{% endblock %}</div>
</div>
<div class="content">
{% block content %}{% endblock %}
</div>
</div>
<script>
function toggleSidebar() {
document.querySelector('.sidebar').classList.toggle('open');
document.getElementById('sideOverlay').classList.toggle('open');
}
function closeSidebar() {
document.querySelector('.sidebar').classList.remove('open');
document.getElementById('sideOverlay').classList.remove('open');
}
// Close sidebar when nav link clicked on mobile
document.querySelectorAll('.nav-link').forEach(function(link) {
link.addEventListener('click', function() {
if (window.innerWidth <= 768) closeSidebar();
});
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
{% extends 'base.html' %}
{% block title %}Compañías{% endblock %}
{% block page_title %}Compañías{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('company_new') }}" class="btn btn-primary">+ Nueva Compañía</a>
{% endblock %}
{% block content %}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px">
{% for c in companies %}
<div class="card" style="position:relative">
<div style="display:flex;align-items:center;gap:14px;margin-bottom:14px">
{% if c.logo_path %}
<img src="/static/uploads/logos/{{ c.logo_path }}"
style="width:56px;height:56px;object-fit:contain;border-radius:8px;
background:white;padding:4px;border:1px solid rgba(0,180,216,0.2)">
{% else %}
<div style="width:56px;height:56px;border-radius:8px;background:rgba(0,180,216,0.1);
display:flex;align-items:center;justify-content:center;font-size:24px">🏢</div>
{% endif %}
<div>
<div style="font-size:16px;font-weight:700;color:var(--white)">{{ c.name }}</div>
<div style="font-size:12px;color:var(--cyan)">{{ c.vessel_count }} embarcación{{ 'es' if c.vessel_count != 1 else '' }}</div>
</div>
</div>
<div style="font-size:12px;color:var(--gray);display:flex;flex-direction:column;gap:4px">
{% if c.phone %}<span>📞 {{ c.phone }}</span>{% endif %}
{% if c.email %}<span>✉️ {{ c.email }}</span>{% endif %}
{% if c.address %}<span>📍 {{ c.address }}</span>{% endif %}
{% if c.website %}<span>🌐 {{ c.website }}</span>{% endif %}
</div>
<div class="flex gap-2 mt-4">
<a href="{{ url_for('vessels') }}?company={{ c.id }}" class="btn btn-sm btn-secondary">Ver embarcaciones</a>
<a href="{{ url_for('company_edit', coid=c.id) }}" class="btn btn-sm btn-secondary">✏️ Editar</a>
</div>
</div>
{% else %}
<div style="grid-column:1/-1;text-align:center;padding:50px;color:var(--gray)">
<div style="font-size:40px;margin-bottom:12px">🏢</div>
<div>No hay compañías registradas.</div>
</div>
{% endfor %}
</div>
{% endblock %}
+51
View File
@@ -0,0 +1,51 @@
{% extends 'base.html' %}
{% block title %}{% if company %}Editar{% else %}Nueva{% endif %} Compañía{% endblock %}
{% block page_title %}{% if company %}Editar Compañía{% else %}Nueva Compañía{% endif %}{% endblock %}
{% block topbar_actions %}<a href="{{ url_for('companies') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
{% block content %}
<div class="card" style="max-width:640px">
<form method="POST" enctype="multipart/form-data">
{% if company and company.logo_path %}
<div style="margin-bottom:16px">
<div style="font-size:11px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">Logo actual</div>
<img src="/static/uploads/logos/{{ company.logo_path }}"
style="height:60px;object-fit:contain;background:white;padding:6px;border-radius:8px;border:1px solid rgba(0,180,216,0.2)">
</div>
{% endif %}
<div class="form-grid">
<div class="form-group full">
<label>Nombre de la Compañía *</label>
<input type="text" name="name" value="{{ company.name if company else '' }}" required>
</div>
<div class="form-group full">
<label>Logo (PNG o JPG — aparece en los reportes PDF)</label>
<input type="file" name="logo" accept="image/*" style="padding:8px">
</div>
<div class="form-group">
<label>Teléfono</label>
<input type="tel" name="phone" value="{{ company.phone if company else '' }}">
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" value="{{ company.email if company else '' }}">
</div>
<div class="form-group full">
<label>Dirección</label>
<input type="text" name="address" value="{{ company.address if company else '' }}">
</div>
<div class="form-group full">
<label>Sitio Web</label>
<input type="text" name="website" value="{{ company.website if company else '' }}" placeholder="https://...">
</div>
<div class="form-group full">
<label>Notas</label>
<textarea name="notes">{{ company.notes if company else '' }}</textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
<a href="{{ url_for('companies') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+84
View File
@@ -0,0 +1,84 @@
{% extends 'base.html' %}
{% block title %}Dashboard — Marine Maintenance{% endblock %}
{% block page_title %}Dashboard{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('work_order_new') }}" class="btn btn-primary">+ Nueva Orden</a>
{% endblock %}
{% block content %}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">🚢 Embarcaciones</div>
<div class="stat-value">{{ stats.vessels }}</div>
<div class="stat-sub">registradas</div>
</div>
<div class="stat-card">
<div class="stat-label">🔧 Órdenes Activas</div>
<div class="stat-value" style="color:var(--warn)">{{ stats.open_orders }}</div>
<div class="stat-sub">abiertas / en progreso</div>
</div>
<div class="stat-card">
<div class="stat-label">📦 Stock Bajo</div>
<div class="stat-value" style="color:{% if stats.low_stock > 0 %}var(--danger){% else %}var(--success){% endif %}">{{ stats.low_stock }}</div>
<div class="stat-sub">repuestos bajo mínimo</div>
</div>
<div class="stat-card">
<div class="stat-label">✅ Completadas</div>
<div class="stat-value" style="color:var(--success)">{{ stats.completed_this_month }}</div>
<div class="stat-sub">este mes</div>
</div>
</div>
<div class="grid-2">
<div class="card">
<div class="card-header">🔧 Órdenes Recientes</div>
{% if recent_orders %}
<div class="table-wrap">
<table>
<thead><tr><th>Orden</th><th>Embarcación</th><th>Estado</th><th></th></tr></thead>
<tbody>
{% for o in recent_orders %}
<tr>
<td><span class="text-cyan">{{ o.order_number }}</span></td>
<td>{{ o.vessel_name }}</td>
<td><span class="badge badge-{{ o.status }}">{{ o.status.replace('_',' ') }}</span></td>
<td><a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-secondary">Ver</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray" style="font-size:13px">No hay órdenes aún.</p>
{% endif %}
</div>
<div>
{% if low_stock_parts %}
<div class="card mb-4">
<div class="card-header">⚠️ Stock Bajo</div>
{% for p in low_stock_parts %}
<div class="flex justify-between" style="padding:8px 0; border-bottom:1px solid rgba(255,255,255,0.05); font-size:13px">
<span>{{ p.name }}</span>
<span class="text-danger">{{ p.quantity }} {{ p.unit }} / mín {{ p.min_quantity }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if upcoming %}
<div class="card">
<div class="card-header">📅 Mantenimientos Próximos</div>
{% for s in upcoming %}
<div style="padding:8px 0; border-bottom:1px solid rgba(255,255,255,0.05); font-size:13px">
<div class="flex justify-between">
<span>{{ s.vessel_name }}</span>
<span class="text-warn">{{ s.next_due_date }}</span>
</div>
<div class="text-gray" style="font-size:12px">{{ s.task_name }}</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endblock %}
+117
View File
@@ -0,0 +1,117 @@
{% extends 'base.html' %}
{% block title %}Configuración Email{% endblock %}
{% block page_title %}Configuración de Email{% endblock %}
{% block content %}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;max-width:900px">
<div class="card">
<div class="card-header">⚙️ Servidor SMTP</div>
<p style="font-size:12px;color:var(--gray);margin-bottom:16px">
Configura el servidor de correo para enviar reportes por email.
Funciona con Gmail, Outlook, Yahoo o cualquier servidor SMTP.
</p>
<form method="POST">
<div class="form-group mb-3">
<label>Servidor SMTP</label>
<input type="text" name="smtp_host" value="{{ cfg.smtp_host if cfg else '' }}"
placeholder="smtp.gmail.com">
</div>
<div class="form-grid mb-3">
<div class="form-group">
<label>Puerto</label>
<select name="smtp_port">
<option value="587" {% if not cfg or cfg.smtp_port==587 %}selected{% endif %}>587 (TLS — recomendado)</option>
<option value="465" {% if cfg and cfg.smtp_port==465 %}selected{% endif %}>465 (SSL)</option>
<option value="25" {% if cfg and cfg.smtp_port==25 %}selected{% endif %}>25 (sin cifrado)</option>
</select>
</div>
<div class="form-group">
<label>Usar TLS</label>
<select name="use_tls">
<option value="1" {% if not cfg or cfg.use_tls %}selected{% endif %}></option>
<option value="0" {% if cfg and not cfg.use_tls %}selected{% endif %}>No</option>
</select>
</div>
</div>
<div class="form-group mb-3">
<label>Usuario (email de la cuenta)</label>
<input type="email" name="smtp_user" value="{{ cfg.smtp_user if cfg else '' }}"
placeholder="tu@gmail.com">
</div>
<div class="form-group mb-3">
<label>Contraseña / App Password</label>
<input type="password" name="smtp_password" value="{{ cfg.smtp_password if cfg else '' }}"
placeholder="••••••••">
<div style="font-size:11px;color:var(--gray);margin-top:4px">
Para Gmail usa una <a href="https://myaccount.google.com/apppasswords" target="_blank" style="color:var(--cyan)">App Password</a>, no tu contraseña normal.
</div>
</div>
<div class="form-group mb-3">
<label>Nombre del Remitente</label>
<input type="text" name="from_name" value="{{ cfg.from_name if cfg else '' }}"
placeholder="Marine Maintenance Pro">
</div>
<div class="form-group mb-4">
<label>Email del Remitente</label>
<input type="email" name="from_email" value="{{ cfg.from_email if cfg else '' }}"
placeholder="reportes@tuempresa.com">
</div>
<div class="flex gap-3">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
</div>
</form>
</div>
<div>
<div class="card mb-4">
<div class="card-header">🧪 Probar Configuración</div>
<p style="font-size:12px;color:var(--gray);margin-bottom:12px">
Envía un email de prueba para verificar que la configuración es correcta.
</p>
<div class="form-group mb-3">
<label>Enviar prueba a</label>
<input type="email" id="testEmail" placeholder="tu@email.com">
</div>
<button onclick="testEmail()" class="btn btn-secondary">📧 Enviar Prueba</button>
<div id="testResult" style="margin-top:10px;font-size:13px"></div>
</div>
<div class="card">
<div class="card-header">📋 Guías Rápidas</div>
<div style="font-size:12px;color:var(--gray);line-height:1.8">
<div style="margin-bottom:10px">
<strong style="color:var(--white)">Gmail:</strong><br>
Host: smtp.gmail.com · Puerto: 587<br>
Requiere App Password (2FA activado)
</div>
<div style="margin-bottom:10px">
<strong style="color:var(--white)">Outlook / Hotmail:</strong><br>
Host: smtp-mail.outlook.com · Puerto: 587
</div>
<div>
<strong style="color:var(--white)">Yahoo:</strong><br>
Host: smtp.mail.yahoo.com · Puerto: 587
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function testEmail() {
const to = document.getElementById('testEmail').value.trim();
const res = document.getElementById('testResult');
if (!to) { res.textContent = 'Ingresa un email.'; return; }
res.textContent = 'Enviando...'; res.style.color = 'var(--gray)';
fetch('/settings/email/test', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({to})
}).then(r=>r.json()).then(d=>{
if (d.ok) { res.textContent = '✅ Email enviado. Revisa tu bandeja.'; res.style.color='var(--success)'; }
else { res.textContent = '❌ ' + d.error; res.style.color='var(--danger)'; }
});
}
</script>
{% endblock %}
+89
View File
@@ -0,0 +1,89 @@
{% extends 'base.html' %}
{% block title %}{% if equip %}Editar{% else %}Nuevo{% endif %} Equipo — {{ vessel.name }}{% endblock %}
{% block page_title %}{% if equip %}Editar Equipo{% else %}Nuevo Equipo{% endif %}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('vessel_history', vid=vessel.id) }}" class="btn btn-secondary">← Volver</a>
{% endblock %}
{% block content %}
<div class="card" style="max-width:720px">
<div style="margin-bottom:18px;font-size:13px;color:var(--gray)">
Embarcación: <span class="text-cyan">{{ vessel.name }}</span>
</div>
<form method="POST">
<div class="form-grid cols-3">
<div class="form-group full">
<label>Nombre del Equipo *</label>
<input type="text" name="name" value="{{ equip.name if equip else '' }}" required
placeholder="Ej: Motor Principal Estribor">
</div>
<div class="form-group">
<label>Tipo de Equipo</label>
<select name="equipment_type">
<option value="">-- Seleccionar --</option>
{% for t in [('engine','Motor'),('generator','Generador'),('pump','Bomba'),
('hydraulic','Hidráulico'),('electrical','Eléctrico/Electrónico'),
('hvac','HVAC / A/C'),('navigation','Navegación'),
('safety','Seguridad'),('thruster','Thruster'),
('watermaker','Water Maker'),('other','Otro')] %}
<option value="{{ t[0] }}" {% if equip and equip.equipment_type==t[0] %}selected{% endif %}>{{ t[1] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Posición a Bordo</label>
<select name="position">
<option value="">-- Seleccionar --</option>
{% for p in [('starboard','Estribor'),('port','Babor'),('center','Centro'),
('forward','Proa'),('aft','Popa'),('engine_room','Sala de Máquinas')] %}
<option value="{{ p[0] }}" {% if equip and equip.position==p[0] %}selected{% endif %}>{{ p[1] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Marca</label>
<input type="text" name="make" value="{{ equip.make if equip else '' }}"
placeholder="Ej: MTU, Caterpillar, Volvo">
</div>
<div class="form-group">
<label>Modelo</label>
<input type="text" name="model" value="{{ equip.model if equip else '' }}"
placeholder="Ej: 12V2000 M94">
</div>
<div class="form-group">
<label>Año</label>
<input type="number" name="year" value="{{ equip.year if equip else '' }}" min="1950" max="2030">
</div>
<div class="form-group full" style="background:rgba(0,180,216,0.06);border:1px solid rgba(0,180,216,0.25);border-radius:8px;padding:14px">
<label style="color:var(--cyan)">⚠ Número de Serie (crítico)</label>
<input type="text" name="serial_number"
value="{{ equip.serial_number if equip else '' }}"
placeholder="Ej: MTU-2024-12V-00432-S"
style="border-color:rgba(0,180,216,0.4);margin-top:4px">
</div>
<div class="form-group">
<label>Horas Actuales</label>
<input type="number" step="0.1" name="engine_hours"
value="{{ equip.engine_hours if equip else '0' }}">
</div>
<div class="form-group">
<label>Último Servicio (fecha)</label>
<input type="date" name="last_service_date"
value="{{ equip.last_service_date if equip else '' }}">
</div>
<div class="form-group">
<label>Último Servicio (horas)</label>
<input type="number" step="0.1" name="last_service_hours"
value="{{ equip.last_service_hours if equip else '' }}">
</div>
<div class="form-group full">
<label>Notas</label>
<textarea name="notes">{{ equip.notes if equip else '' }}</textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
<a href="{{ url_for('vessel_history', vid=vessel.id) }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+140
View File
@@ -0,0 +1,140 @@
{% extends 'base.html' %}
{% block title %}Inventario{% endblock %}
{% block page_title %}Inventario{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('part_new') }}" class="btn btn-primary">+ Agregar Item</a>
{% endblock %}
{% block head %}
<style>
.inv-table { display:block; }
.inv-cards { display:none; }
@media (max-width:768px) {
.inv-table { display:none; }
.inv-cards { display:block; }
}
.icard {
background:var(--navy2);border:1px solid rgba(0,180,216,0.12);
border-radius:10px;margin-bottom:10px;overflow:hidden;
}
.icard-header {
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;
}
.icard-name { font-size:14px;font-weight:600;color:var(--white); }
.icard-body { padding:10px 14px;font-size:13px; }
.icard-meta { display:flex;gap:12px;flex-wrap:wrap;color:var(--gray);margin-bottom:8px; }
.icard-stock { font-size:22px;font-weight:700;color:var(--cyan); }
.icard-actions {
display:flex;gap:8px;padding:8px 14px;
border-top:1px solid rgba(255,255,255,0.05);
}
.stock-low { color:var(--danger) !important; }
</style>
{% endblock %}
{% block content %}
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;align-items:center">
<select id="catFilter" onchange="filterInv()"
style="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">
<option value="">Todas las categorías</option>
{% for c in categories %}
<option value="{{ c.name|lower }}">{{ c.name }}</option>
{% endfor %}
</select>
<input type="text" id="invSearch" placeholder="🔍 Buscar repuesto o material..."
oninput="filterInv()"
style="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">
</div>
<!-- TABLA DESKTOP -->
<div class="inv-table card">
<div class="table-wrap">
<table>
<thead><tr><th>Nombre</th><th>Categoría</th><th>N° Parte</th><th>Marca</th><th>Stock</th><th>Mín.</th><th>Precio</th><th></th></tr></thead>
<tbody>
{% for p in parts %}
<tr class="inv-row" data-search="{{ (p.name ~ ' ' ~ (p.part_number or '') ~ ' ' ~ (p.brand or '') ~ ' ' ~ (p.category_name or ''))|lower }}" data-cat="{{ (p.category_name or '')|lower }}">
<td>
<strong>{{ p.name }}</strong>
{% if p.description %}<br><span class="text-gray" style="font-size:11px">{{ p.description[:60] }}</span>{% endif %}
</td>
<td><span class="badge badge-open" style="font-size:11px">{{ p.category_name or '—' }}</span></td>
<td class="text-gray" style="font-family:monospace;font-size:12px">{{ p.part_number or '—' }}</td>
<td class="text-gray">{{ p.brand or '—' }}</td>
<td>
<span style="font-weight:600;{% if p.quantity <= p.min_quantity %}color:var(--danger){% else %}color:var(--success){% endif %}">
{{ p.quantity }} {{ p.unit }}
</span>
</td>
<td class="text-gray">{{ p.min_quantity }}</td>
<td>${{ "%.2f"|format(p.cost_price or 0) }}</td>
<td class="flex gap-2">
<a href="{{ url_for('part_edit', pid=p.id) }}" class="btn btn-sm btn-secondary">✏️</a>
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-gray" style="text-align:center;padding:30px">Sin items en inventario.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TARJETAS MÓVIL -->
<div class="inv-cards">
{% for p in parts %}
<div class="icard inv-row" data-search="{{ (p.name ~ ' ' ~ (p.part_number or '') ~ ' ' ~ (p.brand or '') ~ ' ' ~ (p.category_name or ''))|lower }}" data-cat="{{ (p.category_name or '')|lower }}">
<div class="icard-header">
<div>
<div class="icard-name">{{ p.name }}</div>
{% if p.part_number %}<div style="font-size:11px;color:var(--gray);font-family:monospace">{{ p.part_number }}</div>{% endif %}
</div>
<span class="badge badge-open" style="font-size:11px">{{ p.category_name or '—' }}</span>
</div>
<div class="icard-body">
<div class="icard-meta">
{% if p.brand %}<span>🏷️ {{ p.brand }}</span>{% endif %}
{% if p.location %}<span>📍 {{ p.location }}</span>{% endif %}
<span>💲{{ "%.2f"|format(p.cost_price or 0) }}</span>
</div>
<div style="display:flex;align-items:center;gap:16px">
<div>
<div style="font-size:11px;color:var(--gray);margin-bottom:2px">Stock actual</div>
<div class="icard-stock {% if p.quantity <= p.min_quantity %}stock-low{% endif %}">
{{ p.quantity }} <span style="font-size:14px">{{ p.unit }}</span>
</div>
</div>
<div>
<div style="font-size:11px;color:var(--gray);margin-bottom:2px">Mínimo</div>
<div style="font-size:16px;color:var(--gray)">{{ p.min_quantity }} {{ p.unit }}</div>
</div>
{% if p.quantity <= p.min_quantity %}
<span style="color:var(--danger);font-size:12px;font-weight:600">⚠️ Stock bajo</span>
{% endif %}
</div>
</div>
<div class="icard-actions">
<a href="{{ url_for('part_edit', pid=p.id) }}" class="btn btn-sm btn-secondary" style="flex:1;text-align:center">✏️ Editar</a>
</div>
</div>
{% else %}
<div style="text-align:center;padding:40px;color:var(--gray)">Sin items en inventario.</div>
{% endfor %}
</div>
{% endblock %}
{% block scripts %}
<script>
function filterInv() {
const q = (document.getElementById('invSearch').value || '').toLowerCase().trim();
const cat = (document.getElementById('catFilter').value || '').toLowerCase();
document.querySelectorAll('.inv-row').forEach(r => {
const matchQ = !q || r.dataset.search.includes(q);
const matchC = !cat || r.dataset.cat.includes(cat);
r.style.display = (matchQ && matchC) ? '' : 'none';
});
}
</script>
{% endblock %}
+100
View File
@@ -0,0 +1,100 @@
{% extends 'base.html' %}
{% block title %}ISM — Procedimientos{% endblock %}
{% block page_title %}ISM — Procedimientos de Trabajo Seguro{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('swp_new') }}" class="btn btn-primary">+ Nuevo SWP</a>
<a href="{{ url_for('msds_index') }}" class="btn btn-secondary">📋 MSDS</a>
{% endblock %}
{% block content %}
<!-- Filtros -->
<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap;align-items:center">
{% if is_superadmin and companies %}
<select onchange="location.href='{{ url_for('ism_index') }}?company_id='+this.value"
style="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">
<option value="">🏢 Todas las compañías</option>
{% for c in companies %}
<option value="{{ c.id }}" {% if current_company==c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
{% endif %}
<input type="text" id="swpSearch" placeholder="🔍 Buscar procedimiento..."
oninput="filterSWP(this.value)"
style="flex:1;min-width:200px;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">
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
{% if is_superadmin %}<th>Compañía</th>{% endif %}
<th>Código</th><th>Título</th><th>Categoría</th>
<th>Versión</th><th>Estado versión</th><th>Vigente desde</th><th>Aprobado por</th><th></th>
</tr>
</thead>
<tbody>
{% for s in swps %}
<tr class="swp-row" data-search="{{ (s.code ~ ' ' ~ s.title ~ ' ' ~ s.category ~ ' ' ~ (s.company_name or ''))|lower }}">
{% if is_superadmin %}
<td class="text-gray" style="font-size:12px">{{ s.company_name or '—' }}</td>
{% endif %}
<td class="text-cyan" style="font-family:monospace;font-weight:600">{{ s.code }}</td>
<td><strong>{{ s.title }}</strong></td>
<td>
{% set cat_map = {'electrical':'⚡ Eléctrico','mechanical':'⚙️ Mecánico',
'chemical':'🧪 Químicos','confined':'🔒 Esp. Confinado',
'height':'⚓ Altura','welding':'🔥 Soldadura',
'hull':'🚢 Casco','other':'📋 Otro'} %}
<span style="font-size:12px">{{ cat_map.get(s.category, s.category) }}</span>
</td>
<td>
{% if s.version %}
<span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan)">{{ s.version }}</span>
{% else %}—{% endif %}
</td>
<td>
{% if s.ver_status == 'active' %}
<span class="badge badge-completed">✅ Aprobada</span>
{% elif s.ver_status == 'draft' %}
<span class="badge badge-open">📝 Borrador</span>
{% elif s.ver_status == 'superseded' %}
<span class="badge" style="background:rgba(138,155,176,0.2);color:var(--gray)">Sup.</span>
{% else %}—{% endif %}
</td>
<td class="text-gray" style="font-size:12px">{{ s.effective_date or '—' }}</td>
<td class="text-gray" style="font-size:12px">{{ s.approved_by or '—' }}</td>
<td class="flex gap-2" style="white-space:nowrap">
<a href="{{ url_for('swp_detail', sid=s.id) }}" class="btn btn-sm btn-secondary">Ver</a>
{% if s.ver_status == 'draft' and s.ver_id %}
<a href="{{ url_for('swp_edit_version', sid=s.id, vid=s.ver_id) }}" class="btn btn-sm btn-warning">✏️</a>
{% endif %}
{% if s.ver_status == 'active' %}
<a href="{{ url_for('swp_pdf', sid=s.id) }}?lang=es" target="_blank" class="btn btn-sm btn-primary">📄 ES</a>
<a href="{{ url_for('swp_pdf', sid=s.id) }}?lang=en" target="_blank" class="btn btn-sm btn-secondary">📄 EN</a>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="9" class="text-gray" style="text-align:center;padding:30px">
Sin procedimientos. <a href="{{ url_for('swp_new') }}" style="color:var(--cyan)">Crear el primero</a>
</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function filterSWP(q) {
q = q.toLowerCase();
document.querySelectorAll('.swp-row').forEach(r => {
r.style.display = (!q || r.dataset.search.includes(q)) ? '' : 'none';
});
}
</script>
{% endblock %}
+153
View File
@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Marine Maintenance — Login</title>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700&family=Barlow:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'Barlow', sans-serif;
background: #0a1628;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* fondo decorativo */
body::before {
content: '';
position: absolute;
width: 600px; height: 600px;
background: radial-gradient(circle, rgba(0,180,216,0.08) 0%, transparent 70%);
top: -100px; right: -100px;
border-radius: 50%;
}
body::after {
content: '';
position: absolute;
width: 400px; height: 400px;
background: radial-gradient(circle, rgba(0,180,216,0.05) 0%, transparent 70%);
bottom: -80px; left: -80px;
border-radius: 50%;
}
.login-box {
background: #0f2040;
border: 1px solid rgba(0,180,216,0.2);
border-radius: 16px;
padding: 48px 44px;
width: 400px;
max-width: 95vw;
position: relative;
z-index: 1;
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
}
.logo-area {
text-align: center;
margin-bottom: 36px;
}
.logo-icon { font-size: 42px; margin-bottom: 10px; }
h1 {
font-family: 'Barlow Condensed', sans-serif;
font-size: 22px;
font-weight: 700;
color: #00b4d8;
letter-spacing: 2px;
text-transform: uppercase;
}
.subtitle {
font-size: 12px;
color: #8a9bb0;
letter-spacing: 3px;
text-transform: uppercase;
margin-top: 4px;
}
.form-group { margin-bottom: 18px; }
label {
display: block;
font-size: 11px;
color: #8a9bb0;
text-transform: uppercase;
letter-spacing: 1.5px;
font-weight: 600;
margin-bottom: 7px;
}
input {
width: 100%;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(0,180,216,0.25);
border-radius: 8px;
padding: 11px 14px;
color: #f0f4f8;
font-family: 'Barlow', sans-serif;
font-size: 14px;
transition: border-color 0.2s, background 0.2s;
}
input:focus {
outline: none;
border-color: #00b4d8;
background: rgba(0,180,216,0.08);
}
.btn-login {
width: 100%;
background: #00b4d8;
color: #0a1628;
border: none;
border-radius: 8px;
padding: 12px;
font-family: 'Barlow', sans-serif;
font-size: 14px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
cursor: pointer;
transition: background 0.2s;
margin-top: 8px;
}
.btn-login:hover { background: #90e0ef; }
.error {
background: rgba(230,57,70,0.15);
border: 1px solid rgba(230,57,70,0.3);
color: #e63946;
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
margin-bottom: 18px;
}
.version {
text-align: center;
font-size: 11px;
color: rgba(138,155,176,0.5);
margin-top: 28px;
}
</style>
</head>
<body>
<div class="login-box">
<div class="logo-area">
<div class="logo-icon"></div>
<h1>Marine Maintenance</h1>
<div class="subtitle">Sistema de Gestión</div>
</div>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="POST">
<div class="form-group">
<label>Usuario</label>
<input type="text" name="username" autocomplete="username"
value="{{ username or '' }}" required autofocus>
</div>
<div class="form-group">
<label>Contraseña</label>
<input type="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn-login">Ingresar →</button>
</form>
<div class="version">Marine Maintenance Pro v2.0 · Puerto 5500</div>
</div>
</body>
</html>
+89
View File
@@ -0,0 +1,89 @@
{% extends 'base.html' %}
{% block title %}{% if msds %}Editar{% else %}Nueva{% endif %} MSDS{% endblock %}
{% block page_title %}{% if msds %}Editar MSDS — {{ msds.product_name }}{% else %}Nueva Ficha MSDS{% endif %}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('msds_index') }}" class="btn btn-secondary">← Volver</a>
{% endblock %}
{% block content %}
<div class="card" style="max-width:820px">
<form method="POST" enctype="multipart/form-data">
<div class="form-grid">
<div class="form-group">
<label>Nombre del Producto *</label>
<input type="text" name="product_name" required value="{{ msds.product_name if msds else '' }}">
</div>
<div class="form-group">
<label>Fabricante</label>
<input type="text" name="manufacturer" value="{{ msds.manufacturer if msds else '' }}">
</div>
<div class="form-group">
<label>Clase de Peligro (GHS)</label>
<select name="hazard_class">
<option value="">— Seleccionar —</option>
{% for cls in ['GHS01 Explosivo','GHS02 Inflamable','GHS03 Oxidante','GHS04 Gas comprimido','GHS05 Corrosivo','GHS06 Tóxico','GHS07 Nocivo/Irritante','GHS08 Peligro salud','GHS09 Peligro ambiental'] %}
<option value="{{ cls }}" {% if msds and msds.hazard_class==cls %}selected{% endif %}>{{ cls }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Repuesto Vinculado (opcional)</label>
<select name="part_id">
<option value="">— Sin vincular —</option>
{% for p in parts %}
<option value="{{ p.id }}" {% if msds and msds.part_id==p.id %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Versión</label>
<input type="text" name="version" value="{{ msds.version if msds else 'v1.0' }}">
</div>
<div class="form-group full">
<label>Riesgos y Peligros</label>
<textarea name="hazards" rows="3">{{ msds.hazards if msds else '' }}</textarea>
</div>
<div class="form-group full">
<label>Primeros Auxilios</label>
<textarea name="first_aid" rows="3">{{ msds.first_aid if msds else '' }}</textarea>
</div>
<div class="form-group full">
<label>EPP Requerido</label>
<textarea name="ppe_required" rows="2">{{ msds.ppe_required if msds else '' }}</textarea>
</div>
<div class="form-group">
<label>Manejo</label>
<textarea name="handling" rows="2">{{ msds.handling if msds else '' }}</textarea>
</div>
<div class="form-group">
<label>Almacenamiento</label>
<textarea name="storage" rows="2">{{ msds.storage if msds else '' }}</textarea>
</div>
<div class="form-group">
<label>Procedimiento de derrame</label>
<textarea name="spill_procedure" rows="2">{{ msds.spill_procedure if msds else '' }}</textarea>
</div>
<div class="form-group">
<label>Disposición / Desecho</label>
<textarea name="disposal" rows="2">{{ msds.disposal if msds else '' }}</textarea>
</div>
<div class="form-group full">
<label>Referencias (OSHA, SDS, etc.)</label>
<textarea name="ref_standards" rows="2">{{ msds.ref_standards if msds else '' }}</textarea>
</div>
<div class="form-group full">
<label>PDF Oficial del Fabricante</label>
<input type="file" name="pdf_file" accept=".pdf" style="padding:8px">
{% if msds and msds.pdf_filename %}
<div style="margin-top:6px;font-size:12px;color:var(--gray)">
Actual: <a href="/static/uploads/docs/{{ msds.pdf_filename }}" target="_blank" style="color:var(--cyan)">{{ msds.pdf_filename }}</a>
</div>
{% endif %}
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar MSDS</button>
<a href="{{ url_for('msds_index') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends 'base.html' %}
{% block title %}MSDS — Fichas Técnicas{% endblock %}
{% block page_title %}Fichas de Seguridad (MSDS){% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('msds_new') }}" class="btn btn-primary">+ Nueva MSDS</a>
<a href="{{ url_for('ism_index') }}" class="btn btn-secondary">← ISM</a>
{% endblock %}
{% block content %}
<div class="card">
<div class="table-wrap">
<table>
<thead><tr><th>Producto</th><th>Fabricante</th><th>Clase GHS</th><th>Versión</th><th>Repuesto vinculado</th><th>PDF</th><th></th></tr></thead>
<tbody>
{% for m in msds_list %}
<tr>
<td><strong>{{ m.product_name }}</strong></td>
<td class="text-gray">{{ m.manufacturer or '—' }}</td>
<td>
{% if m.hazard_class %}
<span class="badge" style="background:rgba(230,57,70,0.15);color:var(--danger)">{{ m.hazard_class }}</span>
{% else %}—{% endif %}
</td>
<td><span class="badge badge-open" style="font-size:11px">{{ m.version }}</span></td>
<td class="text-gray" style="font-size:12px">{{ m.part_name or '—' }}</td>
<td>
{% if m.pdf_filename %}
<a href="/static/uploads/docs/{{ m.pdf_filename }}" target="_blank" class="btn btn-sm btn-primary">📄</a>
{% else %}—{% endif %}
</td>
<td>
<a href="{{ url_for('msds_edit', mid=m.id) }}" class="btn btn-sm btn-secondary">✏️</a>
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-gray" style="text-align:center;padding:30px">
Sin fichas MSDS. <a href="{{ url_for('msds_new') }}" style="color:var(--cyan)">Agregar primera</a>
</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends 'base.html' %}
{% block title %}{% if part %}Editar{% else %}Nuevo{% endif %} Repuesto{% endblock %}
{% block page_title %}{% if part %}Editar Repuesto{% else %}Nuevo Repuesto{% endif %}{% endblock %}
{% block topbar_actions %}<a href="{{ url_for('inventory') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
{% block content %}
<div class="card" style="max-width:700px">
<form method="POST">
<div class="form-grid">
<div class="form-group full">
<label>Nombre *</label>
<input type="text" name="name" value="{{ part.name if part else '' }}" required>
</div>
<div class="form-group">
<label>Número de Parte</label>
<input type="text" name="part_number" value="{{ part.part_number if part else '' }}">
</div>
<div class="form-group">
<label>Categoría</label>
<select name="category_id">
<option value="">-- Sin categoría --</option>
{% for c in categories %}
<option value="{{ c.id }}" {% if part and part.category_id==c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Marca</label>
<input type="text" name="brand" value="{{ part.brand if part else '' }}">
</div>
<div class="form-group">
<label>Ubicación en Taller</label>
<input type="text" name="location" value="{{ part.location if part else '' }}" placeholder="Ej: Estante A-3">
</div>
<div class="form-group">
<label>Cantidad</label>
<input type="number" step="0.01" name="quantity" value="{{ part.quantity if part else '0' }}">
</div>
<div class="form-group">
<label>Unidad</label>
<select name="unit">
{% for u in ['pcs','ft','m','gal','L','qt','kg','lb','set','pair','roll','box'] %}
<option value="{{ u }}" {% if part and part.unit==u %}selected{% endif %}>{{ u }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Stock Mínimo (alerta)</label>
<input type="number" step="0.01" name="min_quantity" value="{{ part.min_quantity if part else '0' }}">
</div>
<div class="form-group">
<label>Precio Costo ($)</label>
<input type="number" step="0.01" name="cost_price" value="{{ part.cost_price if part else '0' }}">
</div>
<div class="form-group">
<label>Precio Venta ($)</label>
<input type="number" step="0.01" name="sale_price" value="{{ part.sale_price if part else '0' }}">
</div>
<div class="form-group full">
<label>Descripción</label>
<textarea name="description">{{ part.description if part else '' }}</textarea>
</div>
<div class="form-group full">
<label>Notas</label>
<textarea name="notes">{{ part.notes if part else '' }}</textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
<a href="{{ url_for('inventory') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+92
View File
@@ -0,0 +1,92 @@
{% extends 'base.html' %}
{% block title %}Compra #{{ purchase.id }}{% endblock %}
{% block page_title %}Compra — {{ purchase.purchase_date }}{% endblock %}
{% block topbar_actions %}<a href="{{ url_for('purchases') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
{% block content %}
<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">Proveedor</td><td>{{ purchase.supplier_name or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Factura</td><td>{{ purchase.invoice_number or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Fecha</td><td>{{ purchase.purchase_date }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Total</td><td class="text-cyan">${{ "%.2f"|format(purchase.total_amount or 0) }}</td></tr>
</table>
{% if purchase.notes %}<p style="font-size:13px;color:var(--gray);margin-top:10px">{{ purchase.notes }}</p>{% endif %}
</div>
</div>
<div class="card">
<div class="card-header flex justify-between">
<span>📦 Ítems Comprados</span>
<button onclick="document.getElementById('itemModal').style.display='flex'" class="btn btn-sm btn-primary">+ Agregar Ítem</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Repuesto</th><th>Descripción</th><th>Cantidad</th><th>Costo Unit.</th><th>Total</th></tr></thead>
<tbody>
{% for i in items %}
<tr>
<td>{{ i.part_name or '—' }}</td>
<td class="text-gray">{{ i.description or '' }}</td>
<td>{{ i.quantity }}</td>
<td>${{ "%.2f"|format(i.unit_cost) }}</td>
<td class="text-cyan">${{ "%.2f"|format(i.total_cost) }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-gray" style="text-align:center;padding:20px">Sin ítems.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div id="itemModal" 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 Ítem</div>
<div class="form-group mb-4">
<label>Repuesto del Inventario</label>
<select id="itemPart">
<option value="">-- Sin inventario (manual) --</option>
{% for p in parts %}
<option value="{{ p.id }}">{{ p.name }} {% if p.part_number %}({{ p.part_number }}){% endif %}</option>
{% endfor %}
</select>
</div>
<div class="form-group mb-4">
<label>Descripción (si es manual)</label>
<input type="text" id="itemDesc">
</div>
<div class="form-grid mb-4">
<div class="form-group">
<label>Cantidad</label>
<input type="number" id="itemQty" value="1" step="0.01">
</div>
<div class="form-group">
<label>Costo Unitario ($)</label>
<input type="number" id="itemCost" value="0" step="0.01">
</div>
</div>
<div class="flex gap-3">
<button onclick="addItem()" class="btn btn-primary"> Agregar</button>
<button onclick="document.getElementById('itemModal').style.display='none'" class="btn btn-secondary">Cancelar</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function addItem() {
const data = {
part_id: document.getElementById('itemPart').value || null,
description: document.getElementById('itemDesc').value,
quantity: document.getElementById('itemQty').value,
unit_cost: document.getElementById('itemCost').value
};
fetch(`/purchases/{{ purchase.id }}/add-item`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(data)
}).then(() => location.reload());
}
</script>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends 'base.html' %}
{% block title %}Nueva Compra{% endblock %}
{% block page_title %}Nueva Compra{% endblock %}
{% block topbar_actions %}<a href="{{ url_for('purchases') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
{% block content %}
<div class="card" style="max-width:600px">
<form method="POST">
<div class="form-grid">
<div class="form-group">
<label>Proveedor</label>
<select name="supplier_id">
<option value="">-- Sin proveedor --</option>
{% for s in suppliers %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Fecha *</label>
<input type="date" name="purchase_date" required>
</div>
<div class="form-group">
<label>Número de Factura</label>
<input type="text" name="invoice_number">
</div>
<div class="form-group full">
<label>Notas</label>
<textarea name="notes"></textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Crear y Agregar Ítems</button>
<a href="{{ url_for('purchases') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>document.querySelector('[name=purchase_date]').value = new Date().toISOString().split('T')[0];</script>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% block title %}Compras{% endblock %}
{% block page_title %}Compras de Materiales{% endblock %}
{% block topbar_actions %}<a href="{{ url_for('purchase_new') }}" class="btn btn-primary">+ Nueva Compra</a>{% endblock %}
{% block content %}
<div class="card">
<div class="table-wrap">
<table>
<thead><tr><th>Fecha</th><th>Proveedor</th><th>Factura</th><th>Total</th><th></th></tr></thead>
<tbody>
{% for p in purchases %}
<tr>
<td>{{ p.purchase_date }}</td>
<td>{{ p.supplier_name or 'Sin proveedor' }}</td>
<td class="text-gray">{{ p.invoice_number or '—' }}</td>
<td class="text-cyan">${{ "%.2f"|format(p.total_amount or 0) }}</td>
<td><a href="{{ url_for('purchase_detail', pid=p.id) }}" class="btn btn-sm btn-secondary">Ver</a></td>
</tr>
{% else %}
<tr><td colspan="5" class="text-gray" style="text-align:center;padding:30px">Sin compras registradas.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends 'base.html' %}
{% block title %}Nuevo Proveedor{% endblock %}
{% block page_title %}Nuevo Proveedor{% endblock %}
{% block topbar_actions %}<a href="{{ url_for('suppliers') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
{% block content %}
<div class="card" style="max-width:600px">
<form method="POST">
<div class="form-grid">
<div class="form-group full">
<label>Nombre *</label>
<input type="text" name="name" required value="{{ supplier.name if supplier else '' }}">
</div>
<div class="form-group">
<label>Contacto</label>
<input type="text" name="contact_name" value="{{ supplier.contact_name if supplier else '' }}">
</div>
<div class="form-group">
<label>Teléfono</label>
<input type="tel" name="phone" value="{{ supplier.phone if supplier else '' }}">
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" value="{{ supplier.email if supplier else '' }}">
</div>
<div class="form-group full">
<label>Dirección</label>
<input type="text" name="address" value="{{ supplier.address if supplier else '' }}">
</div>
<div class="form-group full">
<label>Notas</label>
<textarea name="notes">{{ supplier.notes if supplier else '' }}</textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
<a href="{{ url_for('suppliers') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+47
View File
@@ -0,0 +1,47 @@
{% extends 'base.html' %}
{% block title %}Proveedores{% endblock %}
{% block page_title %}Proveedores{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('supplier_new') }}" class="btn btn-primary">+ Nuevo Proveedor</a>
{% endblock %}
{% block content %}
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr><th>Nombre</th><th>Contacto</th><th>Teléfono</th><th>Email</th><th>Dirección</th><th></th></tr>
</thead>
<tbody>
{% for s in suppliers %}
<tr>
<td><strong>{{ s.name }}</strong></td>
<td>{{ s.contact_name or '—' }}</td>
<td>{{ s.phone or '—' }}</td>
<td>{{ s.email or '—' }}</td>
<td class="text-gray" style="font-size:12px">{{ s.address or '—' }}</td>
<td class="flex gap-2">
<a href="{{ url_for('supplier_edit', sid=s.id) }}" class="btn btn-sm btn-secondary">✏️</a>
<button onclick="deleteSupplier({{ s.id }}, this.closest('tr'))" class="btn btn-sm btn-danger">🗑️</button>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-gray" style="text-align:center;padding:30px">Sin proveedores.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function deleteSupplier(id, row) {
if (!confirm('¿Eliminar este proveedor? Solo si no tiene órdenes de compra asociadas.')) return;
fetch('/suppliers/' + id + '/delete', {method: 'DELETE'})
.then(r => r.json())
.then(d => {
if (d.ok) row.remove();
else alert('No se puede eliminar: ' + (d.error || 'tiene registros asociados'));
});
}
</script>
{% endblock %}
+171
View File
@@ -0,0 +1,171 @@
{% extends 'base.html' %}
{% block title %}{{ swp.code }} — {{ swp.title }}{% endblock %}
{% block page_title %}{{ swp.code }} — {{ swp.title }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('swp_pdf', sid=swp.id) }}?lang=es" target="_blank" class="btn btn-primary">📄 PDF ES</a>
<a href="{{ url_for('swp_pdf', sid=swp.id) }}?lang=en" target="_blank" class="btn btn-secondary">📄 PDF EN</a>
{% if current and current.status == 'draft' %}
<a href="{{ url_for('swp_edit_version', sid=swp.id, vid=current.id) }}" class="btn btn-warning">✏️ Editar Borrador</a>
{% else %}
<a href="{{ url_for('swp_edit', sid=swp.id) }}" class="btn btn-secondary">✏️ Editar</a>
<a href="{{ url_for('swp_new_version', sid=swp.id) }}" class="btn btn-secondary">📝 Nueva Versión</a>
{% endif %}
<a href="{{ url_for('ism_index') }}" class="btn btn-secondary">← Volver</a>
{% endblock %}
{% block content %}
{% if current and current.status == 'draft' %}
<div style="background:rgba(244,162,97,0.12);border:1px solid rgba(244,162,97,0.4);border-radius:8px;padding:12px 16px;margin-bottom:16px;display:flex;justify-content:space-between;align-items:center">
<div>
<span style="color:var(--warning);font-weight:600">📝 Versión {{ current.version }} pendiente de aprobación</span>
<span style="color:var(--gray);font-size:12px;margin-left:10px">Creada por {{ current.created_by }} · {{ current.created_at[:10] }}</span>
</div>
<div class="flex gap-2">
<a href="{{ url_for('swp_edit_version', sid=swp.id, vid=current.id) }}" class="btn btn-sm btn-warning">✏️ Editar</a>
<button onclick="approveVersion({{ current.id }})" class="btn btn-sm btn-success">✅ Aprobar ahora</button>
</div>
</div>
{% endif %}
{% if current %}
{% set hazards = json.loads(current.hazards or '[]') %}
{% set ppe = json.loads(current.ppe or '[]') %}
{% set tools = json.loads(current.tools or '[]') %}
{% set steps = json.loads(current.steps or '[]') %}
{% set refs = json.loads(current.ref_standards or '[]') %}
<!-- META -->
<div class="grid-2 mb-4">
<div class="card" style="padding:16px">
<div style="font-size:10px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">Información</div>
<table style="width:100%;font-size:13px">
<tr><td style="color:var(--gray);padding:4px 0;width:40%">Código</td><td style="color:var(--cyan);font-weight:700;font-family:monospace">{{ swp.code }}</td></tr>
<tr><td style="color:var(--gray);padding:4px 0">Versión activa</td><td><span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan)">{{ current.version }}</span></td></tr>
<tr><td style="color:var(--gray);padding:4px 0">Categoría</td><td>{{ categories.get(swp.category, swp.category) }}</td></tr>
<tr><td style="color:var(--gray);padding:4px 0">Vigente desde</td><td>{{ current.effective_date or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:4px 0">Aprobado por</td><td>{{ current.approved_by or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:4px 0">Creado por</td><td>{{ current.created_by or '—' }}</td></tr>
</table>
</div>
<div class="card" style="padding:16px">
<div style="font-size:10px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">Propósito y Alcance</div>
{% if current.purpose %}<p style="font-size:13px;margin-bottom:8px">{{ current.purpose }}</p>{% endif %}
{% if current.scope %}<p style="font-size:13px;color:var(--gray)">{{ current.scope }}</p>{% endif %}
</div>
</div>
<!-- RIESGOS + EPP -->
<div class="grid-2 mb-4">
<div class="card" style="padding:16px;border-left:3px solid var(--danger)">
<div style="font-size:10px;color:var(--danger);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">⚠️ Riesgos Identificados</div>
{% for h in hazards %}
<div style="display:flex;gap:8px;padding:5px 0;border-bottom:1px solid rgba(255,255,255,0.05);font-size:13px">
<span style="color:var(--danger)"></span> {{ h }}
</div>
{% else %}<p class="text-gray" style="font-size:13px"></p>{% endfor %}
</div>
<div class="card" style="padding:16px;border-left:3px solid var(--warning)">
<div style="font-size:10px;color:var(--warning);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">🦺 EPP Requerido</div>
{% for p in ppe %}
<div style="display:flex;gap:8px;padding:5px 0;border-bottom:1px solid rgba(255,255,255,0.05);font-size:13px">
<span style="color:var(--warning)"></span> {{ p }}
</div>
{% else %}<p class="text-gray" style="font-size:13px"></p>{% endfor %}
</div>
</div>
<!-- HERRAMIENTAS -->
{% if tools %}
<div class="card mb-4" style="padding:16px;border-left:3px solid #7b2d8b">
<div style="font-size:10px;color:#7b2d8b;text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">🔧 Herramientas y Materiales</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
{% for t in tools %}
<span style="background:rgba(123,45,139,0.1);border:1px solid rgba(123,45,139,0.3);color:#b57bee;padding:4px 10px;border-radius:20px;font-size:12px">{{ t }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- PASOS -->
<div class="card mb-4" style="padding:16px;border-left:3px solid var(--cyan)">
<div style="font-size:10px;color:var(--cyan);text-transform:uppercase;letter-spacing:1px;margin-bottom:12px">📋 Pasos del Procedimiento</div>
{% for step in steps %}
<div style="display:flex;gap:12px;padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.05);font-size:13px">
<span style="color:var(--cyan);font-weight:700;min-width:24px">{{ loop.index }}.</span>
<span>{{ step }}</span>
</div>
{% else %}<p class="text-gray" style="font-size:13px">Sin pasos definidos.</p>{% endfor %}
</div>
<!-- EMERGENCIA -->
{% if current.emergency %}
<div class="card mb-4" style="padding:16px;border:1px solid var(--warning);background:rgba(244,162,97,0.05)">
<div style="font-size:10px;color:var(--warning);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">🚨 Medidas de Emergencia</div>
<p style="font-size:13px">{{ current.emergency }}</p>
</div>
{% endif %}
<!-- REFERENCIAS -->
{% if refs %}
<div class="card mb-4" style="padding:16px">
<div style="font-size:10px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">📚 Referencias y Normativa</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
{% for r in refs %}
<span style="background:rgba(0,180,216,0.1);border:1px solid rgba(0,180,216,0.2);color:var(--cyan);padding:4px 10px;border-radius:20px;font-size:12px">{{ r }}</span>
{% endfor %}
</div>
</div>
{% endif %}
{% else %}
<div class="card" style="padding:40px;text-align:center;color:var(--gray)">
Sin versión activa. <a href="{{ url_for('swp_new_version', sid=swp.id) }}" style="color:var(--cyan)">Crear primera versión</a>
</div>
{% endif %}
<!-- HISTORIAL DE VERSIONES -->
<div class="card mb-4">
<div class="card-header">📚 Historial de Versiones</div>
<div class="table-wrap">
<table>
<thead><tr><th>Versión</th><th>Estado</th><th>Motivo</th><th>Diferencias</th><th>Creado por</th><th>Aprobado por</th><th>Vigente desde</th><th></th></tr></thead>
<tbody>
{% for v in versions %}
<tr>
<td><span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan);font-family:monospace">{{ v.version }}</span></td>
<td>
{% if v.status == 'active' %}<span class="badge badge-completed">Activa</span>
{% elif v.status == 'draft' %}<span class="badge badge-open">Borrador</span>
{% elif v.status == 'superseded' %}<span class="badge" style="background:rgba(138,155,176,0.2);color:var(--gray)">Supersedida</span>
{% else %}<span class="badge badge-cancelled">Archivada</span>{% endif %}
</td>
<td style="font-size:12px">{{ v.change_reason or '—' }}</td>
<td style="font-size:12px;color:var(--gray)">{{ v.diff_summary or '—' }}</td>
<td style="font-size:12px">{{ v.created_by or '—' }}</td>
<td style="font-size:12px">{{ v.approved_by or '—' }}</td>
<td style="font-size:12px">{{ v.effective_date or '—' }}</td>
<td>
{% if v.status == 'draft' %}
<div class="flex gap-2">
<a href="{{ url_for('swp_edit_version', sid=swp.id, vid=v.id) }}" class="btn btn-sm btn-warning">✏️</a>
<button onclick="approveVersion({{ v.id }})" class="btn btn-sm btn-success">✅ Aprobar</button>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function approveVersion(vid) {
if (!confirm('¿Aprobar esta versión? La versión actual quedará como supersedida.')) return;
fetch('/ism/{{ swp.id }}/versions/' + vid + '/approve', {method:'POST'})
.then(r => r.json())
.then(d => { if (d.ok) location.reload(); else alert('Error: ' + d.error); });
}
</script>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% extends 'base.html' %}
{% block title %}Editar — {{ swp.code }}{% endblock %}
{% block page_title %}Editar datos — {{ swp.code }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">← Cancelar</a>
{% endblock %}
{% block content %}
<div class="card mb-3" style="padding:12px 16px;background:rgba(0,180,216,0.06);border:1px solid rgba(0,180,216,0.2)">
<div style="font-size:13px;color:var(--gray)">
️ Edita código, título, categoría y compañía. Para cambiar el contenido (pasos, riesgos, EPP) usa
<a href="{{ url_for('swp_detail', sid=swp.id) }}" style="color:var(--warning)">✏️ Editar Borrador</a> o
<a href="{{ url_for('swp_new_version', sid=swp.id) }}" style="color:var(--cyan)">📝 Nueva Versión</a> desde el detalle del procedimiento.
</div>
</div>
<div class="card" style="max-width:640px">
<form method="POST">
<div class="form-grid">
<div class="form-group full">
<label>Compañía *</label>
<select name="company_id" required>
<option value="">— Seleccionar —</option>
{% for c in companies %}
<option value="{{ c.id }}" {% if swp.company_id==c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Código <span style="color:var(--gray);font-size:11px">(solo corrección de errores tipográficos)</span></label>
<input type="text" name="code" value="{{ swp.code }}" required>
</div>
<div class="form-group">
<label>Categoría *</label>
<select name="category" required>
{% for val, label in categories %}
<option value="{{ val }}" {% if swp.category==val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group full">
<label>Título *</label>
<input type="text" name="title" value="{{ swp.title }}" required>
</div>
</div>
<div class="flex gap-3 mt-4">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+90
View File
@@ -0,0 +1,90 @@
{% extends 'base.html' %}
{% block title %}{% if swp %}Editar{% else %}Nuevo{% endif %} Procedimiento{% endblock %}
{% block page_title %}{% if swp %}Editar — {{ swp.code }}{% else %}Nuevo Procedimiento SWP{% endif %}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('ism_index') }}" class="btn btn-secondary">← Volver</a>
{% endblock %}
{% block content %}
<div class="card" style="max-width:860px">
<form method="POST">
{% if is_superadmin and companies %}
<div class="form-group mb-4" style="background:rgba(0,180,216,0.06);border:1px solid rgba(0,180,216,0.2);border-radius:8px;padding:12px 16px">
<label style="color:var(--cyan)">🏢 Compañía *</label>
<select name="company_id" required>
<option value="">— Seleccionar compañía —</option>
{% for c in companies %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="form-grid">
<div class="form-group">
<label>Código <span style="color:var(--gray);font-size:11px">(asignado automáticamente)</span></label>
<input type="text" name="code" value="{{ swp.code if swp else code }}"
readonly style="opacity:0.6;cursor:not-allowed;background:rgba(255,255,255,0.03)">
</div>
<div class="form-group">
<label>Categoría *</label>
<select name="category" required>
{% for val, label in categories %}
<option value="{{ val }}" {% if swp and swp.category==val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group full">
<label>Título del Procedimiento *</label>
<input type="text" name="title" required
value="{{ swp.title if swp else '' }}"
placeholder="Ej: Procedimiento para trabajo eléctrico a bordo">
</div>
<div class="form-group full">
<label>Propósito</label>
<textarea name="purpose" rows="2" placeholder="¿Para qué sirve este procedimiento?">{{ swp.purpose if swp else '' }}</textarea>
</div>
<div class="form-group full">
<label>Alcance</label>
<textarea name="scope" rows="2" placeholder="¿A qué trabajos y personal aplica?">{{ swp.scope if swp else '' }}</textarea>
</div>
<div class="form-group full">
<label>Riesgos Identificados <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="hazards" rows="4" placeholder="Electrocución&#10;Quemaduras&#10;Caída al agua&#10;Cortocircuito">{{ swp.hazards if swp else '' }}</textarea>
</div>
<div class="form-group full">
<label>EPP Requerido <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="ppe" rows="3" placeholder="Guantes dieléctricos&#10;Gafas de seguridad&#10;Zapatos con aislamiento">{{ swp.ppe if swp else '' }}</textarea>
</div>
<div class="form-group full">
<label>Herramientas y Materiales <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="tools" rows="3" placeholder="Multímetro&#10;Destornillador aislado&#10;Cinta aislante"></textarea>
</div>
<div class="form-group full">
<label>Pasos del Procedimiento <span style="color:var(--gray);font-size:11px">(uno por línea, en orden)</span></label>
<textarea name="steps" rows="8" placeholder="Verificar que el sistema esté desenergizado&#10;Bloquear y etiquetar el breaker (LOTO)&#10;Verificar ausencia de voltaje con multímetro&#10;Realizar el trabajo&#10;Verificar conexiones antes de energizar&#10;Energizar y probar funcionamiento">{{ swp.steps if swp else '' }}</textarea>
</div>
<div class="form-group full">
<label>Medidas de Emergencia</label>
<textarea name="emergency" rows="3" placeholder="En caso de accidente eléctrico: cortar energía inmediatamente, llamar al 911, aplicar RCP si es necesario. No tocar a la víctima sin cortar la corriente primero.">{{ swp.emergency if swp else '' }}</textarea>
</div>
<div class="form-group full">
<label>Referencias y Normativa <span style="color:var(--gray);font-size:11px">(una por línea)</span></label>
<textarea name="ref_standards" rows="3" placeholder="OSHA 1910.147 — Control of Hazardous Energy&#10;NFPA 70E — Electrical Safety&#10;Código ISM — Sección 7">{{ swp.ref_standards if swp else '' }}</textarea>
</div>
<div class="form-group">
<label>Fecha de Vigencia</label>
<input type="date" name="effective_date">
</div>
{% if not swp %}
<div class="form-group full" style="background:rgba(244,162,97,0.08);border:1px solid rgba(244,162,97,0.3);border-radius:8px;padding:12px">
<div style="font-size:12px;color:var(--warning);margin-bottom:4px">📝 Se creará como <strong>Borrador (v1.0)</strong></div>
<div style="font-size:12px;color:var(--gray)">Puedes editarlo libremente mientras sea borrador. Una vez aprobado por el admin, quedará activo y solo se podrá modificar creando una nueva versión.</div>
</div>
{% endif %}
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar Procedimiento</button>
<a href="{{ url_for('ism_index') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
{% extends 'base.html' %}
{% block title %}Editar {{ swp.code }} {{ version.version }}{% endblock %}
{% block page_title %}✏️ Editar {{ swp.code }} — {{ version.version }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">← Cancelar</a>
{% endblock %}
{% block content %}
{% if version.status == 'active' %}
<div class="card mb-4" style="padding:12px 16px;background:rgba(244,162,97,0.08);border:1px solid rgba(244,162,97,0.3)">
<div style="font-size:13px;color:var(--warning)">
⚠️ Esta versión ya está <strong>aprobada y activa</strong>. Los cambios se guardarán pero considera si es mejor crear una Nueva Versión para mantener el historial.
</div>
</div>
{% endif %}
<div class="card" style="max-width:860px">
<form method="POST">
<div class="form-grid">
<div class="form-group">
<label>Motivo del cambio</label>
<input type="text" name="change_reason" value="{{ version.change_reason or '' }}"
placeholder="Ej: Corrección inicial">
</div>
<div class="form-group">
<label>Fecha de Vigencia</label>
<input type="date" name="effective_date" value="{{ version.effective_date or '' }}">
</div>
<div class="form-group">
<label>Resumen de diferencias</label>
<input type="text" name="diff_summary" value="{{ version.diff_summary or '' }}">
</div>
</div>
<hr style="border-color:rgba(255,255,255,0.08);margin:16px 0">
<div class="form-grid">
<div class="form-group full">
<label>Propósito</label>
<textarea name="purpose" rows="2">{{ version.purpose or '' }}</textarea>
</div>
<div class="form-group full">
<label>Alcance</label>
<textarea name="scope" rows="2">{{ version.scope or '' }}</textarea>
</div>
<div class="form-group full">
<label>Riesgos Identificados <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="hazards" rows="4">{% set h=json.loads(version.hazards or '[]') %}{% for i in h %}{{ i }}
{% endfor %}</textarea>
</div>
<div class="form-group full">
<label>EPP Requerido <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="ppe" rows="3">{% set p=json.loads(version.ppe or '[]') %}{% for i in p %}{{ i }}
{% endfor %}</textarea>
</div>
<div class="form-group full">
<label>Herramientas y Materiales <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="tools" rows="3" placeholder="Multímetro&#10;Destornillador aislado&#10;Cinta aislante">{% set to=json.loads(version.tools or '[]') %}{% for i in to %}{{ i }}
{% endfor %}</textarea>
</div>
<div class="form-group full">
<label>Pasos del Procedimiento <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="steps" rows="8">{% set s=json.loads(version.steps or '[]') %}{% for i in s %}{{ i }}
{% endfor %}</textarea>
</div>
<div class="form-group full">
<label>Medidas de Emergencia</label>
<textarea name="emergency" rows="3">{{ version.emergency or '' }}</textarea>
</div>
<div class="form-group full">
<label>Referencias y Normativa <span style="color:var(--gray);font-size:11px">(una por línea)</span></label>
<textarea name="ref_standards" rows="3">{% set r=json.loads(version.ref_standards or '[]') %}{% for i in r %}{{ i }}
{% endfor %}</textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar Cambios</button>
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+82
View File
@@ -0,0 +1,82 @@
{% extends 'base.html' %}
{% block title %}Nueva Versión — {{ swp.code }}{% endblock %}
{% block page_title %}Nueva Versión {{ new_version }} — {{ swp.code }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">← Cancelar</a>
{% endblock %}
{% block content %}
<div class="card mb-4" style="padding:12px 16px;background:rgba(244,162,97,0.08);border:1px solid rgba(244,162,97,0.3)">
<div style="font-size:13px;color:var(--warning)">
⚠️ Estás creando la versión <strong>{{ new_version }}</strong> de <strong>{{ swp.code }}</strong>.
La versión actual quedará como "Supersedida" cuando apruebes esta nueva versión.
</div>
</div>
<div class="card" style="max-width:860px">
<form method="POST">
<div class="form-grid">
<div class="form-group">
<label>Motivo del Cambio *</label>
<input type="text" name="change_reason" required
placeholder="Ej: Actualización requisitos OSHA 2026">
</div>
<div class="form-group">
<label>Resumen de Diferencias</label>
<input type="text" name="diff_summary"
placeholder="Ej: Se agregó paso de verificación LOTO">
</div>
<div class="form-group">
<label>Fecha de Vigencia</label>
<input type="date" name="effective_date">
</div>
</div>
<hr style="border-color:rgba(255,255,255,0.08);margin:16px 0">
<div class="form-grid">
<div class="form-group full">
<label>Propósito</label>
<textarea name="purpose" rows="2">{{ current.purpose if current else '' }}</textarea>
</div>
<div class="form-group full">
<label>Alcance</label>
<textarea name="scope" rows="2">{{ current.scope if current else '' }}</textarea>
</div>
<div class="form-group full">
<label>Riesgos Identificados <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="hazards" rows="4">{% if current %}{% set h=json.loads(current.hazards or '[]') %}{% for i in h %}{{ i }}
{% endfor %}{% endif %}</textarea>
</div>
<div class="form-group full">
<label>EPP Requerido <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="ppe" rows="3">{% if current %}{% set p=json.loads(current.ppe or '[]') %}{% for i in p %}{{ i }}
{% endfor %}{% endif %}</textarea>
</div>
<div class="form-group full">
<label>Herramientas y Materiales <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="tools" rows="3" placeholder="Multímetro&#10;Destornillador aislado&#10;Cinta aislante">{% if current %}{% set to=json.loads(current.tools or '[]') %}{% for i in to %}{{ i }}
{% endfor %}{% endif %}</textarea>
</div>
<div class="form-group full">
<label>Pasos del Procedimiento <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="steps" rows="8">{% if current %}{% set s=json.loads(current.steps or '[]') %}{% for i in s %}{{ i }}
{% endfor %}{% endif %}</textarea>
</div>
<div class="form-group full">
<label>Medidas de Emergencia</label>
<textarea name="emergency" rows="3">{{ current.emergency if current else '' }}</textarea>
</div>
<div class="form-group full">
<label>Referencias y Normativa <span style="color:var(--gray);font-size:11px">(una por línea)</span></label>
<textarea name="ref_standards" rows="3">{% if current %}{% set r=json.loads(current.ref_standards or '[]') %}{% for i in r %}{{ i }}
{% endfor %}{% endif %}</textarea>
</div>
</div>
<div style="background:rgba(0,180,216,0.06);border:1px solid rgba(0,180,216,0.2);border-radius:8px;padding:12px;margin-top:16px;font-size:12px;color:var(--gray)">
️ Esta versión quedará en estado <strong style="color:var(--cyan)">Borrador</strong> hasta que el admin la apruebe desde el detalle del procedimiento.
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar Borrador</button>
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block title %}{% if system %}Editar{% else %}Nuevo{% endif %} Sistema{% endblock %}
{% block page_title %}{% if system %}Editar Sistema{% else %}Nuevo Sistema{% endif %}{% endblock %}
{% block topbar_actions %}<a href="{{ url_for('systems') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
{% block content %}
<div class="card" style="max-width:500px">
{% if system and system.is_default %}
<div class="alert alert-warn mb-4">Los sistemas predefinidos no se pueden editar.</div>
{% else %}
<form method="POST">
<div class="form-group mb-4">
<label>Nombre del Sistema *</label>
<input type="text" name="name" value="{{ system.name if system else '' }}"
required placeholder="Ej: Watermaker, Bow Thruster...">
</div>
<div class="form-group mb-4">
<label>Descripción (opcional)</label>
<textarea name="description">{{ system.description if system else '' }}</textarea>
</div>
<div class="flex gap-3">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
<a href="{{ url_for('systems') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
{% endif %}
</div>
{% endblock %}
+45
View File
@@ -0,0 +1,45 @@
{% extends 'base.html' %}
{% block title %}Sistemas{% endblock %}
{% block page_title %}Sistemas{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('system_new') }}" class="btn btn-primary">+ Nuevo Sistema</a>
{% endblock %}
{% block content %}
<div class="card">
<div class="table-wrap">
<table>
<thead><tr><th>Sistema</th><th>Descripción</th><th>Tipo</th><th></th></tr></thead>
<tbody>
{% for s in systems %}
<tr>
<td><strong>{{ s.name }}</strong></td>
<td class="text-gray">{{ s.description or '—' }}</td>
<td>
{% if s.is_default %}
<span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan)">Predefinido</span>
{% else %}
<span class="badge" style="background:rgba(46,196,182,0.15);color:var(--success)">Personalizado</span>
{% endif %}
</td>
<td>
{% if not s.is_default %}
<a href="{{ url_for('system_edit', sid=s.id) }}" class="btn btn-sm btn-secondary">✏️</a>
<button onclick="deleteSystem({{ s.id }}, this.closest('tr'))" class="btn btn-sm btn-danger">🗑️</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function deleteSystem(id, row) {
if (!confirm('¿Eliminar este sistema?')) return;
fetch(`/systems/${id}/delete`, {method:'DELETE'})
.then(() => row.remove());
}
</script>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends 'base.html' %}
{% block title %}{% if user %}Editar{% else %}Nuevo{% endif %} Usuario{% endblock %}
{% block page_title %}{% if user %}Editar Usuario{% else %}Nuevo Usuario{% endif %}{% endblock %}
{% block topbar_actions %}<a href="{{ url_for('users') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
{% block content %}
<div class="card" style="max-width:560px">
<form method="POST">
<div class="form-grid">
<div class="form-group">
<label>Usuario *</label>
<input type="text" name="username" value="{{ user.username if user else '' }}" required>
</div>
<div class="form-group">
<label>Nombre Completo</label>
<input type="text" name="full_name" value="{{ user.full_name if user else '' }}">
</div>
<div class="form-group full">
<label>Email *</label>
<input type="email" name="email" value="{{ user.email if user else '' }}" required>
</div>
<div class="form-group">
<label>Contraseña {% if user %}(dejar vacío = no cambiar){% endif %}</label>
<input type="password" name="password" {% if not user %}required{% endif %}>
</div>
<div class="form-group">
<label>Rol</label>
<select name="role">
{% if current_user.role == 'superadmin' %}
<option value="superadmin" {% if user and user.role=='superadmin' %}selected{% endif %}>Super Admin</option>
<option value="admin" {% if user and user.role=='admin' %}selected{% endif %}>Admin</option>
{% endif %}
<option value="technician" {% if user and user.role=='technician' %}selected{% endif %}>Técnico</option>
</select>
</div>
{% if current_user.role == 'superadmin' %}
<div class="form-group full">
<label>Compañía (vacío = acceso a todas)</label>
<select name="company_id">
<option value="">-- Todas las compañías --</option>
{% for c in companies %}
<option value="{{ c.id }}" {% if user and user.company_id==c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if user %}
<div class="form-group">
<label>Estado</label>
<select name="is_active">
<option value="1" {% if user.is_active %}selected{% endif %}>Activo</option>
<option value="0" {% if not user.is_active %}selected{% endif %}>Inactivo</option>
</select>
</div>
{% endif %}
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
<a href="{{ url_for('users') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
{% extends 'base.html' %}
{% block title %}Usuarios{% endblock %}
{% block page_title %}Usuarios del Sistema{% endblock %}
{% block topbar_actions %}
{% if current_user.role == 'superadmin' or current_user.role == 'admin' %}
<a href="{{ url_for('user_new') }}" class="btn btn-primary">+ Nuevo Usuario</a>
{% endif %}
{% endblock %}
{% block content %}
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr><th>Usuario</th><th>Nombre</th><th>Email</th><th>Compañía</th><th>Rol</th><th>Estado</th><th>Último Login</th><th></th></tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td><strong>{{ u.username }}</strong></td>
<td>{{ u.full_name or '—' }}</td>
<td class="text-gray">{{ u.email }}</td>
<td>{{ u.company_name or '<span class="text-cyan">Todas</span>'|safe }}</td>
<td>
{% if u.role == 'superadmin' %}
<span class="badge" style="background:rgba(0,180,216,0.2);color:var(--cyan)">Super Admin</span>
{% elif u.role == 'admin' %}
<span class="badge" style="background:rgba(46,196,182,0.2);color:var(--success)">Admin</span>
{% else %}
<span class="badge" style="background:rgba(255,255,255,0.1);color:var(--gray)">Técnico</span>
{% endif %}
</td>
<td>
{% if u.is_active %}
<span class="badge badge-completed">Activo</span>
{% else %}
<span class="badge badge-cancelled">Inactivo</span>
{% endif %}
</td>
<td class="text-gray" style="font-size:12px">{{ u.last_login[:16] if u.last_login else '—' }}</td>
<td>
{% if current_user.role == 'superadmin' or (current_user.role == 'admin' and u.role == 'technician') %}
<a href="{{ url_for('user_edit', uid=u.id) }}" class="btn btn-sm btn-secondary">✏️</a>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-gray" style="text-align:center;padding:30px">Sin usuarios.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends 'base.html' %}
{% block title %}{{ 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 %}
<div class="grid-2 mb-4">
<div class="card">
<div class="card-header">📋 Información</div>
<table style="font-size:13px">
<tr><td style="color:var(--gray);padding:6px 0;width:140px">Matrícula</td><td>{{ vessel.registration or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Tipo</td><td>{{ vessel.vessel_type or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Marca / Modelo</td><td>{{ vessel.make or '' }} {{ vessel.model or '' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Año</td><td>{{ vessel.year or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Eslora</td><td>{{ vessel.length_ft or '—' }} ft</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Motor</td><td>{{ vessel.engine_type or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Horas Motor</td><td class="text-cyan">{{ vessel.engine_hours or 0 }} h</td></tr>
</table>
</div>
<div class="card">
<div class="card-header">👤 Propietario</div>
<table style="font-size:13px">
<tr><td style="color:var(--gray);padding:6px 0;width:100px">Nombre</td><td>{{ vessel.owner_name or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Teléfono</td><td>{{ vessel.owner_phone or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Email</td><td>{{ vessel.owner_email or '—' }}</td></tr>
</table>
{% if vessel.captain_name or vessel.captain_phone %}
<div style="margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.05)">
<div style="font-size:11px;color:var(--cyan);text-transform:uppercase;letter-spacing:1.5px;font-weight:600;margin-bottom:8px">⚓ Capitán</div>
<table style="font-size:13px">
<tr><td style="color:var(--gray);padding:4px 0;width:100px">Nombre</td><td>{{ vessel.captain_name or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:4px 0">Teléfono</td><td>{{ vessel.captain_phone or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:4px 0">Email</td><td>{{ vessel.captain_email or '—' }}</td></tr>
</table>
</div>
{% endif %}
{% if vessel.notes %}
<div class="mt-4" style="font-size:13px;color:var(--gray);border-top:1px solid rgba(255,255,255,0.05);padding-top:12px">
{{ vessel.notes }}
</div>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-header">🔧 Historial de Órdenes de Trabajo</div>
<div class="table-wrap">
<table>
<thead><tr><th>Orden</th><th>Tipo</th><th>Descripción</th><th>Técnico</th><th>Fecha</th><th>Estado</th><th></th></tr></thead>
<tbody>
{% for o in orders %}
<tr>
<td class="text-cyan">{{ o.order_number }}</td>
<td>{{ o.work_type or '—' }}</td>
<td>{{ o.description[:60] }}{% if o.description|length > 60 %}...{% endif %}</td>
<td>{{ o.technician or '—' }}</td>
<td class="text-gray">{{ o.start_date or o.created_at[:10] }}</td>
<td><span class="badge badge-{{ o.status }}">{{ o.status.replace('_',' ') }}</span></td>
<td><a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-secondary">Ver</a></td>
</tr>
{% else %}
<tr><td colspan="7" class="text-gray" style="text-align:center;padding:20px">Sin órdenes de trabajo.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+114
View File
@@ -0,0 +1,114 @@
{% extends 'base.html' %}
{% block title %}{% if vessel %}Editar{% else %}Nueva{% endif %} Embarcación{% endblock %}
{% block page_title %}{% if vessel %}Editar Embarcación{% else %}Nueva Embarcación{% endif %}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('vessels') }}" class="btn btn-secondary">← Volver</a>
{% endblock %}
{% block content %}
<div class="card" style="max-width:820px">
<form method="POST">
{% if current_user.role in ('superadmin','admin') and companies %}
<div class="section-title">Compañía</div>
<div class="form-grid mb-4">
<div class="form-group full">
<label>Compañía *</label>
<select name="company_id" required>
<option value="">-- Seleccionar --</option>
{% for c in companies %}
<option value="{{ c.id }}" {% if vessel and vessel.company_id==c.id %}selected{% elif current_user.company_id==c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
<div class="section-title">Datos de la Embarcación</div>
<div class="form-grid cols-3">
<div class="form-group full">
<label>Nombre *</label>
<input type="text" name="name" value="{{ vessel.name if vessel else '' }}" required>
</div>
<div class="form-group">
<label>Matrícula</label>
<input type="text" name="registration" value="{{ vessel.registration if vessel else '' }}">
</div>
<div class="form-group">
<label>Tipo</label>
<select name="vessel_type">
<option value="">-- Seleccionar --</option>
{% for t in ['Motor Yacht','Sailing Yacht','Sport Fisherman','Center Console','Catamaran','Trawler','Pontoon','PWC','Commercial','Other'] %}
<option value="{{ t }}" {% if vessel and vessel.vessel_type==t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Año</label>
<input type="number" name="year" value="{{ vessel.year if vessel else '' }}" min="1900" max="2030">
</div>
<div class="form-group">
<label>Marca</label>
<input type="text" name="make" value="{{ vessel.make if vessel else '' }}" placeholder="Azimut, Sunseeker...">
</div>
<div class="form-group">
<label>Modelo</label>
<input type="text" name="model" value="{{ vessel.model if vessel else '' }}" placeholder="80, 68S...">
</div>
<div class="form-group">
<label>Eslora (pies)</label>
<input type="number" step="0.1" name="length_ft" value="{{ vessel.length_ft if vessel else '' }}">
</div>
<div class="form-group">
<label>Bandera</label>
<input type="text" name="flag" value="{{ vessel.flag if vessel else '' }}" placeholder="USA, Panama...">
</div>
<div class="form-group">
<label>Puerto de Matrícula</label>
<input type="text" name="port_of_registry" value="{{ vessel.port_of_registry if vessel else '' }}">
</div>
</div>
<div class="section-title mt-6">Propietario</div>
<div class="form-grid">
<div class="form-group">
<label>Nombre Propietario</label>
<input type="text" name="owner_name" value="{{ vessel.owner_name if vessel else '' }}">
</div>
<div class="form-group">
<label>Teléfono</label>
<input type="tel" name="owner_phone" value="{{ vessel.owner_phone if vessel else '' }}">
</div>
<div class="form-group full">
<label>Email</label>
<input type="email" name="owner_email" value="{{ vessel.owner_email if vessel else '' }}">
</div>
</div>
<div class="section-title mt-6">⚓ Capitán / Contacto Operativo</div>
<div class="form-grid">
<div class="form-group">
<label>Nombre Capitán</label>
<input type="text" name="captain_name" value="{{ vessel.captain_name if vessel else '' }}">
</div>
<div class="form-group">
<label>Teléfono</label>
<input type="tel" name="captain_phone" value="{{ vessel.captain_phone if vessel else '' }}">
</div>
<div class="form-group full">
<label>Email</label>
<input type="email" name="captain_email" value="{{ vessel.captain_email if vessel else '' }}">
</div>
</div>
<div class="form-group full mt-4">
<label>Notas</label>
<textarea name="notes">{{ vessel.notes if vessel else '' }}</textarea>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
<a href="{{ url_for('vessels') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+333
View File
@@ -0,0 +1,333 @@
{% 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 %}
+119
View File
@@ -0,0 +1,119 @@
{% extends 'base.html' %}
{% block title %}Embarcaciones{% endblock %}
{% block page_title %}Embarcaciones{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('vessel_new') }}" class="btn btn-primary">+ Nueva Embarcación</a>
{% endblock %}
{% block head %}
<style>
.vessel-cards { display:none; }
.vessel-table { display:block; }
@media (max-width:768px) {
.vessel-table { display:none; }
.vessel-cards { display:grid; grid-template-columns:1fr; gap:10px; }
}
.vcard {
background:var(--navy2);border:1px solid rgba(0,180,216,0.12);
border-radius:10px;overflow:hidden;
}
.vcard-header {
padding:12px 14px;background:rgba(0,0,0,0.25);
border-bottom:1px solid rgba(255,255,255,0.05);
display:flex;justify-content:space-between;align-items:center;
}
.vcard-name { font-size:16px;font-weight:600;color:var(--white); }
.vcard-type { font-size:12px;color:var(--cyan); }
.vcard-body { padding:12px 14px; }
.vcard-row { display:flex;gap:16px;flex-wrap:wrap;margin-bottom:8px;font-size:13px; }
.vcard-row span { color:var(--gray); }
.vcard-row strong { color:var(--white); }
.vcard-actions {
display:flex;gap:8px;padding:10px 14px;
border-top:1px solid rgba(255,255,255,0.05);
background:rgba(0,0,0,0.1);
}
.vcard-actions a { flex:1;text-align:center; }
</style>
{% endblock %}
{% block content %}
<div style="margin-bottom:14px">
<input type="text" id="vesselSearch"
placeholder="🔍 Buscar embarcación..."
oninput="filterVessels(this.value)"
style="width:100%;max-width:400px;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">
</div>
<!-- TABLA DESKTOP -->
<div class="vessel-table card">
<div class="table-wrap">
<table>
<thead><tr><th>Nombre</th><th>Tipo</th><th>Marca/Modelo</th><th>Año</th><th>Propietario</th><th>Capitán</th><th>WOs</th><th></th></tr></thead>
<tbody>
{% for v in vessels %}
<tr class="vessel-row" data-search="{{ (v.name ~ ' ' ~ (v.owner_name or '') ~ ' ' ~ (v.captain_name or '') ~ ' ' ~ (v.vessel_type or ''))|lower }}">
<td><strong>{{ v.name }}</strong></td>
<td>{{ v.vessel_type or '—' }}</td>
<td class="text-gray">{{ v.make or '' }} {{ v.model or '' }}</td>
<td>{{ v.year or '—' }}</td>
<td>{{ v.owner_name or '—' }}</td>
<td>{{ v.captain_name or '—' }}</td>
<td><span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan)">{{ v.wo_count or 0 }}</span></td>
<td class="flex gap-2">
<a href="{{ url_for('vessel_history', vid=v.id) }}" class="btn btn-sm btn-primary">Historial</a>
<a href="{{ url_for('vessel_edit', vid=v.id) }}" class="btn btn-sm btn-secondary">✏️</a>
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-gray" style="text-align:center;padding:30px">Sin embarcaciones.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TARJETAS MÓVIL -->
<div class="vessel-cards" id="vesselCards">
{% for v in vessels %}
<div class="vcard vessel-row" data-search="{{ (v.name ~ ' ' ~ (v.owner_name or '') ~ ' ' ~ (v.captain_name or '') ~ ' ' ~ (v.vessel_type or ''))|lower }}">
<div class="vcard-header">
<div>
<div class="vcard-name">🚢 {{ v.name }}</div>
<div class="vcard-type">{{ v.vessel_type or '—' }} {% if v.year %}· {{ v.year }}{% endif %}</div>
</div>
<span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan);font-size:13px">
{{ v.wo_count or 0 }} WOs
</span>
</div>
<div class="vcard-body">
<div class="vcard-row">
{% if v.make or v.model %}<span>Marca/Modelo:</span><strong>{{ v.make or '' }} {{ v.model or '' }}</strong>{% endif %}
</div>
<div class="vcard-row">
{% if v.owner_name %}<span>Propietario:</span><strong>{{ v.owner_name }}</strong>{% endif %}
{% if v.captain_name %}<span>Capitán:</span><strong>{{ v.captain_name }}</strong>{% endif %}
</div>
{% if v.engine_hours %}<div style="font-size:12px;color:var(--gray)">⚙️ {{ v.engine_hours }} h motor</div>{% endif %}
</div>
<div class="vcard-actions">
<a href="{{ url_for('vessel_history', vid=v.id) }}" class="btn btn-sm btn-primary">📋 Historial</a>
<a href="{{ url_for('work_order_new') }}?vessel={{ v.id }}" class="btn btn-sm btn-secondary">+ WO</a>
<a href="{{ url_for('vessel_edit', vid=v.id) }}" class="btn btn-sm btn-secondary">✏️</a>
</div>
</div>
{% else %}
<div style="text-align:center;padding:40px;color:var(--gray)">Sin embarcaciones registradas.</div>
{% endfor %}
</div>
{% endblock %}
{% block scripts %}
<script>
function filterVessels(q) {
q = q.toLowerCase().trim();
document.querySelectorAll('.vessel-row').forEach(r => {
r.style.display = (!q || r.dataset.search.includes(q)) ? '' : 'none';
});
}
</script>
{% endblock %}
+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 %}
+78
View File
@@ -0,0 +1,78 @@
{% extends 'base.html' %}
{% block title %}Editar {{ order.order_number }}{% endblock %}
{% block page_title %}✏️ Editar {{ order.order_number }} — {{ order.vessel_name }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('work_order_detail', woid=order.id) }}" class="btn btn-secondary">← Cancelar</a>
{% endblock %}
{% block content %}
<div class="card" style="max-width:820px">
<form method="POST">
<div class="form-grid">
<div class="form-group">
<label>Sistema Principal</label>
<select name="system_id">
<option value="">-- Sin sistema --</option>
{% for s in systems %}
<option value="{{ s.id }}" {% if order.system_id==s.id %}selected{% endif %}>{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Tipo de Facturación *</label>
<select name="billing_type" required>
<option value="labor_materials" {% if order.billing_type=='labor_materials' or not order.billing_type %}selected{% endif %}>
Mano de obra + Materiales (discriminado)
</option>
<option value="lump_sum" {% if order.billing_type=='lump_sum' %}selected{% endif %}>
A todo costo (precio fijo todo incluido)
</option>
<option value="labor_only" {% if order.billing_type=='labor_only' %}selected{% endif %}>
Solo mano de obra
</option>
</select>
</div>
<div class="form-group">
<label>Tipo de Trabajo</label>
<select name="work_type">
<option value="">-- Seleccionar --</option>
{% for wt in ['Preventive','Corrective','Inspection','Installation','Other'] %}
<option value="{{ wt }}" {% if order.work_type==wt %}selected{% endif %}>{{ wt }}</option>
{% endfor %}
</select>
</div>
<div class="form-group full">
<label>Scope — Resumen del Trabajo *</label>
<input type="text" name="scope" value="{{ order.scope or '' }}" required>
</div>
<div class="form-group">
<label>Técnico</label>
<input type="text" name="technician" value="{{ order.technician or '' }}">
</div>
<div class="form-group">
<label>Fecha Inicio</label>
<input type="date" name="start_date" value="{{ order.start_date or '' }}">
</div>
<div class="form-group">
<label>Horas Motor (inicio)</label>
<input type="number" step="0.1" name="engine_hours_start" value="{{ order.engine_hours_start or '' }}">
</div>
<div class="form-group">
<label>Tarifa M.O. ($/h)</label>
<input type="number" step="0.01" name="labor_rate" value="{{ order.labor_rate or 0 }}">
</div>
<div class="form-group full">
<label>Descripción Detallada</label>
<textarea name="description" rows="4">{{ order.description or '' }}</textarea>
</div>
<div class="form-group full">
<label>Notas Internas</label>
<textarea name="notes" rows="2">{{ order.notes or '' }}</textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar Cambios</button>
<a href="{{ url_for('work_order_detail', woid=order.id) }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+133
View File
@@ -0,0 +1,133 @@
{% extends 'base.html' %}
{% block title %}Nueva Orden de Trabajo{% endblock %}
{% block page_title %}Nueva Orden de Trabajo{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('work_orders') }}" class="btn btn-secondary">← Volver</a>
{% endblock %}
{% block content %}
<div class="card" style="max-width:820px">
<form method="POST">
<div class="form-grid">
<div class="form-group">
<label>Sistema Principal *</label>
<select name="system_id" id="systemSelect" required>
<option value="">-- Seleccionar Sistema --</option>
</select>
</div>
<div class="form-group">
<label style="color:var(--cyan)">🚢 Embarcación *</label>
<select name="vessel_id" id="vesselSelect" required onchange="loadEquipment(this.value)"
style="border-color:rgba(0,180,216,0.4);font-weight:600">
<option value="">— Seleccionar Embarcación —</option>
{% for v in vessels %}
<option value="{{ v.id }}" {% if preselect_vessel and preselect_vessel==v.id|string %}selected{% endif %}>{{ v.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Equipo Específico (opcional)</label>
<select name="equipment_id" id="equipmentSelect">
<option value="">— General / Sin equipo específico —</option>
{% for e in equipment_list %}
<option value="{{ e.id }}">{{ e.name }} {% if e.serial_number %}· S/N: {{ e.serial_number }}{% endif %}</option>
{% endfor %}
</select>
</div>
<div class="form-group full">
<label>Scope — Resumen del Trabajo *</label>
<input type="text" name="scope" required
placeholder="Ej: Diagnóstico y cambio de cargador de baterías estribor">
</div>
<div class="form-group">
<label>Tipo de Facturación *</label>
<select name="billing_type" required>
<option value="labor_materials" {% if not order or order.billing_type=='labor_materials' %}selected{% endif %}>
Mano de obra + Materiales (discriminado)
</option>
<option value="lump_sum" {% if order and order.billing_type=='lump_sum' %}selected{% endif %}>
A todo costo (precio fijo todo incluido)
</option>
<option value="labor_only" {% if order and order.billing_type=='labor_only' %}selected{% endif %}>
Solo mano de obra
</option>
</select>
</div>
<div class="form-group">
<label>Tipo de Trabajo</label>
<select name="work_type">
<option value="">-- Seleccionar --</option>
<option value="Preventive">Preventivo</option>
<option value="Corrective">Correctivo</option>
<option value="Inspection">Inspección</option>
<option value="Installation">Instalación</option>
<option value="Other">Otro</option>
</select>
</div>
<div class="form-group">
<label>Técnico</label>
<input type="text" name="technician">
</div>
<div class="form-group">
<label>Fecha Inicio</label>
<input type="date" name="start_date" id="startDate">
</div>
<div class="form-group">
<label>Horas Motor (inicio)</label>
<input type="number" step="0.1" name="engine_hours_start">
</div>
<div class="form-group">
<label>Tarifa Mano de Obra ($/h)</label>
<input type="number" step="0.01" name="labor_rate" value="0">
</div>
<div class="form-group full">
<label>Descripción Detallada</label>
<textarea name="description" rows="4"
placeholder="Describe en detalle el trabajo a realizar..."></textarea>
</div>
<div class="form-group full">
<label>Notas Internas</label>
<textarea name="notes" rows="2"></textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Crear Orden</button>
<a href="{{ url_for('work_orders') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('startDate').value = new Date().toISOString().split('T')[0];
// Load systems
fetch('/api/systems').then(r=>r.json()).then(data => {
const sel = document.getElementById('systemSelect');
data.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name;
sel.appendChild(opt);
});
});
function loadEquipment(vesselId) {
const sel = document.getElementById('equipmentSelect');
sel.innerHTML = '<option value="">— General / Sin equipo específico —</option>';
if (!vesselId) return;
fetch(`/api/vessel-equipment/${vesselId}`)
.then(r => r.json())
.then(data => {
data.forEach(e => {
const opt = document.createElement('option');
opt.value = e.id;
opt.textContent = e.name + (e.serial_number ? ` · S/N: ${e.serial_number}` : '');
sel.appendChild(opt);
});
});
}
{% if preselect_vessel %}
loadEquipment('{{ preselect_vessel }}');
{% endif %}
</script>
{% endblock %}
+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 %}