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:
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}>Sí</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 Quemaduras Caída al agua 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 Gafas de seguridad 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 Destornillador aislado 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 Bloquear y etiquetar el breaker (LOTO) Verificar ausencia de voltaje con multímetro Realizar el trabajo Verificar conexiones antes de energizar 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 NFPA 70E — Electrical Safety 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 %}
|
||||
@@ -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 Destornillador aislado 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 %}
|
||||
@@ -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 Destornillador aislado 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user