Initial commit — MarineMaintenance v1.0

Marine maintenance management: work orders with photos, ISM/SWP procedures,
MSDS, inventory, RFQ/purchases, vessel history, bilingual PDF reports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 01:54:20 -04:00
commit 67a0e674ca
44 changed files with 8439 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
# === Base de datos ===
marine_maintenance.db
*.db
# === Archivos subidos (datos operacionales) ===
static/uploads/photos/
static/uploads/pdfs/
static/uploads/docs/
static/uploads/signatures/
static/uploads/logos/
# === Secrets ===
.env
*.env
secrets.py
# === Python ===
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
*.egg
# === Entorno virtual ===
venv/
env/
.venv/
# === Archivos temporales ===
*.swp
*.swo
{templates,static
# === IDE / OS ===
.vscode/
.idea/
.DS_Store
Thumbs.db
+55
View File
@@ -0,0 +1,55 @@
==================================================
MARINE MAINTENANCE PRO v2.0
==================================================
INSTALACIÓN
-----------
1. Descomprime el ZIP en cualquier carpeta, ej: C:\Projects\marine_maintenance\
2. Instala dependencias (solo una vez):
pip install flask reportlab Pillow
3. Arranca la aplicación:
cd marine_maintenance
python app.py
4. Abre en el navegador:
http://localhost:5500
PRIMER ACCESO
-------------
Usuario: admin
Contraseña: admin123
(Cámbiala inmediatamente en Usuarios → Editar)
ACCESO DESDE CELULAR (misma red WiFi)
--------------------------------------
http://192.168.x.x:5500
(reemplaza x.x con la IP de tu PC)
ROLES
-----
- superadmin : Ve todo, todas las compañías
- admin : Ve solo su compañía
- technician : Ve solo su compañía, sin acceso a administración
ESTRUCTURA DE CARPETAS
----------------------
marine_maintenance/
├── app.py ← Servidor principal
├── auth.py ← Sistema de login
├── report_generator.py ← Generador de PDF
├── schema.sql ← Estructura de la BD
├── requirements.txt
├── templates/ ← Pantallas HTML
└── static/
└── uploads/
├── photos/ ← Fotos de órdenes de trabajo
├── logos/ ← Logos de compañías
└── docs/ ← Documentos adjuntos
INTEGRACIÓN FUTURA CON MARINEINVOICE PRO
-----------------------------------------
Las órdenes completadas tienen un campo invoice_exported=0
listo para ser usado cuando se integren ambos sistemas.
==================================================
+23
View File
@@ -0,0 +1,23 @@
@echo off
title Marine Maintenance
color 0A
echo.
echo ================================================
echo Marine Maintenance Pro - Servidor de Mantenimiento
echo ================================================
echo.
echo Iniciando servidor...
echo.
echo Acceso LOCAL: http://localhost:5500
echo Acceso TAILSCALE: http://100.96.43.86:5500
echo.
echo Usuario inicial: admin
echo Contrasena: Geronimo6&8
echo (Cambiala despues del primer login!)
echo.
echo Para detener el servidor: Ctrl+C
echo ================================================
echo.
cd /d "%~dp0"
C:\Users\aerom\AppData\Local\Python\pythoncore-3.14-64\python.exe app.py
pause
+2341
View File
File diff suppressed because it is too large Load Diff
+125
View File
@@ -0,0 +1,125 @@
"""
auth.py — Login y control de acceso sin flask-login
Usa sesiones de Flask + werkzeug para hash de contraseñas
"""
from functools import wraps
from flask import session, redirect, url_for, flash, request
from werkzeug.security import generate_password_hash, check_password_hash
import sqlite3, os
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'marine_maintenance.db')
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
# ── Sesión ────────────────────────────────────────────────────────────────────
def login_user(user):
session['user_id'] = user['id']
session['username'] = user['username']
session['full_name'] = user['full_name'] or user['username']
session['role'] = user['role']
session['company_id'] = user['company_id']
session['company_name'] = user['company_name'] if 'company_name' in user.keys() else None
# update last_login
conn = get_db()
conn.execute("UPDATE users SET last_login=CURRENT_TIMESTAMP WHERE id=?", (user['id'],))
conn.commit()
conn.close()
def logout_user():
session.clear()
def current_user():
if 'user_id' not in session:
return None
return {
'id': session.get('user_id'),
'username': session.get('username'),
'full_name': session.get('full_name'),
'role': session.get('role'),
'company_id': session.get('company_id'),
'company_name': session.get('company_name'),
}
def is_logged_in():
return 'user_id' in session
def is_superadmin():
return session.get('role') == 'superadmin'
def is_admin():
return session.get('role') in ('superadmin', 'admin')
# ── Decoradores ───────────────────────────────────────────────────────────────
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not is_logged_in():
return redirect(url_for('auth_login', next=request.url))
return f(*args, **kwargs)
return decorated
def admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not is_logged_in():
return redirect(url_for('auth_login'))
if not is_admin():
return redirect(url_for('dashboard'))
return f(*args, **kwargs)
return decorated
def superadmin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not is_logged_in():
return redirect(url_for('auth_login'))
if not is_superadmin():
return redirect(url_for('dashboard'))
return f(*args, **kwargs)
return decorated
# ── Filtro de compañía ────────────────────────────────────────────────────────
def company_filter():
"""
Retorna (sql_where, params) para filtrar por compañía del usuario.
Si es superadmin, no filtra.
"""
if is_superadmin():
return "", []
cid = session.get('company_id')
if cid:
return "WHERE company_id = ?", [cid]
return "WHERE 1=0", [] # sin compañía asignada no ve nada
def vessel_filter():
"""WHERE clause para embarcaciones según compañía del usuario."""
if is_superadmin():
return "", []
cid = session.get('company_id')
if cid:
return "WHERE v.company_id = ?", [cid]
return "WHERE 1=0", []
# ── Hash de contraseñas ───────────────────────────────────────────────────────
def hash_password(password):
return generate_password_hash(password)
def verify_password(password, hashed):
return check_password_hash(hashed, password)
# ── Crear superadmin inicial ──────────────────────────────────────────────────
def create_initial_superadmin(username='admin', password='admin123', email='admin@marine.local'):
conn = get_db()
existing = conn.execute("SELECT id FROM users WHERE role='superadmin'").fetchone()
if not existing:
conn.execute("""
INSERT INTO users (company_id, username, email, password_hash, full_name, role)
VALUES (NULL, ?, ?, ?, 'Super Administrator', 'superadmin')
""", (username, email, hash_password(password)))
conn.commit()
print(f"[AUTH] Superadmin creado: {username} / {password}")
conn.close()
+210
View File
@@ -0,0 +1,210 @@
"""
mailer.py — Envío de email con PDF adjunto via SMTP
"""
import smtplib
import ssl
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
import sqlite3, os
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'marine_maintenance.db')
def get_email_config(company_id):
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cfg = None
if company_id:
cfg = conn.execute("SELECT * FROM email_config WHERE company_id=?", (company_id,)).fetchone()
if not cfg:
cfg = conn.execute("SELECT * FROM email_config WHERE company_id IS NULL").fetchone()
if not cfg:
cfg = conn.execute("SELECT * FROM email_config LIMIT 1").fetchone()
conn.close()
return dict(cfg) if cfg else None
def _smtp_send(host, port, user, pwd, use_tls, from_addr, to_addrs, msg_str):
"""Envío SMTP puro — abre conexión fresca cada vez."""
if port == 465:
with smtplib.SMTP_SSL(host, port, timeout=30) as s:
s.login(user, pwd)
s.sendmail(from_addr, to_addrs, msg_str)
else:
with smtplib.SMTP(host, port, timeout=30) as s:
s.ehlo()
if use_tls:
s.starttls()
s.ehlo()
s.login(user, pwd)
s.sendmail(from_addr, to_addrs, msg_str)
def send_report_email(to_email, to_name, subject, body_html,
pdf_bytes, pdf_filename, company_id):
cfg = get_email_config(company_id)
if not cfg or not cfg.get('smtp_host'):
return False, "Email no configurado. Ve a Configuración → Email."
host = cfg['smtp_host']
port = int(cfg.get('smtp_port', 587))
user = cfg['smtp_user']
pwd = cfg['smtp_password']
from_addr = cfg.get('from_email') or user
from_name = cfg.get('from_name', '')
use_tls = bool(cfg.get('use_tls', 1))
if not host or not user or not pwd:
return False, "Configuración SMTP incompleta."
# ── Email al cliente (con PDF adjunto) ────────────────────────────────────
msg = MIMEMultipart('mixed')
msg['From'] = f"{from_name} <{from_addr}>" if from_name else from_addr
msg['To'] = f"{to_name} <{to_email}>" if to_name else to_email
msg['Subject'] = subject
body_part = MIMEMultipart('alternative')
body_part.attach(MIMEText(body_html, 'html', 'utf-8'))
msg.attach(body_part)
if pdf_bytes:
pdf_part = MIMEApplication(pdf_bytes, _subtype='pdf')
pdf_part.add_header('Content-Disposition', 'attachment', filename=pdf_filename)
msg.attach(pdf_part)
try:
_smtp_send(host, port, user, pwd, use_tls, from_addr, [to_email], msg.as_string())
except smtplib.SMTPAuthenticationError as e:
return False, f"Error de autenticación: {e}"
except smtplib.SMTPConnectError as e:
return False, f"No se pudo conectar a {host}:{port}: {e}"
except smtplib.SMTPServerDisconnected as e:
return False, f"Servidor cerró la conexión: {e}"
except smtplib.SMTPException as e:
return False, f"Error SMTP: {e}"
except OSError as e:
return False, f"Error de red: {e}"
except Exception as e:
return False, f"{type(e).__name__}: {e}"
# ── Copia al remitente CON PDF adjunto ────────────────────────────────────
try:
msg_copy = MIMEMultipart('mixed')
msg_copy['From'] = f"{from_name} <{from_addr}>" if from_name else from_addr
msg_copy['To'] = from_addr
msg_copy['Subject'] = f"[COPIA] {subject}"
body_copy = MIMEMultipart('alternative')
body_copy.attach(MIMEText(body_html, 'html', 'utf-8'))
msg_copy.attach(body_copy)
if pdf_bytes:
pdf_copy = MIMEApplication(pdf_bytes, _subtype='pdf')
pdf_copy.add_header('Content-Disposition', 'attachment', filename=pdf_filename)
msg_copy.attach(pdf_copy)
_smtp_send(host, port, user, pwd, use_tls, from_addr, [from_addr], msg_copy.as_string())
except Exception:
pass # copia falla silenciosamente
return True, ''
def build_email_body(order, vessel, company_name, app_url=''):
order_num = order.get('order_number', '')
scope = order.get('scope') or order.get('description', '')[:100]
status = order.get('status', '').replace('_', ' ').title()
total = float(order.get('total_labor_cost') or 0) + float(order.get('total_parts_cost') or 0)
vessel_n = vessel.get('name', '')
date_end = order.get('end_date') or order.get('start_date') or ''
pdf_link = f"{app_url}/work-orders/{order.get('id')}/pdf" if app_url else ''
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="font-family:Arial,sans-serif;background:#f4f7fb;margin:0;padding:20px">
<div style="max-width:600px;margin:0 auto;background:white;border-radius:10px;
overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,0.1)">
<div style="background:#0a1628;padding:24px 30px;text-align:center">
<div style="font-size:28px;margin-bottom:6px">⚓</div>
<div style="color:#00b4d8;font-size:20px;font-weight:700;letter-spacing:2px">{company_name.upper()}</div>
<div style="color:#8a9bb0;font-size:12px;letter-spacing:2px">REPORTE DE MANTENIMIENTO</div>
</div>
<div style="padding:28px 30px">
<div style="font-size:13px;color:#8a9bb0;margin-bottom:4px">Orden de Trabajo</div>
<div style="font-size:24px;font-weight:700;color:#0a1628;margin-bottom:16px">{order_num}</div>
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:20px">
<tr style="background:#f0f4f8">
<td style="padding:10px 14px;color:#8a9bb0;width:40%"><b>Embarcación</b></td>
<td style="padding:10px 14px;color:#0a1628">{vessel_n}</td>
</tr>
<tr>
<td style="padding:10px 14px;color:#8a9bb0"><b>Trabajo</b></td>
<td style="padding:10px 14px;color:#0a1628">{scope}</td>
</tr>
<tr style="background:#f0f4f8">
<td style="padding:10px 14px;color:#8a9bb0"><b>Fecha</b></td>
<td style="padding:10px 14px;color:#0a1628">{date_end}</td>
</tr>
<tr>
<td style="padding:10px 14px;color:#8a9bb0"><b>Estado</b></td>
<td style="padding:10px 14px">
<span style="background:#e0f7fa;color:#00838f;padding:2px 10px;
border-radius:12px;font-size:11px;font-weight:700">{status}</span>
</td>
</tr>
<tr style="background:#f0f4f8">
<td style="padding:10px 14px;color:#8a9bb0"><b>Total</b></td>
<td style="padding:10px 14px;color:#0a1628;font-weight:700;font-size:16px">${total:.2f}</td>
</tr>
</table>
<p style="font-size:13px;color:#555;line-height:1.6">
Adjunto encontrará el reporte completo en PDF con todos los detalles del trabajo realizado.
</p>
{'<div style="text-align:center;margin-top:20px"><a href="' + pdf_link + '" style="background:#00b4d8;color:white;padding:12px 28px;border-radius:8px;text-decoration:none;font-weight:700;font-size:13px">Ver Reporte Online</a></div>' if pdf_link else ''}
</div>
<div style="background:#f0f4f8;padding:16px 30px;text-align:center;font-size:11px;color:#8a9bb0">
{company_name} · Reporte generado automáticamente por Marine Maintenance Pro
</div>
</div>
</body></html>"""
def send_wo_notification(company_id, to_emails, subject, body_html, pdf_path=None, pdf_name=None):
cfg = get_email_config(company_id)
if not cfg or not cfg.get('smtp_host'):
return False, "Sin configuración SMTP"
if isinstance(to_emails, str):
to_emails = [e.strip() for e in to_emails.replace(',',';').split(';') if e.strip()]
host = cfg['smtp_host']
port = int(cfg.get('smtp_port', 587))
user = cfg['smtp_user']
pwd = cfg['smtp_password']
from_addr = cfg.get('from_email') or user
from_name = cfg.get('from_name', '')
use_tls = bool(cfg.get('use_tls', 1))
msg = MIMEMultipart()
msg['From'] = f"{from_name} <{from_addr}>" if from_name else from_addr
msg['To'] = ', '.join(to_emails)
msg['Subject'] = subject
msg.attach(MIMEText(body_html, 'html', 'utf-8'))
if pdf_path and os.path.exists(pdf_path):
with open(pdf_path, 'rb') as f:
part = MIMEApplication(f.read(), _subtype='pdf')
part.add_header('Content-Disposition', 'attachment',
filename=pdf_name or os.path.basename(pdf_path))
msg.attach(part)
try:
_smtp_send(host, port, user, pwd, use_tls, from_addr, to_emails + [from_addr], msg.as_string())
return True, "OK"
except smtplib.SMTPAuthenticationError as e:
return False, f"Error de autenticación: {e}"
except smtplib.SMTPConnectError as e:
return False, f"No se pudo conectar a {host}:{port}: {e}"
except smtplib.SMTPServerDisconnected as e:
return False, f"Servidor cerró la conexión: {e}"
except OSError as e:
return False, f"Error de red: {e}"
except Exception as e:
return False, f"{type(e).__name__}: {e}"
+636
View File
@@ -0,0 +1,636 @@
"""
report_generator.py - Marine Maintenance Pro
Genera reporte PDF con traduccion automatica via Claude API
"""
import os, re, json, urllib.request
from io import BytesIO
from reportlab.lib.pagesizes import letter
from reportlab.lib import colors
from reportlab.lib.units import inch
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_RIGHT
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table,
TableStyle, HRFlowable, Image as RLImage, KeepTogether)
from reportlab.platypus import Flowable
from PIL import Image as PILImage
# Colors
NAVY = colors.HexColor('#0a1628')
CYAN = colors.HexColor('#00b4d8')
LIGHT_BG = colors.HexColor('#f0f4f8')
GRAY = colors.HexColor('#8a9bb0')
WHITE = colors.white
WARN = colors.HexColor('#f4a261')
SUCCESS = colors.HexColor('#2ec4b6')
STATUS_COLORS = {'open':CYAN,'in_progress':WARN,'completed':SUCCESS,'cancelled':GRAY}
# UI Labels
T = {
'es': {
'work_order':'ORDEN DE TRABAJO','vessel_data':'Datos de la Embarcacion',
'vessel':'Embarcacion','registration':'Matricula','type':'Tipo','year':'Ano',
'make_model':'Marca / Modelo','engine_hours':'Horas Motor','owner':'Propietario',
'captain':'Capitan','status':'Estado','start_date':'Fecha Inicio',
'end_date':'Fecha Cierre','technician':'Tecnico','system':'Sistema',
'scope':'Scope / Alcance','description':'Descripcion del Trabajo',
'root_cause':'Causa Tecnica de la Falla','repairs':'Reparaciones Realizadas',
'system':'Sistema',
'equipment_worked':'Equipos Trabajados','equip_name':'Equipo','serial':'N Serie',
'hrs':'Hrs','work_done':'Trabajo Realizado','parts':'Repuestos y Materiales',
'part':'Repuesto','desc':'Descripcion','qty':'Cant.','unit_price':'P. Unit.',
'total':'Total','costs':'Resumen de Costos','labor':'Mano de Obra',
'parts_cost':'Repuestos y Materiales','before':'ANTES','after':'DESPUES',
'evidence':'Evidencia Fotografica','signatures':'Firmas y Aprobacion',
'tech_sign':'Tecnico Responsable','client_sign':'Capitan / Propietario',
'generated':'Generado automaticamente por Marine Maintenance Pro',
'status_open':'Abierta','status_in_progress':'En Progreso',
'status_completed':'Completada','status_cancelled':'Cancelada',
},
'en': {
'work_order':'WORK ORDER','vessel_data':'Vessel Information',
'vessel':'Vessel','registration':'Registration','type':'Type','year':'Year',
'make_model':'Make / Model','engine_hours':'Engine Hours','owner':'Owner',
'captain':'Captain','status':'Status','start_date':'Start Date',
'end_date':'End Date','technician':'Technician','system':'System',
'scope':'Scope','description':'Work Description',
'root_cause':'Technical Root Cause','repairs':'Repairs Performed',
'system':'System',
'equipment_worked':'Equipment Worked On','equip_name':'Equipment','serial':'Serial No.',
'hrs':'Hrs','work_done':'Work Performed','parts':'Parts & Materials Used',
'part':'Part / Material','desc':'Description','qty':'Qty','unit_price':'Unit Price',
'total':'Total','costs':'Cost Summary','labor':'Labor',
'parts_cost':'Parts & Materials','before':'BEFORE','after':'AFTER',
'evidence':'Photo Evidence','signatures':'Signatures & Approval',
'tech_sign':'Responsible Technician','client_sign':'Captain / Owner',
'generated':'Automatically generated by Marine Maintenance Pro',
'status_open':'Open','status_in_progress':'In Progress',
'status_completed':'Completed','status_cancelled':'Cancelled',
'lump_sum_label':'Fixed Price (all inclusive)',
}
}
def t(lang, key):
return T.get(lang, T['es']).get(key, key)
def status_label(lang, status):
return t(lang, f'status_{status}')
# ── Auto-translate via Ollama ─────────────────────────────────────────────────
def translate_text(text, model='llama3.1:8b'):
"""Translate a single text string ES->EN via Ollama."""
if not text or not text.strip():
return text
try:
prompt = (
f"Translate this Spanish text to professional English. "
f"Keep marine and electrical technical terms accurate. "
f"Return ONLY the translated text, nothing else:\n\n{text}"
)
payload = json.dumps({
"model": model,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.1}
}).encode('utf-8')
req = urllib.request.Request(
"http://localhost:11434/api/generate",
data=payload,
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=25) as resp:
data = json.loads(resp.read().decode('utf-8'))
result = data.get('response', text).strip()
# Remove common Ollama preambles
for prefix in ['Here is the translated text:', 'Here is the translation:',
'Translation:', 'Translated text:', 'Here\'s the translation:']:
if result.lower().startswith(prefix.lower()):
result = result[len(prefix):].strip()
return result
except Exception as e:
print(f"[translate] Ollama error: {e}")
return text
def translate_content(texts_dict, target_lang='en'):
"""Translate ALL fields in a single Ollama call for speed."""
if target_lang != 'en':
return texts_dict
# Filter non-empty values
to_translate = {k: v for k, v in texts_dict.items() if v and str(v).strip()}
if not to_translate:
return texts_dict
try:
# Build numbered list for single batch translation
keys = list(to_translate.keys())
lines = '\n'.join(f"{i+1}. {to_translate[k]}" for i, k in enumerate(keys))
prompt = (
"Translate the following numbered items from Spanish to English. "
"Keep marine/nautical/technical terminology accurate. "
"Return ONLY the numbered list with translations, same format, nothing else:\n\n"
+ lines
)
payload = json.dumps({
"model": "llama3.1:8b",
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.1}
}).encode('utf-8')
req = urllib.request.Request(
"http://localhost:11434/api/generate",
data=payload, headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read().decode('utf-8'))
response = data.get('response', '').strip()
# Parse numbered response back
result = dict(texts_dict) # start with originals as fallback
for line in response.split('\n'):
line = line.strip()
if not line:
continue
# Match "1. text" or "1) text"
import re
m = re.match(r'^(\d+)[.)]\s*(.+)$', line)
if m:
idx = int(m.group(1)) - 1
if 0 <= idx < len(keys):
result[keys[idx]] = m.group(2).strip()
return result
except Exception as e:
print(f"[translate_batch] Ollama error: {e}")
return texts_dict # fallback: original text
# ── Styles ────────────────────────────────────────────────────────────────────
def make_styles():
s = {}
s['label'] = ParagraphStyle('label', fontName='Helvetica-Bold', fontSize=8,
textColor=GRAY, spaceAfter=2, leading=10)
s['value'] = ParagraphStyle('value', fontName='Helvetica', fontSize=9,
textColor=NAVY, spaceAfter=2, leading=12)
s['body'] = ParagraphStyle('body', fontName='Helvetica', fontSize=9,
textColor=NAVY, spaceAfter=3, leading=14,
leftIndent=0, rightIndent=0, borderPad=0)
s['small'] = ParagraphStyle('small', fontName='Helvetica', fontSize=8,
textColor=GRAY, spaceAfter=2, leading=10)
s['mono'] = ParagraphStyle('mono', fontName='Courier', fontSize=7,
textColor=NAVY, leading=10)
s['sign'] = ParagraphStyle('sign', fontName='Helvetica', fontSize=8,
textColor=GRAY, alignment=TA_CENTER)
s['photo'] = ParagraphStyle('photo', fontName='Helvetica-Bold', fontSize=8,
alignment=TA_CENTER, spaceAfter=2)
return s
class SectionHeader(Flowable):
def __init__(self, text, width=None):
Flowable.__init__(self)
self.text = text
self._fixed_width = width
self.height = 16
def wrap(self, availWidth, availHeight):
self._width = self._fixed_width if self._fixed_width else availWidth
return self._width, self.height
def draw(self):
self.canv.setFillColor(NAVY)
self.canv.rect(0, 0, self._width, self.height, fill=1, stroke=0)
self.canv.setFillColor(CYAN)
self.canv.rect(0, 0, 4, self.height, fill=1, stroke=0)
self.canv.setFillColor(WHITE)
self.canv.setFont('Helvetica-Bold', 9)
self.canv.drawString(12, 4, self.text.upper())
def resize_image(path, max_w, max_h):
try:
with PILImage.open(path) as img:
iw, ih = img.size
ratio = min(max_w/iw, max_h/ih)
return iw*ratio, ih*ratio
except:
return max_w, max_h
def text_block(paragraphs, W):
"""Single flowing text cell — no forced page breaks between paragraphs."""
# Join all paragraphs into one cell so text flows naturally
tbl = Table([[paragraphs]], colWidths=[W])
tbl.setStyle(TableStyle([
('LEFTPADDING', (0,0), (-1,-1), 10),
('RIGHTPADDING', (0,0), (-1,-1), 10),
('TOPPADDING',(0,0),(-1,-1),4),
('BOTTOMPADDING',(0,0),(-1,-1),4),
('VALIGN', (0,0), (-1,-1), 'TOP'),
('BOX', (0,0), (-1,-1), 0.5, colors.HexColor('#d0dae6')),
]))
return tbl
def section_block(header_flowable, content_flowables):
"""Header stays with content start, but content flows freely across pages.
Strategy: use KeepTogether ONLY for header + a tiny anchor spacer (not the
whole content block). This prevents orphan headers without forcing large
tables to jump pages looking for space.
"""
if not content_flowables:
return [header_flowable, Spacer(1,2)]
first = content_flowables[0]
rest = content_flowables[1:]
# If first content is a Table, we can't split KeepTogether with it safely
# Instead just keep header + 2pt spacer together (very small, almost never jumps)
# and let the table itself flow/split naturally
result = [KeepTogether([header_flowable, Spacer(1,2)]), first]
result.extend(rest)
return result
def generate_work_order_pdf(order, vessel, photos, parts_used, wo_equipment,
upload_folder, sig_folder,
company_name="Marine Maintenance Pro",
company_info="", company_logo=None, lang='es'):
# ── Translate content if EN ───────────────────────────────────────────────
if lang == 'en':
content_to_translate = {
'scope': order.get('scope') or '',
'description': order.get('description') or '',
'root_cause': order.get('root_cause') or '',
'repairs_done': order.get('repairs_done') or '',
'system_name': order.get('system_name') or '',
}
# Translate equipment names AND descriptions
for i, e in enumerate(wo_equipment):
content_to_translate[f'equip_name_{i}'] = e.get('equip_name') or ''
content_to_translate[f'equip_desc_{i}'] = e.get('description') or ''
# Translate photo captions
for i, p in enumerate(photos):
if p.get('caption'):
content_to_translate[f'photo_cap_{i}'] = p['caption']
translated = translate_content(content_to_translate, 'en')
order = dict(order)
order['scope'] = translated.get('scope', order.get('scope',''))
order['description'] = translated.get('description', order.get('description',''))
order['root_cause'] = translated.get('root_cause', order.get('root_cause',''))
order['repairs_done'] = translated.get('repairs_done', order.get('repairs_done',''))
order['system_name'] = translated.get('system_name', order.get('system_name',''))
wo_equipment = [dict(e) for e in wo_equipment]
for i, e in enumerate(wo_equipment):
e['equip_name'] = translated.get(f'equip_name_{i}', e.get('equip_name',''))
e['description'] = translated.get(f'equip_desc_{i}', e.get('description',''))
photos = [dict(p) for p in photos]
for i, p in enumerate(photos):
if p.get('caption'):
p['caption'] = translated.get(f'photo_cap_{i}', p['caption'])
# ── Build PDF ─────────────────────────────────────────────────────────────
buf = BytesIO()
W = 7.5 * inch
doc = SimpleDocTemplate(buf, pagesize=letter,
leftMargin=0.75*inch, rightMargin=0.75*inch,
topMargin=0.75*inch, bottomMargin=0.75*inch)
S = make_styles()
story = []
# ── HEADER ───────────────────────────────────────────────────────────────
story.append(HRFlowable(width=W, thickness=3, color=CYAN, spaceAfter=4))
if company_logo and os.path.exists(company_logo):
try:
lw, lh = resize_image(company_logo, 1.6*inch, 0.65*inch)
logo_cell = RLImage(company_logo, width=lw, height=lh)
except:
logo_cell = Paragraph(f'<b>{company_name}</b>',
ParagraphStyle('ch', fontName='Helvetica-Bold', fontSize=16, textColor=NAVY))
else:
logo_cell = Paragraph(f'<b>{company_name}</b>',
ParagraphStyle('ch', fontName='Helvetica-Bold', fontSize=16, textColor=NAVY, leading=20))
hdr = Table([[logo_cell,
Paragraph(f'<b>{t(lang,"work_order")}</b><br/>'
f'<font color="#00b4d8" size="18"><b>{order.get("order_number","")}</b></font>',
ParagraphStyle('on', fontName='Helvetica-Bold', fontSize=9,
textColor=GRAY, alignment=TA_RIGHT, leading=22))
]], colWidths=[W*0.6, W*0.4])
hdr.setStyle(TableStyle([('VALIGN',(0,0),(-1,-1),'TOP')]))
story.append(hdr)
if company_info:
story.append(Paragraph(company_info,
ParagraphStyle('ci', fontName='Helvetica', fontSize=8, textColor=GRAY, spaceAfter=2)))
story.append(HRFlowable(width=W, thickness=1, color=LIGHT_BG, spaceAfter=4))
# ── STATUS ROW ───────────────────────────────────────────────────────────
sk = order.get('status','open')
sc = STATUS_COLORS.get(sk, GRAY)
meta = Table([
[Paragraph(f'<b>{t(lang,"status")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"start_date")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"end_date")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"technician")}</b>',S['label'])],
[Paragraph(f'<font color="{sc.hexval()}"><b>{status_label(lang,sk)}</b></font>',S['value']),
Paragraph(str(order.get('start_date') or ''),S['value']),
Paragraph(str(order.get('end_date') or ''),S['value']),
Paragraph(str(order.get('technician') or ''),S['value'])],
], colWidths=[W/4]*4)
meta.setStyle(TableStyle([
('BACKGROUND',(0,0),(-1,0),LIGHT_BG),
('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')),
('TOPPADDING',(0,0),(-1,-1),3),('BOTTOMPADDING',(0,0),(-1,-1),3),
('LEFTPADDING',(0,0),(-1,-1),10),('RIGHTPADDING',(0,0),(-1,-1),10),
]))
story.append(KeepTogether([meta]))
story.append(Spacer(1,5))
# ── VESSEL ───────────────────────────────────────────────────────────────
vt = Table([
[Paragraph(f'<b>{t(lang,"vessel")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"registration")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"type")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"year")}</b>',S['label'])],
[Paragraph(str(vessel.get('name') or ''),S['value']),
Paragraph(str(vessel.get('registration') or ''),S['value']),
Paragraph(str(vessel.get('vessel_type') or ''),S['value']),
Paragraph(str(vessel.get('year') or ''),S['value'])],
[Paragraph(f'<b>{t(lang,"make_model")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"engine_hours")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"captain")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"owner")}</b>',S['label'])],
[Paragraph(f'{vessel.get("make") or ""} {vessel.get("model") or ""}'.strip() or '',S['value']),
Paragraph(f'{vessel.get("engine_hours") or 0} h',S['value']),
Paragraph(str(vessel.get('captain_name') or ''),S['value']),
Paragraph(str(vessel.get('owner_name') or ''),S['value'])],
], colWidths=[W/4]*4)
vt.setStyle(TableStyle([
('BACKGROUND',(0,0),(-1,0),LIGHT_BG),('BACKGROUND',(0,2),(-1,2),LIGHT_BG),
('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')),
('TOPPADDING',(0,0),(-1,-1),3),('BOTTOMPADDING',(0,0),(-1,-1),3),
('LEFTPADDING',(0,0),(-1,-1),10),('RIGHTPADDING',(0,0),(-1,-1),10),
]))
story += section_block(SectionHeader(f'{t(lang,"vessel_data")}'), [vt])
story.append(Spacer(1,5))
# ── SCOPE ────────────────────────────────────────────────────────────────
sys_name = order.get('system_name') or ''
scope = order.get('scope') or ''
if sys_name or scope:
rows = []
if sys_name:
rows.append([Paragraph(f'<b>{t(lang,"system")}</b>', S['label']),
Paragraph(sys_name, S['value'])])
if scope:
rows.append([Paragraph(f'<b>{t(lang,"scope")}</b>', S['label']),
Paragraph(scope, S['value'])])
st = Table(rows, colWidths=[W/4, W - W/4])
st.setStyle(TableStyle([
('BACKGROUND', (0,0), (0,-1), LIGHT_BG),
('GRID', (0,0), (-1,-1), 0.5, colors.HexColor('#d0dae6')),
('TOPPADDING',(0,0),(-1,-1),3),
('BOTTOMPADDING',(0,0),(-1,-1),3),
('LEFTPADDING', (0,0), (-1,-1), 8),
('RIGHTPADDING', (0,0), (-1,-1), 8),
('VALIGN', (0,0), (-1,-1), 'TOP'),
]))
story += section_block(SectionHeader(f'{t(lang,"scope")}'), [st])
story.append(Spacer(1,4))
# ── DESCRIPTION ──────────────────────────────────────────────────────────
desc = order.get('description') or ''
if desc:
story += section_block(SectionHeader(f'{t(lang,"description")}'),
[text_block([Paragraph(desc.replace('\n', '<br/>'), S['body'])], W)])
story.append(Spacer(1,4))
# ── ROOT CAUSE ───────────────────────────────────────────────────────────
root_cause = order.get('root_cause') or ''
if root_cause:
story += section_block(SectionHeader(f'{t(lang,"root_cause")}'),
[text_block([Paragraph(root_cause.replace('\n', '<br/>'), S['body'])], W)])
story.append(Spacer(1,4))
# ── REPAIRS ──────────────────────────────────────────────────────────────
repairs = order.get('repairs_done') or ''
if repairs:
story += section_block(SectionHeader(f'{t(lang,"repairs")}'),
[text_block([Paragraph(repairs.replace('\n', '<br/>'), S['body'])], W)])
story.append(Spacer(1,4))
# ── EQUIPMENT WORKED ─────────────────────────────────────────────────────
if wo_equipment:
hdr_row = [
Paragraph(f'<b>{t(lang,"equip_name")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"serial")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"hrs")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"work_done")}</b>',S['label']),
]
rows = [hdr_row]
for e in wo_equipment:
name_str = str(e.get('equip_name') or '')
brand = f'{e.get("make") or ""} {e.get("model") or ""}'.strip()
if brand:
name_str += f'<br/><font size="7" color="#8a9bb0">{brand}</font>'
rows.append([
Paragraph(name_str, S['value']),
Paragraph(str(e.get('serial_number') or ''), S['mono']),
Paragraph(f'{e.get("labor_hours") or 0}h', S['value']),
Paragraph(str(e.get('description') or ''), S['value']),
])
eqt = Table(rows, colWidths=[W*0.23, W*0.17, W*0.07, W*0.53])
eqt.setStyle(TableStyle([
('BACKGROUND',(0,0),(-1,0),NAVY),('TEXTCOLOR',(0,0),(-1,0),WHITE),
('ROWBACKGROUNDS',(0,1),(-1,-1),[WHITE,LIGHT_BG]),
('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')),
('TOPPADDING',(0,0),(-1,-1),2),('BOTTOMPADDING',(0,0),(-1,-1),2),
('LEFTPADDING',(0,0),(-1,-1),8),('RIGHTPADDING',(0,0),(-1,-1),8),
('VALIGN',(0,0),(-1,-1),'TOP'),
]))
story += section_block(SectionHeader(f'{t(lang,"equipment_worked")}'), [eqt])
story.append(Spacer(1,5))
# ── PARTS (only for labor_materials) ─────────────────────────────────────
billing = order.get('billing_type', 'labor_materials')
if billing == 'labor_materials':
parts_content = []
if parts_used:
hdr_row = [
Paragraph(f'<b>{t(lang,"part")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"desc")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"qty")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"unit_price")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"total")}</b>',S['label']),
]
rows = [hdr_row]
for p in parts_used:
rows.append([
Paragraph(str(p.get('part_name') or ''),S['value']),
Paragraph(str(p.get('description') or ''),S['small']),
Paragraph(str(p.get('quantity') or 0),S['value']),
Paragraph(f'${float(p.get("unit_cost") or 0):.2f}',S['value']),
Paragraph(f'${float(p.get("total_cost") or 0):.2f}',S['value']),
])
ptt = Table(rows, colWidths=[W*0.28,W*0.30,W*0.10,W*0.15,W*0.17])
ptt.setStyle(TableStyle([
('BACKGROUND',(0,0),(-1,0),NAVY),('TEXTCOLOR',(0,0),(-1,0),WHITE),
('ROWBACKGROUNDS',(0,1),(-1,-1),[WHITE,LIGHT_BG]),
('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')),
('ALIGN',(2,0),(-1,-1),'RIGHT'),
('TOPPADDING',(0,0),(-1,-1),2),('BOTTOMPADDING',(0,0),(-1,-1),2),
('LEFTPADDING',(0,0),(-1,-1),8),('RIGHTPADDING',(0,0),(-1,-1),8),
('VALIGN',(0,0),(-1,-1),'TOP'),
]))
parts_content = [ptt]
else:
parts_content = [Paragraph('', S['small'])]
story += section_block(SectionHeader(f'{t(lang,"parts")}'), parts_content)
story.append(Spacer(1,5))
# ── COSTS ────────────────────────────────────────────────────────────────
lh = float(order.get('labor_hours') or 0)
lr = float(order.get('labor_rate') or 0)
lc = lh * lr
pc = float(order.get('total_parts_cost') or 0) if billing == 'labor_materials' else 0
tot = lc + pc
# Build cost rows based on billing type
if billing == 'lump_sum':
cost_rows = [
[Paragraph(t(lang,'lump_sum_label') if lang=='en' else 'Precio fijo (todo incluido)', S['label']),
Paragraph(f'{lh} h', S['value']),
Paragraph(f'${tot:.2f}', S['value'])],
[Paragraph(f'<b>{t(lang,"total").upper()}</b>',
ParagraphStyle('tb',fontName='Helvetica-Bold',fontSize=10,textColor=WHITE)),
Paragraph('',S['value']),
Paragraph(f'<b>${tot:.2f}</b>',
ParagraphStyle('tv',fontName='Helvetica-Bold',fontSize=11,textColor=CYAN,alignment=TA_RIGHT))],
]
elif billing == 'labor_only':
cost_rows = [
[Paragraph(t(lang,'labor'), S['label']),
Paragraph(f'{lh} h x ${lr:.2f}/h', S['value']),
Paragraph(f'${lc:.2f}', S['value'])],
[Paragraph(f'<b>{t(lang,"total").upper()}</b>',
ParagraphStyle('tb',fontName='Helvetica-Bold',fontSize=10,textColor=WHITE)),
Paragraph('',S['value']),
Paragraph(f'<b>${lc:.2f}</b>',
ParagraphStyle('tv',fontName='Helvetica-Bold',fontSize=11,textColor=CYAN,alignment=TA_RIGHT))],
]
else: # labor_materials
cost_rows = [
[Paragraph(t(lang,'labor'),S['label']),
Paragraph(f'{lh} h x ${lr:.2f}/h',S['value']),
Paragraph(f'${lc:.2f}',S['value'])],
[Paragraph(t(lang,'parts_cost'),S['label']),
Paragraph('',S['value']),
Paragraph(f'${pc:.2f}',S['value'])],
[Paragraph(f'<b>{t(lang,"total").upper()}</b>',
ParagraphStyle('tb',fontName='Helvetica-Bold',fontSize=10,textColor=WHITE)),
Paragraph('',S['value']),
Paragraph(f'<b>${tot:.2f}</b>',
ParagraphStyle('tv',fontName='Helvetica-Bold',fontSize=11,textColor=CYAN,alignment=TA_RIGHT))],
]
ct = Table(cost_rows, colWidths=[W*0.42, W*0.33, W*0.25])
ct.setStyle(TableStyle([
('BACKGROUND',(0,0),(-1,-2),LIGHT_BG),('BACKGROUND',(0,-1),(-1,-1),NAVY),
('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')),
('ALIGN',(2,0),(-1,-1),'RIGHT'),
('TOPPADDING',(0,0),(-1,-1),3),('BOTTOMPADDING',(0,0),(-1,-1),3),
('LEFTPADDING',(0,0),(-1,-1),10),('RIGHTPADDING',(0,0),(-1,-1),10),
]))
story += section_block(SectionHeader(f'{t(lang,"costs")}'), [ct])
story.append(Spacer(1,7))
# ── PHOTOS ───────────────────────────────────────────────────────────────
before_photos = [p for p in photos if p.get('photo_type')=='before']
after_photos = [p for p in photos if p.get('photo_type')=='after']
def photo_section(photo_list, label):
if not photo_list: return []
MAX_W = (W - 0.3*inch) / 2
MAX_H = 2.4 * inch
photo_tables = []
for row in [photo_list[i:i+2] for i in range(0,len(photo_list),2)]:
cells = []
for ph in row:
fp = os.path.join(upload_folder, ph['filename'])
if os.path.exists(fp):
try:
# Compress image to reduce PDF size before inserting
from io import BytesIO as _BytesIO
with PILImage.open(fp) as _img:
_img = _img.convert('RGB')
# Resize to max 1200px on longest side
_img.thumbnail((1200, 1200), PILImage.LANCZOS)
_buf = _BytesIO()
_img.save(_buf, format='JPEG', quality=72, optimize=True)
_buf.seek(0)
pw, ph_h = resize_image(fp, MAX_W, MAX_H)
img = RLImage(_buf, width=pw, height=ph_h)
cap = ph.get('caption') or ''
cell = [img]
if cap: cell += [Spacer(1,3), Paragraph(cap, S['photo'])]
cells.append(cell)
except: cells.append([Paragraph('[Error]', S['small'])])
else: cells.append([Paragraph('[Not found]', S['small'])])
while len(cells) < 2: cells.append([Paragraph('', S['small'])])
pt = Table([cells], colWidths=[W/2-0.1*inch, W/2-0.1*inch])
pt.setStyle(TableStyle([
('VALIGN',(0,0),(-1,-1),'TOP'),('ALIGN',(0,0),(-1,-1),'CENTER'),
('TOPPADDING',(0,0),(-1,-1),2),('BOTTOMPADDING',(0,0),(-1,-1),4),
]))
photo_tables.append(pt)
return section_block(SectionHeader(f'{t(lang,"evidence")}{label}'), photo_tables)
if before_photos or after_photos:
story += photo_section(before_photos, t(lang,'before'))
if after_photos: story.append(Spacer(1,3))
story += photo_section(after_photos, t(lang,'after'))
story.append(Spacer(1,7))
# ── SIGNATURES ───────────────────────────────────────────────────────────
story.append(HRFlowable(width=W, thickness=1, color=LIGHT_BG, spaceAfter=7))
def sig_cell(sig_filename, label, name):
content = []
if sig_filename and sig_folder:
fp = os.path.join(sig_folder, sig_filename)
if os.path.exists(fp):
try:
sw, sh = resize_image(fp, W/2-0.3*inch, 0.8*inch)
content.append(RLImage(fp, width=sw, height=sh))
except: pass
if not content:
content.append(Paragraph('_'*38, S['sign']))
content += [Spacer(1,3), Paragraph(f'{label}<br/>{name}', S['sign'])]
return content
tech_name = order.get('technician') or ''
client_name = vessel.get('captain_name') or vessel.get('owner_name') or ''
sig_tbl = Table([[
sig_cell(order.get('signature_tech'), t(lang,'tech_sign'), tech_name),
sig_cell(order.get('signature_client'), t(lang,'client_sign'), client_name),
]], colWidths=[W/2, W/2])
sig_tbl.setStyle(TableStyle([
('ALIGN',(0,0),(-1,-1),'CENTER'),('VALIGN',(0,0),(-1,-1),'BOTTOM'),
('TOPPADDING',(0,0),(-1,-1),2),('BOTTOMPADDING',(0,0),(-1,-1),2),
]))
story += section_block(SectionHeader(f'{t(lang,"signatures")}'), [sig_tbl])
# ── FOOTER ───────────────────────────────────────────────────────────────
story.append(Spacer(1,8))
story.append(HRFlowable(width=W, thickness=1, color=LIGHT_BG, spaceAfter=2))
story.append(Paragraph(
f'{company_name} | {order.get("order_number","")} | {t(lang,"generated")}',
ParagraphStyle('footer', fontName='Helvetica', fontSize=7,
textColor=GRAY, alignment=TA_CENTER)))
doc.build(story)
buf.seek(0)
return buf
+4
View File
@@ -0,0 +1,4 @@
flask>=3.0.0
reportlab>=4.0.0
Pillow>=10.0.0
werkzeug>=3.0.0
+424
View File
@@ -0,0 +1,424 @@
-- MARINE MAINTENANCE SYSTEM
-- Database Schema v2.0
PRAGMA foreign_keys = ON;
-- COMPAÑÍAS
CREATE TABLE IF NOT EXISTS companies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
address TEXT,
phone TEXT,
email TEXT,
website TEXT,
logo_path TEXT,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- USUARIOS (roles: superadmin | admin | technician)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies(id),
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
full_name TEXT,
role TEXT NOT NULL DEFAULT 'technician',
is_active INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP
);
-- EMBARCACIONES
CREATE TABLE IF NOT EXISTS vessels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies(id),
name TEXT NOT NULL,
registration TEXT,
vessel_type TEXT,
make TEXT,
model TEXT,
year INTEGER,
length_ft REAL,
flag TEXT,
port_of_registry TEXT,
engine_type TEXT,
engine_hours REAL DEFAULT 0,
owner_name TEXT,
owner_phone TEXT,
owner_email TEXT,
captain_name TEXT,
captain_phone TEXT,
captain_email TEXT,
notes TEXT,
photo_path TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- EQUIPOS DE LA EMBARCACIÓN
CREATE TABLE IF NOT EXISTS vessel_equipment (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vessel_id INTEGER NOT NULL REFERENCES vessels(id) ON DELETE CASCADE,
name TEXT NOT NULL,
equipment_type TEXT,
make TEXT,
model TEXT,
serial_number TEXT,
year INTEGER,
position TEXT,
engine_hours REAL DEFAULT 0,
last_service_date DATE,
last_service_hours REAL,
notes TEXT,
is_active INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- DOCUMENTOS ADJUNTOS (por embarcación / equipo)
CREATE TABLE IF NOT EXISTS vessel_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vessel_id INTEGER NOT NULL REFERENCES vessels(id) ON DELETE CASCADE,
equipment_id INTEGER REFERENCES vessel_equipment(id),
doc_type TEXT NOT NULL DEFAULT 'other',
title TEXT NOT NULL,
description TEXT,
filename TEXT NOT NULL,
original_filename TEXT,
file_size INTEGER,
uploaded_by TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- CATEGORÍAS DE REPUESTOS
CREATE TABLE IF NOT EXISTS parts_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT
);
-- INVENTARIO
CREATE TABLE IF NOT EXISTS parts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies(id),
category_id INTEGER REFERENCES parts_categories(id),
part_number TEXT,
name TEXT NOT NULL,
description TEXT,
brand TEXT,
location TEXT,
quantity REAL DEFAULT 0,
unit TEXT DEFAULT 'pcs',
min_quantity REAL DEFAULT 0,
cost_price REAL DEFAULT 0,
sale_price REAL DEFAULT 0,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- PROVEEDORES
CREATE TABLE IF NOT EXISTS suppliers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies(id),
name TEXT NOT NULL,
contact_name TEXT,
phone TEXT,
email TEXT,
address TEXT,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- COMPRAS
CREATE TABLE IF NOT EXISTS purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies(id),
supplier_id INTEGER REFERENCES suppliers(id),
vessel_id INTEGER REFERENCES vessels(id), -- embarcación destino
work_order_id INTEGER REFERENCES work_orders(id), -- WO relacionada (opcional)
purchase_number TEXT, -- número interno
invoice_number TEXT, -- número factura proveedor
purchase_date DATE NOT NULL,
delivery_date DATE, -- fecha entrega esperada
received_date DATE, -- fecha recibido real
status TEXT DEFAULT 'requested', -- requested|approved|ordered|received|paid|cancelled
requested_by TEXT,
approved_by TEXT,
payment_method TEXT, -- cash|transfer|check|credit
payment_reference TEXT,
invoice_photo TEXT, -- archivo adjunto factura
total_amount REAL DEFAULT 0,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS purchase_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
purchase_id INTEGER NOT NULL REFERENCES purchases(id) ON DELETE CASCADE,
part_id INTEGER REFERENCES parts(id),
description TEXT,
part_number TEXT, -- número de parte fabricante
quantity REAL NOT NULL,
unit_cost REAL NOT NULL,
total_cost REAL GENERATED ALWAYS AS (quantity * unit_cost) STORED,
quantity_received REAL DEFAULT 0, -- lo que realmente llegó
notes TEXT
);
-- HISTORIAL DE MOVIMIENTOS DE INVENTARIO
CREATE TABLE IF NOT EXISTS inventory_movements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
part_id INTEGER NOT NULL REFERENCES parts(id),
movement_type TEXT NOT NULL, -- in|out|adjustment
quantity REAL NOT NULL, -- positivo=entrada, negativo=salida
reference_type TEXT, -- purchase|work_order|adjustment
reference_id INTEGER, -- id de la compra o WO
vessel_id INTEGER REFERENCES vessels(id),
notes TEXT,
created_by TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ÓRDENES DE TRABAJO
CREATE TABLE IF NOT EXISTS work_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vessel_id INTEGER NOT NULL REFERENCES vessels(id),
equipment_id INTEGER REFERENCES vessel_equipment(id),
order_number TEXT UNIQUE,
status TEXT DEFAULT 'open',
work_type TEXT,
scope TEXT,
description TEXT,
root_cause TEXT,
repairs_done TEXT,
technician TEXT,
start_date DATE,
end_date DATE,
engine_hours_start REAL,
engine_hours_end REAL,
labor_hours REAL DEFAULT 0,
labor_rate REAL DEFAULT 0,
billing_type TEXT DEFAULT 'labor_materials', -- lump_sum | labor_only | labor_materials
total_parts_cost REAL DEFAULT 0,
total_labor_cost REAL GENERATED ALWAYS AS (labor_hours * labor_rate) STORED,
notes TEXT,
invoice_exported INTEGER DEFAULT 0,
system_id INTEGER REFERENCES systems(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS work_order_parts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
work_order_id INTEGER NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
part_id INTEGER REFERENCES parts(id),
description TEXT,
quantity REAL NOT NULL,
unit_cost REAL NOT NULL,
total_cost REAL GENERATED ALWAYS AS (quantity * unit_cost) STORED
);
CREATE TABLE IF NOT EXISTS work_order_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
work_order_id INTEGER NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
photo_type TEXT NOT NULL,
filename TEXT NOT NULL,
caption TEXT,
taken_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS maintenance_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vessel_id INTEGER NOT NULL REFERENCES vessels(id),
equipment_id INTEGER REFERENCES vessel_equipment(id),
task_name TEXT NOT NULL,
description TEXT,
frequency_days INTEGER,
frequency_hours REAL,
last_done_date DATE,
last_done_hours REAL,
next_due_date DATE,
next_due_hours REAL,
is_active INTEGER DEFAULT 1,
notes TEXT
);
-- SISTEMAS (predefinidos + personalizados)
CREATE TABLE IF NOT EXISTS systems (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies(id),
name TEXT NOT NULL,
description TEXT,
is_default INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- EQUIPOS TRABAJADOS EN UNA ORDEN (múltiples por WO)
CREATE TABLE IF NOT EXISTS work_order_equipment (
id INTEGER PRIMARY KEY AUTOINCREMENT,
work_order_id INTEGER NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
equipment_id INTEGER REFERENCES vessel_equipment(id),
description TEXT,
notes TEXT,
labor_hours REAL DEFAULT 0,
labor_rate REAL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Sistemas predefinidos
INSERT OR IGNORE INTO systems (id, name, is_default) VALUES
(1, 'Propulsión', 1),
(2, 'Generación', 1),
(3, 'Navegación y Comunicaciones', 1),
(4, 'Sistema Eléctrico', 1),
(5, 'Baterías y Carga', 1),
(6, 'Hidráulico', 1),
(7, 'HVAC / Climatización', 1),
(8, 'Plomería / Agua', 1),
(9, 'Seguridad', 1),
(10, 'Casco y Estructura', 1),
(11, 'Otro', 1);
INSERT OR IGNORE INTO parts_categories (name) VALUES
('Motor'),('Hidráulico'),('Eléctrico'),('Electrónico'),
('Casco y cubierta'),('Plomería / Sistema de agua'),
('HVAC'),('Seguridad'),('Varios');
-- CONFIGURACIÓN EMAIL POR COMPAÑÍA
CREATE TABLE IF NOT EXISTS email_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies(id),
smtp_host TEXT,
smtp_port INTEGER DEFAULT 587,
smtp_user TEXT,
smtp_password TEXT,
from_name TEXT,
from_email TEXT,
use_tls INTEGER DEFAULT 1,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- FIRMAS Y PDF GUARDADO en work_orders (columnas extra)
-- Se agregan via ALTER TABLE al iniciar si no existen
-- LOG DE EMAILS ENVIADOS
CREATE TABLE IF NOT EXISTS email_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
work_order_id INTEGER REFERENCES work_orders(id),
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
to_email TEXT NOT NULL,
to_name TEXT,
subject TEXT,
lang TEXT DEFAULT 'es',
pdf_filename TEXT,
status TEXT DEFAULT 'sent',
error_msg TEXT,
sent_by TEXT
);
CREATE TABLE IF NOT EXISTS po_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
po_id INTEGER NOT NULL REFERENCES purchase_orders(id) ON DELETE CASCADE,
rfq_item_id INTEGER REFERENCES rfq_items(id),
description TEXT NOT NULL,
quantity REAL DEFAULT 1,
unit TEXT DEFAULT 'pcs',
unit_cost REAL DEFAULT 0,
total_cost REAL GENERATED ALWAYS AS (quantity * unit_cost) STORED,
part_id INTEGER REFERENCES parts(id),
received_qty REAL DEFAULT 0,
notes TEXT
);
-- MOVIMIENTOS DE INVENTARIO
CREATE TABLE IF NOT EXISTS inventory_movements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
part_id INTEGER REFERENCES parts(id),
po_id INTEGER REFERENCES purchase_orders(id),
work_order_id INTEGER REFERENCES work_orders(id),
vessel_id INTEGER REFERENCES vessels(id),
movement_type TEXT NOT NULL, -- in|out|assign_vessel|assign_wo
quantity REAL NOT NULL,
unit_cost REAL DEFAULT 0,
notes TEXT,
created_by TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ═══════════════════════════════════════════════════════
-- MÓDULO ISM — PROCEDIMIENTOS DE TRABAJO SEGURO
-- ═══════════════════════════════════════════════════════
-- PROCEDIMIENTOS MAESTRO
CREATE TABLE IF NOT EXISTS swp (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies(id),
code TEXT NOT NULL, -- SWP-001, SWP-002...
title TEXT NOT NULL,
category TEXT NOT NULL, -- electrical|mechanical|chemical|confined|height|welding|hull|other
status TEXT DEFAULT 'active', -- active|archived
current_version_id INTEGER, -- FK a swp_versions (se actualiza al aprobar)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- VERSIONES DEL PROCEDIMIENTO
CREATE TABLE IF NOT EXISTS swp_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
swp_id INTEGER NOT NULL REFERENCES swp(id) ON DELETE CASCADE,
version TEXT NOT NULL, -- v1.0, v1.1, v2.0
purpose TEXT,
scope TEXT,
hazards TEXT, -- JSON array
ppe TEXT, -- JSON array
tools TEXT, -- JSON array (herramientas y materiales)
steps TEXT, -- JSON array numerado
emergency TEXT,
ref_standards TEXT, -- JSON array (ISM, SOLAS, OSHA...)
status TEXT DEFAULT 'draft', -- draft|active|superseded|archived
change_reason TEXT,
diff_summary TEXT,
created_by TEXT,
approved_by TEXT,
approved_at TIMESTAMP,
effective_date DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- FIRMA DEL TÉCNICO AL LEER EL PROCEDIMIENTO EN UNA WO
CREATE TABLE IF NOT EXISTS swp_acknowledgements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
swp_id INTEGER NOT NULL REFERENCES swp(id),
swp_version_id INTEGER NOT NULL REFERENCES swp_versions(id),
work_order_id INTEGER NOT NULL REFERENCES work_orders(id),
technician TEXT NOT NULL,
signature TEXT, -- filename de firma
acknowledged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
notes TEXT
);
-- MSDS — FICHAS TÉCNICAS DE SEGURIDAD
CREATE TABLE IF NOT EXISTS msds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies(id),
part_id INTEGER REFERENCES parts(id), -- vinculada a inventario
product_name TEXT NOT NULL,
manufacturer TEXT,
version TEXT DEFAULT 'v1.0',
hazard_class TEXT, -- GHS class
hazards TEXT, -- descripción de riesgos
first_aid TEXT,
ppe_required TEXT,
handling TEXT,
storage TEXT,
spill_procedure TEXT,
disposal TEXT,
ref_standards TEXT,
pdf_filename TEXT, -- PDF oficial del fabricante
created_by TEXT,
updated_by TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
+282
View File
@@ -0,0 +1,282 @@
"""swp_generator.py — PDF SWP con espaciado compacto y traducción Ollama"""
import os, json, urllib.request
from io import BytesIO
from reportlab.lib.pagesizes import letter
from reportlab.lib import colors
from reportlab.lib.units import inch
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table,
TableStyle, HRFlowable, Image as RLImage, KeepTogether)
from reportlab.platypus import Flowable
from PIL import Image as PILImage
NAVY = colors.HexColor('#0a1628')
CYAN = colors.HexColor('#00b4d8')
WARN = colors.HexColor('#f4a261')
LIGHT = colors.HexColor('#eef2f7')
GRAY = colors.HexColor('#8a9bb0')
WHITE = colors.white
RED = colors.HexColor('#e63946')
GREEN = colors.HexColor('#2ec4b6')
ORANGE= colors.HexColor('#e76f51')
BLUE = colors.HexColor('#0077b6')
PURPLE= colors.HexColor('#7b2d8b')
CATEGORY_LABELS_ES = {
'electrical':'Trabajo Eléctrico','mechanical':'Mecánico / Motor',
'chemical':'Químicos / Pinturas','confined':'Espacio Confinado',
'height':'Trabajos en Altura','welding':'Soldadura / Calor',
'hull':'Casco / Buceo','other':'Otro',
}
CATEGORY_LABELS_EN = {
'electrical':'Electrical Work','mechanical':'Mechanical / Engine',
'chemical':'Chemicals / Paints','confined':'Confined Space',
'height':'Working at Height','welding':'Welding / Heat Work',
'hull':'Hull / Diving','other':'Other',
}
LABELS = {
'es': dict(title='PROCEDIMIENTO DE TRABAJO SEGURO',active='ACTIVO',
category='Categoría',approved_by='Aprobado por',status='Estado',
purpose='1. Propósito y Alcance',hazards='2. Riesgos Identificados',
ppe='3. EPP Requerido',tools='4. Herramientas y Materiales',
steps='5. Pasos del Procedimiento',emergency='6. Medidas de Emergencia',
refs='7. Referencias y Normativa',version_ctrl='Control de Versiones',
version='Versión',reason='Motivo del cambio',diff='Diferencias',
created_by='Creado por',effective='Vigente desde',
footer='Documento controlado — no válido si se imprime sin sello de aprobación',
purpose_lbl='Propósito',scope_lbl='Alcance'),
'en': dict(title='SAFE WORK PROCEDURE',active='ACTIVE',
category='Category',approved_by='Approved by',status='Status',
purpose='1. Purpose and Scope',hazards='2. Identified Hazards',
ppe='3. Required PPE',tools='4. Tools and Materials',
steps='5. Procedure Steps',emergency='6. Emergency Measures',
refs='7. References and Standards',version_ctrl='Version Control',
version='Version',reason='Reason for change',diff='Differences',
created_by='Created by',effective='Effective date',
footer='Controlled document — not valid if printed without approval stamp',
purpose_lbl='Purpose',scope_lbl='Scope'),
}
def mk(name, **kw):
d = dict(fontName='Helvetica',fontSize=9,textColor=NAVY,leading=11,spaceAfter=0)
d.update(kw); return ParagraphStyle(name, **d)
class SHdr(Flowable):
def __init__(self, text, color=NAVY):
Flowable.__init__(self); self.text=text; self._c=color; self.height=16
def wrap(self,aw,ah): self._w=aw; return aw,self.height
def draw(self):
self.canv.setFillColor(self._c)
self.canv.rect(0,0,self._w,self.height,fill=1,stroke=0)
self.canv.setFillColor(WHITE)
self.canv.setFont('Helvetica-Bold',8)
self.canv.drawString(8,4,self.text.upper())
def num_table(items, W):
if not items: return Paragraph('', mk('b',textColor=GRAY))
rows=[[Paragraph(f'<b>{i+1}</b>',mk('n',fontSize=8,textColor=CYAN,alignment=TA_CENTER)),
Paragraph(str(item),mk('it',fontSize=8,leading=10))] for i,item in enumerate(items)]
t=Table(rows,colWidths=[0.28*inch,W-0.28*inch])
t.setStyle(TableStyle([
('VALIGN',(0,0),(-1,-1),'TOP'),
('TOPPADDING',(0,0),(-1,-1),2),('BOTTOMPADDING',(0,0),(-1,-1),2),
('LEFTPADDING',(0,0),(-1,-1),3),('RIGHTPADDING',(0,0),(-1,-1),3),
('ROWBACKGROUNDS',(0,0),(-1,-1),[WHITE,LIGHT]),
])); return t
def tag_table(items, W):
if not items: return Paragraph('', mk('b',textColor=GRAY))
cells=[Paragraph(f'{item}',mk('tg',fontSize=8,leading=10)) for item in items]
rows=[cells[i:i+3] for i in range(0,len(cells),3)]
while len(rows[-1])<3: rows[-1].append(Paragraph('',mk('tg')))
t=Table(rows,colWidths=[W/3]*3)
t.setStyle(TableStyle([
('BACKGROUND',(0,0),(-1,-1),LIGHT),
('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')),
('TOPPADDING',(0,0),(-1,-1),3),('BOTTOMPADDING',(0,0),(-1,-1),3),
('LEFTPADDING',(0,0),(-1,-1),6),('VALIGN',(0,0),(-1,-1),'MIDDLE'),
])); return t
def translate_text(text, target='en'):
if not text or not text.strip(): return text
try:
direction = 'Spanish to English' if target=='en' else 'English to Spanish'
prompt = (f"Translate from {direction}. Technical safety/marine terminology. "
f"Return ONLY the translated text:\n\n{text}")
payload = json.dumps({"model":"llama3.1:8b","prompt":prompt,
"stream":False,"options":{"temperature":0.1}}).encode('utf-8')
req = urllib.request.Request("http://localhost:11434/api/generate",
data=payload,headers={"Content-Type":"application/json"},method="POST")
with urllib.request.urlopen(req,timeout=25) as resp:
data = json.loads(resp.read().decode('utf-8'))
result = data.get('response',text).strip()
for p in ['Here is the translated text:','Translation:','Translated text:']:
if result.lower().startswith(p.lower()): result=result[len(p):].strip()
return result
except: return text
def translate_list(lst, target='en'):
return [translate_text(i,target) for i in lst]
def resize_image(path,mw,mh):
try:
with PILImage.open(path) as img: iw,ih=img.size
r=min(mw/iw,mh/ih); return iw*r,ih*r
except: return mw,mh
def section(hdr_flowable, content_flowable):
"""Compact section: header + 2pt gap + content + 5pt after"""
return [KeepTogether([hdr_flowable, Spacer(1,2), content_flowable]), Spacer(1,5)]
def generate_swp_pdf(swp, version, logo_path, json_module, lang='es'):
L = LABELS.get(lang, LABELS['es'])
cat_map = CATEGORY_LABELS_EN if lang=='en' else CATEGORY_LABELS_ES
def parse(field):
try: return json_module.loads(version.get(field) or '[]')
except: return []
hazards = parse('hazards'); ppe=parse('ppe')
tools = parse('tools'); steps=parse('steps')
refs = parse('ref_standards')
purpose = version.get('purpose') or ''
scope = version.get('scope') or ''
emergency = version.get('emergency') or ''
if lang == 'en':
purpose = translate_text(purpose,'en')
scope = translate_text(scope,'en')
emergency = translate_text(emergency,'en')
hazards = translate_list(hazards,'en')
ppe = translate_list(ppe,'en')
tools = translate_list(tools,'en')
steps = translate_list(steps,'en')
buf = BytesIO()
W = 7.5*inch
doc = SimpleDocTemplate(buf, pagesize=letter,
leftMargin=0.65*inch, rightMargin=0.65*inch,
topMargin=0.6*inch, bottomMargin=0.6*inch)
story = []
# ── HEADER ───────────────────────────────────────────────────────────────
story.append(HRFlowable(width=W,thickness=3,color=CYAN,spaceAfter=4))
if logo_path and os.path.exists(logo_path):
try:
lw,lh = resize_image(logo_path,1.4*inch,0.55*inch)
logo_cell = RLImage(logo_path,width=lw,height=lh)
except: logo_cell = Paragraph(f"<b>{swp.get('company_name','')}</b>",mk('co',fontSize=12,textColor=NAVY))
else:
logo_cell = Paragraph(f"<b>{swp.get('company_name','')}</b>",mk('co',fontSize=12,textColor=NAVY))
right_txt = (f'<font size="7" color="#8a9bb0">{L["title"]}</font><br/>'
f'<font size="16" color="#00b4d8"><b>{swp["code"]}</b></font><br/>'
f'<font size="7" color="#8a9bb0">{version["version"]} · {L["effective"]}: {version.get("effective_date") or ""}</font>')
hdr = Table([[logo_cell, Paragraph(right_txt,mk('rh',fontSize=8,alignment=TA_RIGHT,leading=13))]],
colWidths=[W*0.55,W*0.45])
hdr.setStyle(TableStyle([('VALIGN',(0,0),(-1,-1),'TOP')]))
story.append(hdr)
if swp.get('company_info'):
story.append(Paragraph(swp['company_info'],mk('ci',fontSize=7,textColor=GRAY)))
story.append(HRFlowable(width=W,thickness=1,color=LIGHT,spaceAfter=4))
# ── META ─────────────────────────────────────────────────────────────────
meta = Table([
[Paragraph(f'<b>{swp.get("title","")}</b>',mk('t',fontSize=10,fontName='Helvetica-Bold')),
Paragraph(cat_map.get(swp.get('category','other'),''),mk('c',fontSize=8)),
Paragraph(version.get('approved_by') or '',mk('a',fontSize=8)),
Paragraph(L['active'],mk('s',fontSize=8,textColor=GREEN,fontName='Helvetica-Bold'))],
[Paragraph(swp.get('code',''),mk('lc',fontSize=7,textColor=GRAY)),
Paragraph(L['category'],mk('lcat',fontSize=7,textColor=GRAY)),
Paragraph(L['approved_by'],mk('lab',fontSize=7,textColor=GRAY)),
Paragraph(L['status'],mk('lst',fontSize=7,textColor=GRAY))],
],colWidths=[W*0.38,W*0.25,W*0.25,W*0.12])
meta.setStyle(TableStyle([
('BACKGROUND',(0,1),(-1,1),LIGHT),
('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')),
('TOPPADDING',(0,0),(-1,-1),3),('BOTTOMPADDING',(0,0),(-1,-1),3),
('LEFTPADDING',(0,0),(-1,-1),6),('RIGHTPADDING',(0,0),(-1,-1),6),
]))
story.append(meta)
story.append(Spacer(1,4))
# ── PURPOSE & SCOPE ──────────────────────────────────────────────────────
if purpose or scope:
rows=[]
if purpose: rows.append([Paragraph(f'<b>{L["purpose_lbl"]}</b>',mk('pl',fontSize=7,textColor=GRAY)),
Paragraph(purpose,mk('pv',fontSize=8,leading=11))])
if scope: rows.append([Paragraph(f'<b>{L["scope_lbl"]}</b>',mk('sl',fontSize=7,textColor=GRAY)),
Paragraph(scope,mk('sv',fontSize=8,leading=11))])
pt=Table(rows,colWidths=[0.85*inch,W-0.85*inch])
pt.setStyle(TableStyle([
('BACKGROUND',(0,0),(0,-1),LIGHT),
('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')),
('TOPPADDING',(0,0),(-1,-1),3),('BOTTOMPADDING',(0,0),(-1,-1),3),
('LEFTPADDING',(0,0),(-1,-1),6),('VALIGN',(0,0),(-1,-1),'TOP'),
]))
story += section(SHdr(L['purpose']), pt)
# ── HAZARDS ──────────────────────────────────────────────────────────────
if hazards:
story += section(SHdr(L['hazards'],RED), num_table(hazards,W))
# ── PPE ──────────────────────────────────────────────────────────────────
if ppe:
story += section(SHdr(L['ppe'],ORANGE), tag_table(ppe,W))
# ── TOOLS & MATERIALS ────────────────────────────────────────────────────
if tools:
story += section(SHdr(L['tools'],PURPLE), tag_table(tools,W))
# ── STEPS ────────────────────────────────────────────────────────────────
if steps:
story += section(SHdr(L['steps'],BLUE), num_table(steps,W))
# ── EMERGENCY ────────────────────────────────────────────────────────────
if emergency:
et=Table([[Paragraph(emergency,mk('em',fontSize=8,leading=11))]],colWidths=[W])
et.setStyle(TableStyle([
('BACKGROUND',(0,0),(-1,-1),colors.HexColor('#fff8e8')),
('BOX',(0,0),(-1,-1),1,WARN),
('TOPPADDING',(0,0),(-1,-1),4),('BOTTOMPADDING',(0,0),(-1,-1),4),
('LEFTPADDING',(0,0),(-1,-1),8),('RIGHTPADDING',(0,0),(-1,-1),8),
]))
story += section(SHdr(L['emergency'],WARN), et)
# ── REFERENCES ───────────────────────────────────────────────────────────
if refs:
story += section(SHdr(L['refs']), tag_table(refs,W))
# ── VERSION CONTROL ──────────────────────────────────────────────────────
story.append(HRFlowable(width=W,thickness=1,color=LIGHT,spaceAfter=3))
vt=Table([
[Paragraph(f'<b>{L["version"]}</b>',mk('vh',fontSize=7,textColor=GRAY)),
Paragraph(f'<b>{L["reason"]}</b>',mk('vh',fontSize=7,textColor=GRAY)),
Paragraph(f'<b>{L["diff"]}</b>',mk('vh',fontSize=7,textColor=GRAY)),
Paragraph(f'<b>{L["created_by"]}</b>',mk('vh',fontSize=7,textColor=GRAY)),
Paragraph(f'<b>{L["effective"]}</b>',mk('vh',fontSize=7,textColor=GRAY))],
[Paragraph(f"<b>{version['version']}</b>",mk('vv',fontSize=8,textColor=CYAN,fontName='Helvetica-Bold')),
Paragraph(version.get('change_reason') or '',mk('vr',fontSize=8)),
Paragraph(version.get('diff_summary') or '',mk('vd',fontSize=8)),
Paragraph(version.get('created_by') or '',mk('vc',fontSize=8)),
Paragraph(version.get('effective_date') or '',mk('ve',fontSize=8))],
],colWidths=[W*0.1,W*0.22,W*0.28,W*0.22,W*0.18])
vt.setStyle(TableStyle([
('BACKGROUND',(0,0),(-1,0),LIGHT),
('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')),
('TOPPADDING',(0,0),(-1,-1),3),('BOTTOMPADDING',(0,0),(-1,-1),3),
('LEFTPADDING',(0,0),(-1,-1),6),('VALIGN',(0,0),(-1,-1),'TOP'),
]))
story += section(SHdr(L['version_ctrl']), vt)
# ── FOOTER ───────────────────────────────────────────────────────────────
story.append(Spacer(1,6))
story.append(HRFlowable(width=W,thickness=1,color=LIGHT,spaceAfter=2))
story.append(Paragraph(
f"{swp.get('company_name','')} | {swp['code']} {version['version']} | {L['footer']}",
mk('ft',fontSize=7,textColor=GRAY,alignment=TA_CENTER)))
doc.build(story)
buf.seek(0)
return buf
+378
View File
@@ -0,0 +1,378 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Marine Maintenance{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700&family=Barlow:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--navy: #0a1628;
--navy2: #0f2040;
--steel: #1a3a5c;
--ocean: #1e5f8a;
--cyan: #00b4d8;
--foam: #90e0ef;
--white: #f0f4f8;
--gray: #8a9bb0;
--warn: #f4a261;
--danger: #e63946;
--success: #2ec4b6;
--sidebar-w: 230px;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'Barlow', sans-serif;
background: var(--navy);
color: var(--white);
min-height: 100vh;
display: flex;
}
/* SIDEBAR */
.sidebar {
width: var(--sidebar-w);
min-height: 100vh;
background: var(--navy2);
border-right: 1px solid rgba(0,180,216,0.15);
display: flex;
flex-direction: column;
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 100;
}
.sidebar-logo {
padding: 24px 20px 20px;
border-bottom: 1px solid rgba(0,180,216,0.15);
}
.sidebar-logo .logo-icon {
font-size: 28px;
margin-bottom: 6px;
}
.sidebar-logo h1 {
font-family: 'Barlow Condensed', sans-serif;
font-size: 18px;
font-weight: 700;
color: var(--cyan);
letter-spacing: 1px;
line-height: 1.2;
}
.sidebar-logo span {
font-size: 11px;
color: var(--gray);
letter-spacing: 2px;
text-transform: uppercase;
}
.sidebar-nav { flex: 1; padding: 16px 0; }
.nav-section {
padding: 8px 16px 4px;
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--gray);
margin-top: 8px;
}
.nav-link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
color: var(--gray);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
border-left: 3px solid transparent;
}
.nav-link:hover { color: var(--white); background: rgba(0,180,216,0.08); }
.nav-link.active {
color: var(--cyan);
background: rgba(0,180,216,0.12);
border-left-color: var(--cyan);
}
.nav-link .icon { font-size: 18px; width: 22px; text-align: center; }
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid rgba(0,180,216,0.15);
font-size: 11px;
color: var(--gray);
}
/* MAIN */
.main {
margin-left: var(--sidebar-w);
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.topbar {
background: var(--navy2);
border-bottom: 1px solid rgba(0,180,216,0.15);
padding: 14px 28px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky; top: 0; z-index: 50;
}
.topbar h2 {
font-family: 'Barlow Condensed', sans-serif;
font-size: 22px;
font-weight: 600;
letter-spacing: 1px;
}
.topbar-actions { display: flex; gap: 10px; align-items: center; }
.content { padding: 28px; flex: 1; }
/* BUTTONS */
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 18px; border-radius: 6px; border: none;
font-family: 'Barlow', sans-serif; font-size: 13px; font-weight: 600;
cursor: pointer; text-decoration: none; transition: all 0.2s;
letter-spacing: 0.5px;
}
.btn-primary { background: var(--cyan); color: var(--navy); }
.btn-primary:hover { background: var(--foam); }
.btn-secondary { background: rgba(255,255,255,0.08); color: var(--white); border: 1px solid rgba(255,255,255,0.15); }
.btn-secondary:hover { background: rgba(255,255,255,0.14); }
.btn-danger { background: var(--danger); color: white; }
.btn-warning { background: var(--warn); color: var(--navy); }
.btn-success { background: var(--success); color: var(--navy); }
.btn-sm { padding: 5px 12px; font-size: 12px; }
/* CARDS */
.card {
background: var(--navy2);
border: 1px solid rgba(0,180,216,0.12);
border-radius: 10px;
padding: 20px;
}
.card-header {
font-family: 'Barlow Condensed', sans-serif;
font-size: 16px; font-weight: 600; letter-spacing: 1px;
color: var(--cyan); margin-bottom: 16px;
text-transform: uppercase;
}
/* STATS */
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
.stat-card {
background: var(--navy2);
border: 1px solid rgba(0,180,216,0.12);
border-radius: 10px; padding: 20px;
display: flex; flex-direction: column; gap: 4px;
}
.stat-label { font-size: 11px; color: var(--gray); text-transform: uppercase; letter-spacing: 1.5px; }
.stat-value { font-family: 'Barlow Condensed', sans-serif; font-size: 36px; font-weight: 700; color: var(--cyan); }
.stat-sub { font-size: 12px; color: var(--gray); }
/* TABLES */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 10px 14px; font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--gray); border-bottom: 1px solid rgba(0,180,216,0.15); font-weight: 600; }
td { padding: 11px 14px; border-bottom: 1px solid rgba(255,255,255,0.05); }
tr:hover td { background: rgba(0,180,216,0.04); }
tr:last-child td { border-bottom: none; }
/* BADGES */
.badge {
display: inline-block; padding: 2px 10px; border-radius: 20px;
font-size: 11px; font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase;
}
.badge-open { background: rgba(0,180,216,0.2); color: var(--cyan); }
.badge-in_progress { background: rgba(244,162,97,0.2); color: var(--warn); }
.badge-completed { background: rgba(46,196,182,0.2); color: var(--success); }
.badge-cancelled { background: rgba(255,255,255,0.1); color: var(--gray); }
.badge-warn { background: rgba(244,162,97,0.2); color: var(--warn); }
.badge-danger { background: rgba(230,57,70,0.2); color: var(--danger); }
/* FORMS */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-grid.cols-3 { grid-template-columns: 1fr 1fr 1fr; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group.full { grid-column: 1 / -1; }
label { font-size: 12px; color: var(--gray); text-transform: uppercase; letter-spacing: 1px; font-weight: 600; }
input, select, textarea {
background: rgba(255,255,255,0.06); border: 1px solid rgba(0,180,216,0.2);
border-radius: 6px; padding: 9px 12px; color: var(--white);
font-family: 'Barlow', sans-serif; font-size: 14px;
transition: border-color 0.2s;
}
input:focus, select:focus, textarea:focus {
outline: none; border-color: var(--cyan);
background: rgba(0,180,216,0.08);
}
select option { background: var(--navy2); }
textarea { resize: vertical; min-height: 80px; }
/* ALERTS */
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 13px; }
.alert-warn { background: rgba(244,162,97,0.15); border: 1px solid rgba(244,162,97,0.3); color: var(--warn); }
.alert-danger { background: rgba(230,57,70,0.15); border: 1px solid rgba(230,57,70,0.3); color: var(--danger); }
/* GRID LAYOUTS */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; }
/* PHOTO GRID */
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
.photo-card {
background: rgba(255,255,255,0.04); border-radius: 8px; overflow: hidden;
border: 1px solid rgba(0,180,216,0.15); position: relative;
}
.photo-card img { width: 100%; height: 130px; object-fit: cover; display: block; }
.photo-card .photo-label {
padding: 6px 10px; font-size: 11px; font-weight: 600;
letter-spacing: 1px; text-transform: uppercase;
}
.photo-card.before .photo-label { color: var(--warn); }
.photo-card.after .photo-label { color: var(--success); }
.photo-del {
position: absolute; top: 6px; right: 6px;
background: rgba(0,0,0,0.7); border: none; color: white;
border-radius: 50%; width: 24px; height: 24px;
cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center;
}
/* MISC */
.text-cyan { color: var(--cyan); }
.text-warn { color: var(--warn); }
.text-danger { color: var(--danger); }
.text-success { color: var(--success); }
.text-gray { color: var(--gray); }
.mt-4 { margin-top: 16px; }
.mt-6 { margin-top: 24px; }
.mb-4 { margin-bottom: 16px; }
.flex { display: flex; align-items: center; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.justify-between { justify-content: space-between; }
.section-title {
font-family: 'Barlow Condensed', sans-serif;
font-size: 18px; font-weight: 600; letter-spacing: 1px;
color: var(--cyan); margin-bottom: 14px; text-transform: uppercase;
}
/* Mobile */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
transition: transform 0.25s ease;
z-index: 999;
}
.sidebar.open { transform: translateX(0); }
.main { margin-left: 0; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.form-grid { grid-template-columns: 1fr; }
.grid-2, .grid-3 { grid-template-columns: 1fr; }
.hamburger {
display: flex !important;
align-items: center;
justify-content: center;
width: 38px; height: 38px;
background: rgba(0,180,216,0.15);
border: 1px solid rgba(0,180,216,0.3);
border-radius: 8px;
cursor: pointer;
font-size: 18px;
color: var(--cyan);
flex-shrink: 0;
}
.overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
z-index: 998;
}
.overlay.open { display: block; }
}
.hamburger { display: none; }
</style>
{% block head %}{% endblock %}
</head>
<body>
<nav class="sidebar">
<div class="sidebar-logo">
<div class="logo-icon"></div>
<h1>MARINE<br>MAINTENANCE</h1>
<span>v1.0</span>
</div>
<div class="sidebar-nav">
<div class="nav-section">Principal</div>
<a href="{{ url_for('dashboard') }}" class="nav-link {% if request.endpoint=='dashboard' %}active{% endif %}">
<span class="icon">📊</span> Dashboard
</a>
<div class="nav-section">Operaciones</div>
<a href="{{ url_for('vessels') }}" class="nav-link {% if 'vessel' in request.endpoint %}active{% endif %}">
<span class="icon">🚢</span> Embarcaciones
</a>
<a href="{{ url_for('work_orders') }}" class="nav-link {% if 'work_order' in request.endpoint %}active{% endif %}">
<span class="icon">🔧</span> Órdenes de Trabajo
</a>
<a href="{{ url_for('systems') }}" class="nav-link {% if 'system' in request.endpoint %}active{% endif %}">
<span class="icon">🔩</span> Sistemas
</a>
<a href="{{ url_for('ism_index') }}" class="nav-link {% if 'swp' in request.endpoint or 'msds' in request.endpoint or 'ism' in request.endpoint %}active{% endif %}">
<span class="icon">🛡️</span> ISM / SWP
</a>
<div class="nav-section">Inventario</div>
<a href="{{ url_for('inventory') }}" class="nav-link {% if 'inventory' in request.endpoint or 'part_' in request.endpoint %}active{% endif %}">
<span class="icon">📦</span> Repuestos
</a>
<a href="{{ url_for('purchases') }}" class="nav-link {% if 'purchase' in request.endpoint %}active{% endif %}">
<span class="icon">🛒</span> Compras
</a>
<a href="{{ url_for('suppliers') }}" class="nav-link {% if 'supplier' in request.endpoint %}active{% endif %}">
<span class="icon">🏪</span> Proveedores
</a>
{% if current_user and current_user.role in ('superadmin', 'admin') %}
<div class="nav-section">Administración</div>
<a href="{{ url_for('companies') }}" class="nav-link {% if 'compan' in request.endpoint %}active{% endif %}">
<span class="icon">🏢</span> Compañías
</a>
<a href="{{ url_for('users') }}" class="nav-link {% if 'user' in request.endpoint %}active{% endif %}">
<span class="icon">👥</span> Usuarios
</a>
<a href="{{ url_for('email_settings') }}" class="nav-link {% if 'email' in request.endpoint %}active{% endif %}">
<span class="icon">✉️</span> Config. Email
</a>
{% endif %}
</div>
<div class="sidebar-footer">
<div style="font-size:12px;color:var(--white);font-weight:600;margin-bottom:2px">
{{ current_user.full_name if current_user else '—' }}
</div>
<div style="font-size:11px;color:var(--cyan);margin-bottom:10px">
{% if current_user %}
{% if current_user.role == 'superadmin' %}⭐ Super Admin
{% elif current_user.role == 'admin' %}🔑 Admin
{% else %}🔧 Técnico{% endif %}
{% if current_user.company_name %} · {{ current_user.company_name }}{% endif %}
{% endif %}
</div>
<a href="{{ url_for('auth_logout') }}" class="btn btn-secondary btn-sm" style="width:100%;text-align:center">
🚪 Cerrar Sesión
</a>
</div>
</nav>
<div class="overlay" id="sideOverlay" onclick="closeSidebar()"></div>
<div class="main">
<div class="topbar">
<div style="display:flex;align-items:center;gap:10px">
<button class="hamburger" id="hamburger" onclick="toggleSidebar()"></button>
<h2>{% block page_title %}Dashboard{% endblock %}</h2>
</div>
<div class="topbar-actions">{% block topbar_actions %}{% endblock %}</div>
</div>
<div class="content">
{% block content %}{% endblock %}
</div>
</div>
<script>
function toggleSidebar() {
document.querySelector('.sidebar').classList.toggle('open');
document.getElementById('sideOverlay').classList.toggle('open');
}
function closeSidebar() {
document.querySelector('.sidebar').classList.remove('open');
document.getElementById('sideOverlay').classList.remove('open');
}
// Close sidebar when nav link clicked on mobile
document.querySelectorAll('.nav-link').forEach(function(link) {
link.addEventListener('click', function() {
if (window.innerWidth <= 768) closeSidebar();
});
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
{% extends 'base.html' %}
{% block title %}Compañías{% endblock %}
{% block page_title %}Compañías{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('company_new') }}" class="btn btn-primary">+ Nueva Compañía</a>
{% endblock %}
{% block content %}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px">
{% for c in companies %}
<div class="card" style="position:relative">
<div style="display:flex;align-items:center;gap:14px;margin-bottom:14px">
{% if c.logo_path %}
<img src="/static/uploads/logos/{{ c.logo_path }}"
style="width:56px;height:56px;object-fit:contain;border-radius:8px;
background:white;padding:4px;border:1px solid rgba(0,180,216,0.2)">
{% else %}
<div style="width:56px;height:56px;border-radius:8px;background:rgba(0,180,216,0.1);
display:flex;align-items:center;justify-content:center;font-size:24px">🏢</div>
{% endif %}
<div>
<div style="font-size:16px;font-weight:700;color:var(--white)">{{ c.name }}</div>
<div style="font-size:12px;color:var(--cyan)">{{ c.vessel_count }} embarcación{{ 'es' if c.vessel_count != 1 else '' }}</div>
</div>
</div>
<div style="font-size:12px;color:var(--gray);display:flex;flex-direction:column;gap:4px">
{% if c.phone %}<span>📞 {{ c.phone }}</span>{% endif %}
{% if c.email %}<span>✉️ {{ c.email }}</span>{% endif %}
{% if c.address %}<span>📍 {{ c.address }}</span>{% endif %}
{% if c.website %}<span>🌐 {{ c.website }}</span>{% endif %}
</div>
<div class="flex gap-2 mt-4">
<a href="{{ url_for('vessels') }}?company={{ c.id }}" class="btn btn-sm btn-secondary">Ver embarcaciones</a>
<a href="{{ url_for('company_edit', coid=c.id) }}" class="btn btn-sm btn-secondary">✏️ Editar</a>
</div>
</div>
{% else %}
<div style="grid-column:1/-1;text-align:center;padding:50px;color:var(--gray)">
<div style="font-size:40px;margin-bottom:12px">🏢</div>
<div>No hay compañías registradas.</div>
</div>
{% endfor %}
</div>
{% endblock %}
+51
View File
@@ -0,0 +1,51 @@
{% extends 'base.html' %}
{% block title %}{% if company %}Editar{% else %}Nueva{% endif %} Compañía{% endblock %}
{% block page_title %}{% if company %}Editar Compañía{% else %}Nueva Compañía{% endif %}{% endblock %}
{% block topbar_actions %}<a href="{{ url_for('companies') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
{% block content %}
<div class="card" style="max-width:640px">
<form method="POST" enctype="multipart/form-data">
{% if company and company.logo_path %}
<div style="margin-bottom:16px">
<div style="font-size:11px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">Logo actual</div>
<img src="/static/uploads/logos/{{ company.logo_path }}"
style="height:60px;object-fit:contain;background:white;padding:6px;border-radius:8px;border:1px solid rgba(0,180,216,0.2)">
</div>
{% endif %}
<div class="form-grid">
<div class="form-group full">
<label>Nombre de la Compañía *</label>
<input type="text" name="name" value="{{ company.name if company else '' }}" required>
</div>
<div class="form-group full">
<label>Logo (PNG o JPG — aparece en los reportes PDF)</label>
<input type="file" name="logo" accept="image/*" style="padding:8px">
</div>
<div class="form-group">
<label>Teléfono</label>
<input type="tel" name="phone" value="{{ company.phone if company else '' }}">
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" value="{{ company.email if company else '' }}">
</div>
<div class="form-group full">
<label>Dirección</label>
<input type="text" name="address" value="{{ company.address if company else '' }}">
</div>
<div class="form-group full">
<label>Sitio Web</label>
<input type="text" name="website" value="{{ company.website if company else '' }}" placeholder="https://...">
</div>
<div class="form-group full">
<label>Notas</label>
<textarea name="notes">{{ company.notes if company else '' }}</textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
<a href="{{ url_for('companies') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+84
View File
@@ -0,0 +1,84 @@
{% extends 'base.html' %}
{% block title %}Dashboard — Marine Maintenance{% endblock %}
{% block page_title %}Dashboard{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('work_order_new') }}" class="btn btn-primary">+ Nueva Orden</a>
{% endblock %}
{% block content %}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">🚢 Embarcaciones</div>
<div class="stat-value">{{ stats.vessels }}</div>
<div class="stat-sub">registradas</div>
</div>
<div class="stat-card">
<div class="stat-label">🔧 Órdenes Activas</div>
<div class="stat-value" style="color:var(--warn)">{{ stats.open_orders }}</div>
<div class="stat-sub">abiertas / en progreso</div>
</div>
<div class="stat-card">
<div class="stat-label">📦 Stock Bajo</div>
<div class="stat-value" style="color:{% if stats.low_stock > 0 %}var(--danger){% else %}var(--success){% endif %}">{{ stats.low_stock }}</div>
<div class="stat-sub">repuestos bajo mínimo</div>
</div>
<div class="stat-card">
<div class="stat-label">✅ Completadas</div>
<div class="stat-value" style="color:var(--success)">{{ stats.completed_this_month }}</div>
<div class="stat-sub">este mes</div>
</div>
</div>
<div class="grid-2">
<div class="card">
<div class="card-header">🔧 Órdenes Recientes</div>
{% if recent_orders %}
<div class="table-wrap">
<table>
<thead><tr><th>Orden</th><th>Embarcación</th><th>Estado</th><th></th></tr></thead>
<tbody>
{% for o in recent_orders %}
<tr>
<td><span class="text-cyan">{{ o.order_number }}</span></td>
<td>{{ o.vessel_name }}</td>
<td><span class="badge badge-{{ o.status }}">{{ o.status.replace('_',' ') }}</span></td>
<td><a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-secondary">Ver</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray" style="font-size:13px">No hay órdenes aún.</p>
{% endif %}
</div>
<div>
{% if low_stock_parts %}
<div class="card mb-4">
<div class="card-header">⚠️ Stock Bajo</div>
{% for p in low_stock_parts %}
<div class="flex justify-between" style="padding:8px 0; border-bottom:1px solid rgba(255,255,255,0.05); font-size:13px">
<span>{{ p.name }}</span>
<span class="text-danger">{{ p.quantity }} {{ p.unit }} / mín {{ p.min_quantity }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if upcoming %}
<div class="card">
<div class="card-header">📅 Mantenimientos Próximos</div>
{% for s in upcoming %}
<div style="padding:8px 0; border-bottom:1px solid rgba(255,255,255,0.05); font-size:13px">
<div class="flex justify-between">
<span>{{ s.vessel_name }}</span>
<span class="text-warn">{{ s.next_due_date }}</span>
</div>
<div class="text-gray" style="font-size:12px">{{ s.task_name }}</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endblock %}
+117
View File
@@ -0,0 +1,117 @@
{% extends 'base.html' %}
{% block title %}Configuración Email{% endblock %}
{% block page_title %}Configuración de Email{% endblock %}
{% block content %}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;max-width:900px">
<div class="card">
<div class="card-header">⚙️ Servidor SMTP</div>
<p style="font-size:12px;color:var(--gray);margin-bottom:16px">
Configura el servidor de correo para enviar reportes por email.
Funciona con Gmail, Outlook, Yahoo o cualquier servidor SMTP.
</p>
<form method="POST">
<div class="form-group mb-3">
<label>Servidor SMTP</label>
<input type="text" name="smtp_host" value="{{ cfg.smtp_host if cfg else '' }}"
placeholder="smtp.gmail.com">
</div>
<div class="form-grid mb-3">
<div class="form-group">
<label>Puerto</label>
<select name="smtp_port">
<option value="587" {% if not cfg or cfg.smtp_port==587 %}selected{% endif %}>587 (TLS — recomendado)</option>
<option value="465" {% if cfg and cfg.smtp_port==465 %}selected{% endif %}>465 (SSL)</option>
<option value="25" {% if cfg and cfg.smtp_port==25 %}selected{% endif %}>25 (sin cifrado)</option>
</select>
</div>
<div class="form-group">
<label>Usar TLS</label>
<select name="use_tls">
<option value="1" {% if not cfg or cfg.use_tls %}selected{% endif %}></option>
<option value="0" {% if cfg and not cfg.use_tls %}selected{% endif %}>No</option>
</select>
</div>
</div>
<div class="form-group mb-3">
<label>Usuario (email de la cuenta)</label>
<input type="email" name="smtp_user" value="{{ cfg.smtp_user if cfg else '' }}"
placeholder="tu@gmail.com">
</div>
<div class="form-group mb-3">
<label>Contraseña / App Password</label>
<input type="password" name="smtp_password" value="{{ cfg.smtp_password if cfg else '' }}"
placeholder="••••••••">
<div style="font-size:11px;color:var(--gray);margin-top:4px">
Para Gmail usa una <a href="https://myaccount.google.com/apppasswords" target="_blank" style="color:var(--cyan)">App Password</a>, no tu contraseña normal.
</div>
</div>
<div class="form-group mb-3">
<label>Nombre del Remitente</label>
<input type="text" name="from_name" value="{{ cfg.from_name if cfg else '' }}"
placeholder="Marine Maintenance Pro">
</div>
<div class="form-group mb-4">
<label>Email del Remitente</label>
<input type="email" name="from_email" value="{{ cfg.from_email if cfg else '' }}"
placeholder="reportes@tuempresa.com">
</div>
<div class="flex gap-3">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
</div>
</form>
</div>
<div>
<div class="card mb-4">
<div class="card-header">🧪 Probar Configuración</div>
<p style="font-size:12px;color:var(--gray);margin-bottom:12px">
Envía un email de prueba para verificar que la configuración es correcta.
</p>
<div class="form-group mb-3">
<label>Enviar prueba a</label>
<input type="email" id="testEmail" placeholder="tu@email.com">
</div>
<button onclick="testEmail()" class="btn btn-secondary">📧 Enviar Prueba</button>
<div id="testResult" style="margin-top:10px;font-size:13px"></div>
</div>
<div class="card">
<div class="card-header">📋 Guías Rápidas</div>
<div style="font-size:12px;color:var(--gray);line-height:1.8">
<div style="margin-bottom:10px">
<strong style="color:var(--white)">Gmail:</strong><br>
Host: smtp.gmail.com · Puerto: 587<br>
Requiere App Password (2FA activado)
</div>
<div style="margin-bottom:10px">
<strong style="color:var(--white)">Outlook / Hotmail:</strong><br>
Host: smtp-mail.outlook.com · Puerto: 587
</div>
<div>
<strong style="color:var(--white)">Yahoo:</strong><br>
Host: smtp.mail.yahoo.com · Puerto: 587
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function testEmail() {
const to = document.getElementById('testEmail').value.trim();
const res = document.getElementById('testResult');
if (!to) { res.textContent = 'Ingresa un email.'; return; }
res.textContent = 'Enviando...'; res.style.color = 'var(--gray)';
fetch('/settings/email/test', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({to})
}).then(r=>r.json()).then(d=>{
if (d.ok) { res.textContent = '✅ Email enviado. Revisa tu bandeja.'; res.style.color='var(--success)'; }
else { res.textContent = '❌ ' + d.error; res.style.color='var(--danger)'; }
});
}
</script>
{% endblock %}
+89
View File
@@ -0,0 +1,89 @@
{% extends 'base.html' %}
{% block title %}{% if equip %}Editar{% else %}Nuevo{% endif %} Equipo — {{ vessel.name }}{% endblock %}
{% block page_title %}{% if equip %}Editar Equipo{% else %}Nuevo Equipo{% endif %}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('vessel_history', vid=vessel.id) }}" class="btn btn-secondary">← Volver</a>
{% endblock %}
{% block content %}
<div class="card" style="max-width:720px">
<div style="margin-bottom:18px;font-size:13px;color:var(--gray)">
Embarcación: <span class="text-cyan">{{ vessel.name }}</span>
</div>
<form method="POST">
<div class="form-grid cols-3">
<div class="form-group full">
<label>Nombre del Equipo *</label>
<input type="text" name="name" value="{{ equip.name if equip else '' }}" required
placeholder="Ej: Motor Principal Estribor">
</div>
<div class="form-group">
<label>Tipo de Equipo</label>
<select name="equipment_type">
<option value="">-- Seleccionar --</option>
{% for t in [('engine','Motor'),('generator','Generador'),('pump','Bomba'),
('hydraulic','Hidráulico'),('electrical','Eléctrico/Electrónico'),
('hvac','HVAC / A/C'),('navigation','Navegación'),
('safety','Seguridad'),('thruster','Thruster'),
('watermaker','Water Maker'),('other','Otro')] %}
<option value="{{ t[0] }}" {% if equip and equip.equipment_type==t[0] %}selected{% endif %}>{{ t[1] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Posición a Bordo</label>
<select name="position">
<option value="">-- Seleccionar --</option>
{% for p in [('starboard','Estribor'),('port','Babor'),('center','Centro'),
('forward','Proa'),('aft','Popa'),('engine_room','Sala de Máquinas')] %}
<option value="{{ p[0] }}" {% if equip and equip.position==p[0] %}selected{% endif %}>{{ p[1] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Marca</label>
<input type="text" name="make" value="{{ equip.make if equip else '' }}"
placeholder="Ej: MTU, Caterpillar, Volvo">
</div>
<div class="form-group">
<label>Modelo</label>
<input type="text" name="model" value="{{ equip.model if equip else '' }}"
placeholder="Ej: 12V2000 M94">
</div>
<div class="form-group">
<label>Año</label>
<input type="number" name="year" value="{{ equip.year if equip else '' }}" min="1950" max="2030">
</div>
<div class="form-group full" style="background:rgba(0,180,216,0.06);border:1px solid rgba(0,180,216,0.25);border-radius:8px;padding:14px">
<label style="color:var(--cyan)">⚠ Número de Serie (crítico)</label>
<input type="text" name="serial_number"
value="{{ equip.serial_number if equip else '' }}"
placeholder="Ej: MTU-2024-12V-00432-S"
style="border-color:rgba(0,180,216,0.4);margin-top:4px">
</div>
<div class="form-group">
<label>Horas Actuales</label>
<input type="number" step="0.1" name="engine_hours"
value="{{ equip.engine_hours if equip else '0' }}">
</div>
<div class="form-group">
<label>Último Servicio (fecha)</label>
<input type="date" name="last_service_date"
value="{{ equip.last_service_date if equip else '' }}">
</div>
<div class="form-group">
<label>Último Servicio (horas)</label>
<input type="number" step="0.1" name="last_service_hours"
value="{{ equip.last_service_hours if equip else '' }}">
</div>
<div class="form-group full">
<label>Notas</label>
<textarea name="notes">{{ equip.notes if equip else '' }}</textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
<a href="{{ url_for('vessel_history', vid=vessel.id) }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+140
View File
@@ -0,0 +1,140 @@
{% extends 'base.html' %}
{% block title %}Inventario{% endblock %}
{% block page_title %}Inventario{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('part_new') }}" class="btn btn-primary">+ Agregar Item</a>
{% endblock %}
{% block head %}
<style>
.inv-table { display:block; }
.inv-cards { display:none; }
@media (max-width:768px) {
.inv-table { display:none; }
.inv-cards { display:block; }
}
.icard {
background:var(--navy2);border:1px solid rgba(0,180,216,0.12);
border-radius:10px;margin-bottom:10px;overflow:hidden;
}
.icard-header {
padding:10px 14px;background:rgba(0,0,0,0.2);
border-bottom:1px solid rgba(255,255,255,0.05);
display:flex;justify-content:space-between;align-items:center;
}
.icard-name { font-size:14px;font-weight:600;color:var(--white); }
.icard-body { padding:10px 14px;font-size:13px; }
.icard-meta { display:flex;gap:12px;flex-wrap:wrap;color:var(--gray);margin-bottom:8px; }
.icard-stock { font-size:22px;font-weight:700;color:var(--cyan); }
.icard-actions {
display:flex;gap:8px;padding:8px 14px;
border-top:1px solid rgba(255,255,255,0.05);
}
.stock-low { color:var(--danger) !important; }
</style>
{% endblock %}
{% block content %}
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;align-items:center">
<select id="catFilter" onchange="filterInv()"
style="padding:7px 12px;border-radius:6px;background:rgba(255,255,255,0.06);
border:1px solid rgba(0,180,216,0.25);color:var(--white);font-size:13px">
<option value="">Todas las categorías</option>
{% for c in categories %}
<option value="{{ c.name|lower }}">{{ c.name }}</option>
{% endfor %}
</select>
<input type="text" id="invSearch" placeholder="🔍 Buscar repuesto o material..."
oninput="filterInv()"
style="flex:1;min-width:180px;padding:7px 12px;border-radius:6px;
background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.25);
color:var(--white);font-size:13px">
</div>
<!-- TABLA DESKTOP -->
<div class="inv-table card">
<div class="table-wrap">
<table>
<thead><tr><th>Nombre</th><th>Categoría</th><th>N° Parte</th><th>Marca</th><th>Stock</th><th>Mín.</th><th>Precio</th><th></th></tr></thead>
<tbody>
{% for p in parts %}
<tr class="inv-row" data-search="{{ (p.name ~ ' ' ~ (p.part_number or '') ~ ' ' ~ (p.brand or '') ~ ' ' ~ (p.category_name or ''))|lower }}" data-cat="{{ (p.category_name or '')|lower }}">
<td>
<strong>{{ p.name }}</strong>
{% if p.description %}<br><span class="text-gray" style="font-size:11px">{{ p.description[:60] }}</span>{% endif %}
</td>
<td><span class="badge badge-open" style="font-size:11px">{{ p.category_name or '—' }}</span></td>
<td class="text-gray" style="font-family:monospace;font-size:12px">{{ p.part_number or '—' }}</td>
<td class="text-gray">{{ p.brand or '—' }}</td>
<td>
<span style="font-weight:600;{% if p.quantity <= p.min_quantity %}color:var(--danger){% else %}color:var(--success){% endif %}">
{{ p.quantity }} {{ p.unit }}
</span>
</td>
<td class="text-gray">{{ p.min_quantity }}</td>
<td>${{ "%.2f"|format(p.cost_price or 0) }}</td>
<td class="flex gap-2">
<a href="{{ url_for('part_edit', pid=p.id) }}" class="btn btn-sm btn-secondary">✏️</a>
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-gray" style="text-align:center;padding:30px">Sin items en inventario.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TARJETAS MÓVIL -->
<div class="inv-cards">
{% for p in parts %}
<div class="icard inv-row" data-search="{{ (p.name ~ ' ' ~ (p.part_number or '') ~ ' ' ~ (p.brand or '') ~ ' ' ~ (p.category_name or ''))|lower }}" data-cat="{{ (p.category_name or '')|lower }}">
<div class="icard-header">
<div>
<div class="icard-name">{{ p.name }}</div>
{% if p.part_number %}<div style="font-size:11px;color:var(--gray);font-family:monospace">{{ p.part_number }}</div>{% endif %}
</div>
<span class="badge badge-open" style="font-size:11px">{{ p.category_name or '—' }}</span>
</div>
<div class="icard-body">
<div class="icard-meta">
{% if p.brand %}<span>🏷️ {{ p.brand }}</span>{% endif %}
{% if p.location %}<span>📍 {{ p.location }}</span>{% endif %}
<span>💲{{ "%.2f"|format(p.cost_price or 0) }}</span>
</div>
<div style="display:flex;align-items:center;gap:16px">
<div>
<div style="font-size:11px;color:var(--gray);margin-bottom:2px">Stock actual</div>
<div class="icard-stock {% if p.quantity <= p.min_quantity %}stock-low{% endif %}">
{{ p.quantity }} <span style="font-size:14px">{{ p.unit }}</span>
</div>
</div>
<div>
<div style="font-size:11px;color:var(--gray);margin-bottom:2px">Mínimo</div>
<div style="font-size:16px;color:var(--gray)">{{ p.min_quantity }} {{ p.unit }}</div>
</div>
{% if p.quantity <= p.min_quantity %}
<span style="color:var(--danger);font-size:12px;font-weight:600">⚠️ Stock bajo</span>
{% endif %}
</div>
</div>
<div class="icard-actions">
<a href="{{ url_for('part_edit', pid=p.id) }}" class="btn btn-sm btn-secondary" style="flex:1;text-align:center">✏️ Editar</a>
</div>
</div>
{% else %}
<div style="text-align:center;padding:40px;color:var(--gray)">Sin items en inventario.</div>
{% endfor %}
</div>
{% endblock %}
{% block scripts %}
<script>
function filterInv() {
const q = (document.getElementById('invSearch').value || '').toLowerCase().trim();
const cat = (document.getElementById('catFilter').value || '').toLowerCase();
document.querySelectorAll('.inv-row').forEach(r => {
const matchQ = !q || r.dataset.search.includes(q);
const matchC = !cat || r.dataset.cat.includes(cat);
r.style.display = (matchQ && matchC) ? '' : 'none';
});
}
</script>
{% endblock %}
+100
View File
@@ -0,0 +1,100 @@
{% extends 'base.html' %}
{% block title %}ISM — Procedimientos{% endblock %}
{% block page_title %}ISM — Procedimientos de Trabajo Seguro{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('swp_new') }}" class="btn btn-primary">+ Nuevo SWP</a>
<a href="{{ url_for('msds_index') }}" class="btn btn-secondary">📋 MSDS</a>
{% endblock %}
{% block content %}
<!-- Filtros -->
<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap;align-items:center">
{% if is_superadmin and companies %}
<select onchange="location.href='{{ url_for('ism_index') }}?company_id='+this.value"
style="padding:7px 12px;border-radius:6px;background:rgba(255,255,255,0.06);
border:1px solid rgba(0,180,216,0.25);color:var(--white);font-size:13px">
<option value="">🏢 Todas las compañías</option>
{% for c in companies %}
<option value="{{ c.id }}" {% if current_company==c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
{% endif %}
<input type="text" id="swpSearch" placeholder="🔍 Buscar procedimiento..."
oninput="filterSWP(this.value)"
style="flex:1;min-width:200px;padding:7px 12px;border-radius:6px;
background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.25);
color:var(--white);font-size:13px">
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
{% if is_superadmin %}<th>Compañía</th>{% endif %}
<th>Código</th><th>Título</th><th>Categoría</th>
<th>Versión</th><th>Estado versión</th><th>Vigente desde</th><th>Aprobado por</th><th></th>
</tr>
</thead>
<tbody>
{% for s in swps %}
<tr class="swp-row" data-search="{{ (s.code ~ ' ' ~ s.title ~ ' ' ~ s.category ~ ' ' ~ (s.company_name or ''))|lower }}">
{% if is_superadmin %}
<td class="text-gray" style="font-size:12px">{{ s.company_name or '—' }}</td>
{% endif %}
<td class="text-cyan" style="font-family:monospace;font-weight:600">{{ s.code }}</td>
<td><strong>{{ s.title }}</strong></td>
<td>
{% set cat_map = {'electrical':'⚡ Eléctrico','mechanical':'⚙️ Mecánico',
'chemical':'🧪 Químicos','confined':'🔒 Esp. Confinado',
'height':'⚓ Altura','welding':'🔥 Soldadura',
'hull':'🚢 Casco','other':'📋 Otro'} %}
<span style="font-size:12px">{{ cat_map.get(s.category, s.category) }}</span>
</td>
<td>
{% if s.version %}
<span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan)">{{ s.version }}</span>
{% else %}—{% endif %}
</td>
<td>
{% if s.ver_status == 'active' %}
<span class="badge badge-completed">✅ Aprobada</span>
{% elif s.ver_status == 'draft' %}
<span class="badge badge-open">📝 Borrador</span>
{% elif s.ver_status == 'superseded' %}
<span class="badge" style="background:rgba(138,155,176,0.2);color:var(--gray)">Sup.</span>
{% else %}—{% endif %}
</td>
<td class="text-gray" style="font-size:12px">{{ s.effective_date or '—' }}</td>
<td class="text-gray" style="font-size:12px">{{ s.approved_by or '—' }}</td>
<td class="flex gap-2" style="white-space:nowrap">
<a href="{{ url_for('swp_detail', sid=s.id) }}" class="btn btn-sm btn-secondary">Ver</a>
{% if s.ver_status == 'draft' and s.ver_id %}
<a href="{{ url_for('swp_edit_version', sid=s.id, vid=s.ver_id) }}" class="btn btn-sm btn-warning">✏️</a>
{% endif %}
{% if s.ver_status == 'active' %}
<a href="{{ url_for('swp_pdf', sid=s.id) }}?lang=es" target="_blank" class="btn btn-sm btn-primary">📄 ES</a>
<a href="{{ url_for('swp_pdf', sid=s.id) }}?lang=en" target="_blank" class="btn btn-sm btn-secondary">📄 EN</a>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="9" class="text-gray" style="text-align:center;padding:30px">
Sin procedimientos. <a href="{{ url_for('swp_new') }}" style="color:var(--cyan)">Crear el primero</a>
</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function filterSWP(q) {
q = q.toLowerCase();
document.querySelectorAll('.swp-row').forEach(r => {
r.style.display = (!q || r.dataset.search.includes(q)) ? '' : 'none';
});
}
</script>
{% endblock %}
+153
View File
@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Marine Maintenance — Login</title>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700&family=Barlow:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'Barlow', sans-serif;
background: #0a1628;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* fondo decorativo */
body::before {
content: '';
position: absolute;
width: 600px; height: 600px;
background: radial-gradient(circle, rgba(0,180,216,0.08) 0%, transparent 70%);
top: -100px; right: -100px;
border-radius: 50%;
}
body::after {
content: '';
position: absolute;
width: 400px; height: 400px;
background: radial-gradient(circle, rgba(0,180,216,0.05) 0%, transparent 70%);
bottom: -80px; left: -80px;
border-radius: 50%;
}
.login-box {
background: #0f2040;
border: 1px solid rgba(0,180,216,0.2);
border-radius: 16px;
padding: 48px 44px;
width: 400px;
max-width: 95vw;
position: relative;
z-index: 1;
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
}
.logo-area {
text-align: center;
margin-bottom: 36px;
}
.logo-icon { font-size: 42px; margin-bottom: 10px; }
h1 {
font-family: 'Barlow Condensed', sans-serif;
font-size: 22px;
font-weight: 700;
color: #00b4d8;
letter-spacing: 2px;
text-transform: uppercase;
}
.subtitle {
font-size: 12px;
color: #8a9bb0;
letter-spacing: 3px;
text-transform: uppercase;
margin-top: 4px;
}
.form-group { margin-bottom: 18px; }
label {
display: block;
font-size: 11px;
color: #8a9bb0;
text-transform: uppercase;
letter-spacing: 1.5px;
font-weight: 600;
margin-bottom: 7px;
}
input {
width: 100%;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(0,180,216,0.25);
border-radius: 8px;
padding: 11px 14px;
color: #f0f4f8;
font-family: 'Barlow', sans-serif;
font-size: 14px;
transition: border-color 0.2s, background 0.2s;
}
input:focus {
outline: none;
border-color: #00b4d8;
background: rgba(0,180,216,0.08);
}
.btn-login {
width: 100%;
background: #00b4d8;
color: #0a1628;
border: none;
border-radius: 8px;
padding: 12px;
font-family: 'Barlow', sans-serif;
font-size: 14px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
cursor: pointer;
transition: background 0.2s;
margin-top: 8px;
}
.btn-login:hover { background: #90e0ef; }
.error {
background: rgba(230,57,70,0.15);
border: 1px solid rgba(230,57,70,0.3);
color: #e63946;
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
margin-bottom: 18px;
}
.version {
text-align: center;
font-size: 11px;
color: rgba(138,155,176,0.5);
margin-top: 28px;
}
</style>
</head>
<body>
<div class="login-box">
<div class="logo-area">
<div class="logo-icon"></div>
<h1>Marine Maintenance</h1>
<div class="subtitle">Sistema de Gestión</div>
</div>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="POST">
<div class="form-group">
<label>Usuario</label>
<input type="text" name="username" autocomplete="username"
value="{{ username or '' }}" required autofocus>
</div>
<div class="form-group">
<label>Contraseña</label>
<input type="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn-login">Ingresar →</button>
</form>
<div class="version">Marine Maintenance Pro v2.0 · Puerto 5500</div>
</div>
</body>
</html>
+89
View File
@@ -0,0 +1,89 @@
{% extends 'base.html' %}
{% block title %}{% if msds %}Editar{% else %}Nueva{% endif %} MSDS{% endblock %}
{% block page_title %}{% if msds %}Editar MSDS — {{ msds.product_name }}{% else %}Nueva Ficha MSDS{% endif %}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('msds_index') }}" class="btn btn-secondary">← Volver</a>
{% endblock %}
{% block content %}
<div class="card" style="max-width:820px">
<form method="POST" enctype="multipart/form-data">
<div class="form-grid">
<div class="form-group">
<label>Nombre del Producto *</label>
<input type="text" name="product_name" required value="{{ msds.product_name if msds else '' }}">
</div>
<div class="form-group">
<label>Fabricante</label>
<input type="text" name="manufacturer" value="{{ msds.manufacturer if msds else '' }}">
</div>
<div class="form-group">
<label>Clase de Peligro (GHS)</label>
<select name="hazard_class">
<option value="">— Seleccionar —</option>
{% for cls in ['GHS01 Explosivo','GHS02 Inflamable','GHS03 Oxidante','GHS04 Gas comprimido','GHS05 Corrosivo','GHS06 Tóxico','GHS07 Nocivo/Irritante','GHS08 Peligro salud','GHS09 Peligro ambiental'] %}
<option value="{{ cls }}" {% if msds and msds.hazard_class==cls %}selected{% endif %}>{{ cls }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Repuesto Vinculado (opcional)</label>
<select name="part_id">
<option value="">— Sin vincular —</option>
{% for p in parts %}
<option value="{{ p.id }}" {% if msds and msds.part_id==p.id %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Versión</label>
<input type="text" name="version" value="{{ msds.version if msds else 'v1.0' }}">
</div>
<div class="form-group full">
<label>Riesgos y Peligros</label>
<textarea name="hazards" rows="3">{{ msds.hazards if msds else '' }}</textarea>
</div>
<div class="form-group full">
<label>Primeros Auxilios</label>
<textarea name="first_aid" rows="3">{{ msds.first_aid if msds else '' }}</textarea>
</div>
<div class="form-group full">
<label>EPP Requerido</label>
<textarea name="ppe_required" rows="2">{{ msds.ppe_required if msds else '' }}</textarea>
</div>
<div class="form-group">
<label>Manejo</label>
<textarea name="handling" rows="2">{{ msds.handling if msds else '' }}</textarea>
</div>
<div class="form-group">
<label>Almacenamiento</label>
<textarea name="storage" rows="2">{{ msds.storage if msds else '' }}</textarea>
</div>
<div class="form-group">
<label>Procedimiento de derrame</label>
<textarea name="spill_procedure" rows="2">{{ msds.spill_procedure if msds else '' }}</textarea>
</div>
<div class="form-group">
<label>Disposición / Desecho</label>
<textarea name="disposal" rows="2">{{ msds.disposal if msds else '' }}</textarea>
</div>
<div class="form-group full">
<label>Referencias (OSHA, SDS, etc.)</label>
<textarea name="ref_standards" rows="2">{{ msds.ref_standards if msds else '' }}</textarea>
</div>
<div class="form-group full">
<label>PDF Oficial del Fabricante</label>
<input type="file" name="pdf_file" accept=".pdf" style="padding:8px">
{% if msds and msds.pdf_filename %}
<div style="margin-top:6px;font-size:12px;color:var(--gray)">
Actual: <a href="/static/uploads/docs/{{ msds.pdf_filename }}" target="_blank" style="color:var(--cyan)">{{ msds.pdf_filename }}</a>
</div>
{% endif %}
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar MSDS</button>
<a href="{{ url_for('msds_index') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends 'base.html' %}
{% block title %}MSDS — Fichas Técnicas{% endblock %}
{% block page_title %}Fichas de Seguridad (MSDS){% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('msds_new') }}" class="btn btn-primary">+ Nueva MSDS</a>
<a href="{{ url_for('ism_index') }}" class="btn btn-secondary">← ISM</a>
{% endblock %}
{% block content %}
<div class="card">
<div class="table-wrap">
<table>
<thead><tr><th>Producto</th><th>Fabricante</th><th>Clase GHS</th><th>Versión</th><th>Repuesto vinculado</th><th>PDF</th><th></th></tr></thead>
<tbody>
{% for m in msds_list %}
<tr>
<td><strong>{{ m.product_name }}</strong></td>
<td class="text-gray">{{ m.manufacturer or '—' }}</td>
<td>
{% if m.hazard_class %}
<span class="badge" style="background:rgba(230,57,70,0.15);color:var(--danger)">{{ m.hazard_class }}</span>
{% else %}—{% endif %}
</td>
<td><span class="badge badge-open" style="font-size:11px">{{ m.version }}</span></td>
<td class="text-gray" style="font-size:12px">{{ m.part_name or '—' }}</td>
<td>
{% if m.pdf_filename %}
<a href="/static/uploads/docs/{{ m.pdf_filename }}" target="_blank" class="btn btn-sm btn-primary">📄</a>
{% else %}—{% endif %}
</td>
<td>
<a href="{{ url_for('msds_edit', mid=m.id) }}" class="btn btn-sm btn-secondary">✏️</a>
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-gray" style="text-align:center;padding:30px">
Sin fichas MSDS. <a href="{{ url_for('msds_new') }}" style="color:var(--cyan)">Agregar primera</a>
</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends 'base.html' %}
{% block title %}{% if part %}Editar{% else %}Nuevo{% endif %} Repuesto{% endblock %}
{% block page_title %}{% if part %}Editar Repuesto{% else %}Nuevo Repuesto{% endif %}{% endblock %}
{% block topbar_actions %}<a href="{{ url_for('inventory') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
{% block content %}
<div class="card" style="max-width:700px">
<form method="POST">
<div class="form-grid">
<div class="form-group full">
<label>Nombre *</label>
<input type="text" name="name" value="{{ part.name if part else '' }}" required>
</div>
<div class="form-group">
<label>Número de Parte</label>
<input type="text" name="part_number" value="{{ part.part_number if part else '' }}">
</div>
<div class="form-group">
<label>Categoría</label>
<select name="category_id">
<option value="">-- Sin categoría --</option>
{% for c in categories %}
<option value="{{ c.id }}" {% if part and part.category_id==c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Marca</label>
<input type="text" name="brand" value="{{ part.brand if part else '' }}">
</div>
<div class="form-group">
<label>Ubicación en Taller</label>
<input type="text" name="location" value="{{ part.location if part else '' }}" placeholder="Ej: Estante A-3">
</div>
<div class="form-group">
<label>Cantidad</label>
<input type="number" step="0.01" name="quantity" value="{{ part.quantity if part else '0' }}">
</div>
<div class="form-group">
<label>Unidad</label>
<select name="unit">
{% for u in ['pcs','ft','m','gal','L','qt','kg','lb','set','pair','roll','box'] %}
<option value="{{ u }}" {% if part and part.unit==u %}selected{% endif %}>{{ u }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Stock Mínimo (alerta)</label>
<input type="number" step="0.01" name="min_quantity" value="{{ part.min_quantity if part else '0' }}">
</div>
<div class="form-group">
<label>Precio Costo ($)</label>
<input type="number" step="0.01" name="cost_price" value="{{ part.cost_price if part else '0' }}">
</div>
<div class="form-group">
<label>Precio Venta ($)</label>
<input type="number" step="0.01" name="sale_price" value="{{ part.sale_price if part else '0' }}">
</div>
<div class="form-group full">
<label>Descripción</label>
<textarea name="description">{{ part.description if part else '' }}</textarea>
</div>
<div class="form-group full">
<label>Notas</label>
<textarea name="notes">{{ part.notes if part else '' }}</textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
<a href="{{ url_for('inventory') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+92
View File
@@ -0,0 +1,92 @@
{% extends 'base.html' %}
{% block title %}Compra #{{ purchase.id }}{% endblock %}
{% block page_title %}Compra — {{ purchase.purchase_date }}{% endblock %}
{% block topbar_actions %}<a href="{{ url_for('purchases') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
{% block content %}
<div class="grid-2 mb-4">
<div class="card">
<div class="card-header">📋 Detalles</div>
<table style="font-size:13px">
<tr><td style="color:var(--gray);padding:6px 0;width:140px">Proveedor</td><td>{{ purchase.supplier_name or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Factura</td><td>{{ purchase.invoice_number or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Fecha</td><td>{{ purchase.purchase_date }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Total</td><td class="text-cyan">${{ "%.2f"|format(purchase.total_amount or 0) }}</td></tr>
</table>
{% if purchase.notes %}<p style="font-size:13px;color:var(--gray);margin-top:10px">{{ purchase.notes }}</p>{% endif %}
</div>
</div>
<div class="card">
<div class="card-header flex justify-between">
<span>📦 Ítems Comprados</span>
<button onclick="document.getElementById('itemModal').style.display='flex'" class="btn btn-sm btn-primary">+ Agregar Ítem</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Repuesto</th><th>Descripción</th><th>Cantidad</th><th>Costo Unit.</th><th>Total</th></tr></thead>
<tbody>
{% for i in items %}
<tr>
<td>{{ i.part_name or '—' }}</td>
<td class="text-gray">{{ i.description or '' }}</td>
<td>{{ i.quantity }}</td>
<td>${{ "%.2f"|format(i.unit_cost) }}</td>
<td class="text-cyan">${{ "%.2f"|format(i.total_cost) }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-gray" style="text-align:center;padding:20px">Sin ítems.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div id="itemModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:1000;align-items:center;justify-content:center">
<div class="card" style="width:460px;max-width:95vw">
<div class="card-header">📦 Agregar Ítem</div>
<div class="form-group mb-4">
<label>Repuesto del Inventario</label>
<select id="itemPart">
<option value="">-- Sin inventario (manual) --</option>
{% for p in parts %}
<option value="{{ p.id }}">{{ p.name }} {% if p.part_number %}({{ p.part_number }}){% endif %}</option>
{% endfor %}
</select>
</div>
<div class="form-group mb-4">
<label>Descripción (si es manual)</label>
<input type="text" id="itemDesc">
</div>
<div class="form-grid mb-4">
<div class="form-group">
<label>Cantidad</label>
<input type="number" id="itemQty" value="1" step="0.01">
</div>
<div class="form-group">
<label>Costo Unitario ($)</label>
<input type="number" id="itemCost" value="0" step="0.01">
</div>
</div>
<div class="flex gap-3">
<button onclick="addItem()" class="btn btn-primary"> Agregar</button>
<button onclick="document.getElementById('itemModal').style.display='none'" class="btn btn-secondary">Cancelar</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function addItem() {
const data = {
part_id: document.getElementById('itemPart').value || null,
description: document.getElementById('itemDesc').value,
quantity: document.getElementById('itemQty').value,
unit_cost: document.getElementById('itemCost').value
};
fetch(`/purchases/{{ purchase.id }}/add-item`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(data)
}).then(() => location.reload());
}
</script>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends 'base.html' %}
{% block title %}Nueva Compra{% endblock %}
{% block page_title %}Nueva Compra{% endblock %}
{% block topbar_actions %}<a href="{{ url_for('purchases') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
{% block content %}
<div class="card" style="max-width:600px">
<form method="POST">
<div class="form-grid">
<div class="form-group">
<label>Proveedor</label>
<select name="supplier_id">
<option value="">-- Sin proveedor --</option>
{% for s in suppliers %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Fecha *</label>
<input type="date" name="purchase_date" required>
</div>
<div class="form-group">
<label>Número de Factura</label>
<input type="text" name="invoice_number">
</div>
<div class="form-group full">
<label>Notas</label>
<textarea name="notes"></textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Crear y Agregar Ítems</button>
<a href="{{ url_for('purchases') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>document.querySelector('[name=purchase_date]').value = new Date().toISOString().split('T')[0];</script>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% block title %}Compras{% endblock %}
{% block page_title %}Compras de Materiales{% endblock %}
{% block topbar_actions %}<a href="{{ url_for('purchase_new') }}" class="btn btn-primary">+ Nueva Compra</a>{% endblock %}
{% block content %}
<div class="card">
<div class="table-wrap">
<table>
<thead><tr><th>Fecha</th><th>Proveedor</th><th>Factura</th><th>Total</th><th></th></tr></thead>
<tbody>
{% for p in purchases %}
<tr>
<td>{{ p.purchase_date }}</td>
<td>{{ p.supplier_name or 'Sin proveedor' }}</td>
<td class="text-gray">{{ p.invoice_number or '—' }}</td>
<td class="text-cyan">${{ "%.2f"|format(p.total_amount or 0) }}</td>
<td><a href="{{ url_for('purchase_detail', pid=p.id) }}" class="btn btn-sm btn-secondary">Ver</a></td>
</tr>
{% else %}
<tr><td colspan="5" class="text-gray" style="text-align:center;padding:30px">Sin compras registradas.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends 'base.html' %}
{% block title %}Nuevo Proveedor{% endblock %}
{% block page_title %}Nuevo Proveedor{% endblock %}
{% block topbar_actions %}<a href="{{ url_for('suppliers') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
{% block content %}
<div class="card" style="max-width:600px">
<form method="POST">
<div class="form-grid">
<div class="form-group full">
<label>Nombre *</label>
<input type="text" name="name" required value="{{ supplier.name if supplier else '' }}">
</div>
<div class="form-group">
<label>Contacto</label>
<input type="text" name="contact_name" value="{{ supplier.contact_name if supplier else '' }}">
</div>
<div class="form-group">
<label>Teléfono</label>
<input type="tel" name="phone" value="{{ supplier.phone if supplier else '' }}">
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" value="{{ supplier.email if supplier else '' }}">
</div>
<div class="form-group full">
<label>Dirección</label>
<input type="text" name="address" value="{{ supplier.address if supplier else '' }}">
</div>
<div class="form-group full">
<label>Notas</label>
<textarea name="notes">{{ supplier.notes if supplier else '' }}</textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
<a href="{{ url_for('suppliers') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+47
View File
@@ -0,0 +1,47 @@
{% extends 'base.html' %}
{% block title %}Proveedores{% endblock %}
{% block page_title %}Proveedores{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('supplier_new') }}" class="btn btn-primary">+ Nuevo Proveedor</a>
{% endblock %}
{% block content %}
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr><th>Nombre</th><th>Contacto</th><th>Teléfono</th><th>Email</th><th>Dirección</th><th></th></tr>
</thead>
<tbody>
{% for s in suppliers %}
<tr>
<td><strong>{{ s.name }}</strong></td>
<td>{{ s.contact_name or '—' }}</td>
<td>{{ s.phone or '—' }}</td>
<td>{{ s.email or '—' }}</td>
<td class="text-gray" style="font-size:12px">{{ s.address or '—' }}</td>
<td class="flex gap-2">
<a href="{{ url_for('supplier_edit', sid=s.id) }}" class="btn btn-sm btn-secondary">✏️</a>
<button onclick="deleteSupplier({{ s.id }}, this.closest('tr'))" class="btn btn-sm btn-danger">🗑️</button>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-gray" style="text-align:center;padding:30px">Sin proveedores.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function deleteSupplier(id, row) {
if (!confirm('¿Eliminar este proveedor? Solo si no tiene órdenes de compra asociadas.')) return;
fetch('/suppliers/' + id + '/delete', {method: 'DELETE'})
.then(r => r.json())
.then(d => {
if (d.ok) row.remove();
else alert('No se puede eliminar: ' + (d.error || 'tiene registros asociados'));
});
}
</script>
{% endblock %}
+171
View File
@@ -0,0 +1,171 @@
{% extends 'base.html' %}
{% block title %}{{ swp.code }} — {{ swp.title }}{% endblock %}
{% block page_title %}{{ swp.code }} — {{ swp.title }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('swp_pdf', sid=swp.id) }}?lang=es" target="_blank" class="btn btn-primary">📄 PDF ES</a>
<a href="{{ url_for('swp_pdf', sid=swp.id) }}?lang=en" target="_blank" class="btn btn-secondary">📄 PDF EN</a>
{% if current and current.status == 'draft' %}
<a href="{{ url_for('swp_edit_version', sid=swp.id, vid=current.id) }}" class="btn btn-warning">✏️ Editar Borrador</a>
{% else %}
<a href="{{ url_for('swp_edit', sid=swp.id) }}" class="btn btn-secondary">✏️ Editar</a>
<a href="{{ url_for('swp_new_version', sid=swp.id) }}" class="btn btn-secondary">📝 Nueva Versión</a>
{% endif %}
<a href="{{ url_for('ism_index') }}" class="btn btn-secondary">← Volver</a>
{% endblock %}
{% block content %}
{% if current and current.status == 'draft' %}
<div style="background:rgba(244,162,97,0.12);border:1px solid rgba(244,162,97,0.4);border-radius:8px;padding:12px 16px;margin-bottom:16px;display:flex;justify-content:space-between;align-items:center">
<div>
<span style="color:var(--warning);font-weight:600">📝 Versión {{ current.version }} pendiente de aprobación</span>
<span style="color:var(--gray);font-size:12px;margin-left:10px">Creada por {{ current.created_by }} · {{ current.created_at[:10] }}</span>
</div>
<div class="flex gap-2">
<a href="{{ url_for('swp_edit_version', sid=swp.id, vid=current.id) }}" class="btn btn-sm btn-warning">✏️ Editar</a>
<button onclick="approveVersion({{ current.id }})" class="btn btn-sm btn-success">✅ Aprobar ahora</button>
</div>
</div>
{% endif %}
{% if current %}
{% set hazards = json.loads(current.hazards or '[]') %}
{% set ppe = json.loads(current.ppe or '[]') %}
{% set tools = json.loads(current.tools or '[]') %}
{% set steps = json.loads(current.steps or '[]') %}
{% set refs = json.loads(current.ref_standards or '[]') %}
<!-- META -->
<div class="grid-2 mb-4">
<div class="card" style="padding:16px">
<div style="font-size:10px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">Información</div>
<table style="width:100%;font-size:13px">
<tr><td style="color:var(--gray);padding:4px 0;width:40%">Código</td><td style="color:var(--cyan);font-weight:700;font-family:monospace">{{ swp.code }}</td></tr>
<tr><td style="color:var(--gray);padding:4px 0">Versión activa</td><td><span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan)">{{ current.version }}</span></td></tr>
<tr><td style="color:var(--gray);padding:4px 0">Categoría</td><td>{{ categories.get(swp.category, swp.category) }}</td></tr>
<tr><td style="color:var(--gray);padding:4px 0">Vigente desde</td><td>{{ current.effective_date or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:4px 0">Aprobado por</td><td>{{ current.approved_by or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:4px 0">Creado por</td><td>{{ current.created_by or '—' }}</td></tr>
</table>
</div>
<div class="card" style="padding:16px">
<div style="font-size:10px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">Propósito y Alcance</div>
{% if current.purpose %}<p style="font-size:13px;margin-bottom:8px">{{ current.purpose }}</p>{% endif %}
{% if current.scope %}<p style="font-size:13px;color:var(--gray)">{{ current.scope }}</p>{% endif %}
</div>
</div>
<!-- RIESGOS + EPP -->
<div class="grid-2 mb-4">
<div class="card" style="padding:16px;border-left:3px solid var(--danger)">
<div style="font-size:10px;color:var(--danger);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">⚠️ Riesgos Identificados</div>
{% for h in hazards %}
<div style="display:flex;gap:8px;padding:5px 0;border-bottom:1px solid rgba(255,255,255,0.05);font-size:13px">
<span style="color:var(--danger)"></span> {{ h }}
</div>
{% else %}<p class="text-gray" style="font-size:13px"></p>{% endfor %}
</div>
<div class="card" style="padding:16px;border-left:3px solid var(--warning)">
<div style="font-size:10px;color:var(--warning);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">🦺 EPP Requerido</div>
{% for p in ppe %}
<div style="display:flex;gap:8px;padding:5px 0;border-bottom:1px solid rgba(255,255,255,0.05);font-size:13px">
<span style="color:var(--warning)"></span> {{ p }}
</div>
{% else %}<p class="text-gray" style="font-size:13px"></p>{% endfor %}
</div>
</div>
<!-- HERRAMIENTAS -->
{% if tools %}
<div class="card mb-4" style="padding:16px;border-left:3px solid #7b2d8b">
<div style="font-size:10px;color:#7b2d8b;text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">🔧 Herramientas y Materiales</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
{% for t in tools %}
<span style="background:rgba(123,45,139,0.1);border:1px solid rgba(123,45,139,0.3);color:#b57bee;padding:4px 10px;border-radius:20px;font-size:12px">{{ t }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- PASOS -->
<div class="card mb-4" style="padding:16px;border-left:3px solid var(--cyan)">
<div style="font-size:10px;color:var(--cyan);text-transform:uppercase;letter-spacing:1px;margin-bottom:12px">📋 Pasos del Procedimiento</div>
{% for step in steps %}
<div style="display:flex;gap:12px;padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.05);font-size:13px">
<span style="color:var(--cyan);font-weight:700;min-width:24px">{{ loop.index }}.</span>
<span>{{ step }}</span>
</div>
{% else %}<p class="text-gray" style="font-size:13px">Sin pasos definidos.</p>{% endfor %}
</div>
<!-- EMERGENCIA -->
{% if current.emergency %}
<div class="card mb-4" style="padding:16px;border:1px solid var(--warning);background:rgba(244,162,97,0.05)">
<div style="font-size:10px;color:var(--warning);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">🚨 Medidas de Emergencia</div>
<p style="font-size:13px">{{ current.emergency }}</p>
</div>
{% endif %}
<!-- REFERENCIAS -->
{% if refs %}
<div class="card mb-4" style="padding:16px">
<div style="font-size:10px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">📚 Referencias y Normativa</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
{% for r in refs %}
<span style="background:rgba(0,180,216,0.1);border:1px solid rgba(0,180,216,0.2);color:var(--cyan);padding:4px 10px;border-radius:20px;font-size:12px">{{ r }}</span>
{% endfor %}
</div>
</div>
{% endif %}
{% else %}
<div class="card" style="padding:40px;text-align:center;color:var(--gray)">
Sin versión activa. <a href="{{ url_for('swp_new_version', sid=swp.id) }}" style="color:var(--cyan)">Crear primera versión</a>
</div>
{% endif %}
<!-- HISTORIAL DE VERSIONES -->
<div class="card mb-4">
<div class="card-header">📚 Historial de Versiones</div>
<div class="table-wrap">
<table>
<thead><tr><th>Versión</th><th>Estado</th><th>Motivo</th><th>Diferencias</th><th>Creado por</th><th>Aprobado por</th><th>Vigente desde</th><th></th></tr></thead>
<tbody>
{% for v in versions %}
<tr>
<td><span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan);font-family:monospace">{{ v.version }}</span></td>
<td>
{% if v.status == 'active' %}<span class="badge badge-completed">Activa</span>
{% elif v.status == 'draft' %}<span class="badge badge-open">Borrador</span>
{% elif v.status == 'superseded' %}<span class="badge" style="background:rgba(138,155,176,0.2);color:var(--gray)">Supersedida</span>
{% else %}<span class="badge badge-cancelled">Archivada</span>{% endif %}
</td>
<td style="font-size:12px">{{ v.change_reason or '—' }}</td>
<td style="font-size:12px;color:var(--gray)">{{ v.diff_summary or '—' }}</td>
<td style="font-size:12px">{{ v.created_by or '—' }}</td>
<td style="font-size:12px">{{ v.approved_by or '—' }}</td>
<td style="font-size:12px">{{ v.effective_date or '—' }}</td>
<td>
{% if v.status == 'draft' %}
<div class="flex gap-2">
<a href="{{ url_for('swp_edit_version', sid=swp.id, vid=v.id) }}" class="btn btn-sm btn-warning">✏️</a>
<button onclick="approveVersion({{ v.id }})" class="btn btn-sm btn-success">✅ Aprobar</button>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function approveVersion(vid) {
if (!confirm('¿Aprobar esta versión? La versión actual quedará como supersedida.')) return;
fetch('/ism/{{ swp.id }}/versions/' + vid + '/approve', {method:'POST'})
.then(r => r.json())
.then(d => { if (d.ok) location.reload(); else alert('Error: ' + d.error); });
}
</script>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% extends 'base.html' %}
{% block title %}Editar — {{ swp.code }}{% endblock %}
{% block page_title %}Editar datos — {{ swp.code }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">← Cancelar</a>
{% endblock %}
{% block content %}
<div class="card mb-3" style="padding:12px 16px;background:rgba(0,180,216,0.06);border:1px solid rgba(0,180,216,0.2)">
<div style="font-size:13px;color:var(--gray)">
️ Edita código, título, categoría y compañía. Para cambiar el contenido (pasos, riesgos, EPP) usa
<a href="{{ url_for('swp_detail', sid=swp.id) }}" style="color:var(--warning)">✏️ Editar Borrador</a> o
<a href="{{ url_for('swp_new_version', sid=swp.id) }}" style="color:var(--cyan)">📝 Nueva Versión</a> desde el detalle del procedimiento.
</div>
</div>
<div class="card" style="max-width:640px">
<form method="POST">
<div class="form-grid">
<div class="form-group full">
<label>Compañía *</label>
<select name="company_id" required>
<option value="">— Seleccionar —</option>
{% for c in companies %}
<option value="{{ c.id }}" {% if swp.company_id==c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Código <span style="color:var(--gray);font-size:11px">(solo corrección de errores tipográficos)</span></label>
<input type="text" name="code" value="{{ swp.code }}" required>
</div>
<div class="form-group">
<label>Categoría *</label>
<select name="category" required>
{% for val, label in categories %}
<option value="{{ val }}" {% if swp.category==val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group full">
<label>Título *</label>
<input type="text" name="title" value="{{ swp.title }}" required>
</div>
</div>
<div class="flex gap-3 mt-4">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+90
View File
@@ -0,0 +1,90 @@
{% extends 'base.html' %}
{% block title %}{% if swp %}Editar{% else %}Nuevo{% endif %} Procedimiento{% endblock %}
{% block page_title %}{% if swp %}Editar — {{ swp.code }}{% else %}Nuevo Procedimiento SWP{% endif %}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('ism_index') }}" class="btn btn-secondary">← Volver</a>
{% endblock %}
{% block content %}
<div class="card" style="max-width:860px">
<form method="POST">
{% if is_superadmin and companies %}
<div class="form-group mb-4" style="background:rgba(0,180,216,0.06);border:1px solid rgba(0,180,216,0.2);border-radius:8px;padding:12px 16px">
<label style="color:var(--cyan)">🏢 Compañía *</label>
<select name="company_id" required>
<option value="">— Seleccionar compañía —</option>
{% for c in companies %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="form-grid">
<div class="form-group">
<label>Código <span style="color:var(--gray);font-size:11px">(asignado automáticamente)</span></label>
<input type="text" name="code" value="{{ swp.code if swp else code }}"
readonly style="opacity:0.6;cursor:not-allowed;background:rgba(255,255,255,0.03)">
</div>
<div class="form-group">
<label>Categoría *</label>
<select name="category" required>
{% for val, label in categories %}
<option value="{{ val }}" {% if swp and swp.category==val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group full">
<label>Título del Procedimiento *</label>
<input type="text" name="title" required
value="{{ swp.title if swp else '' }}"
placeholder="Ej: Procedimiento para trabajo eléctrico a bordo">
</div>
<div class="form-group full">
<label>Propósito</label>
<textarea name="purpose" rows="2" placeholder="¿Para qué sirve este procedimiento?">{{ swp.purpose if swp else '' }}</textarea>
</div>
<div class="form-group full">
<label>Alcance</label>
<textarea name="scope" rows="2" placeholder="¿A qué trabajos y personal aplica?">{{ swp.scope if swp else '' }}</textarea>
</div>
<div class="form-group full">
<label>Riesgos Identificados <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="hazards" rows="4" placeholder="Electrocución&#10;Quemaduras&#10;Caída al agua&#10;Cortocircuito">{{ swp.hazards if swp else '' }}</textarea>
</div>
<div class="form-group full">
<label>EPP Requerido <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="ppe" rows="3" placeholder="Guantes dieléctricos&#10;Gafas de seguridad&#10;Zapatos con aislamiento">{{ swp.ppe if swp else '' }}</textarea>
</div>
<div class="form-group full">
<label>Herramientas y Materiales <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="tools" rows="3" placeholder="Multímetro&#10;Destornillador aislado&#10;Cinta aislante"></textarea>
</div>
<div class="form-group full">
<label>Pasos del Procedimiento <span style="color:var(--gray);font-size:11px">(uno por línea, en orden)</span></label>
<textarea name="steps" rows="8" placeholder="Verificar que el sistema esté desenergizado&#10;Bloquear y etiquetar el breaker (LOTO)&#10;Verificar ausencia de voltaje con multímetro&#10;Realizar el trabajo&#10;Verificar conexiones antes de energizar&#10;Energizar y probar funcionamiento">{{ swp.steps if swp else '' }}</textarea>
</div>
<div class="form-group full">
<label>Medidas de Emergencia</label>
<textarea name="emergency" rows="3" placeholder="En caso de accidente eléctrico: cortar energía inmediatamente, llamar al 911, aplicar RCP si es necesario. No tocar a la víctima sin cortar la corriente primero.">{{ swp.emergency if swp else '' }}</textarea>
</div>
<div class="form-group full">
<label>Referencias y Normativa <span style="color:var(--gray);font-size:11px">(una por línea)</span></label>
<textarea name="ref_standards" rows="3" placeholder="OSHA 1910.147 — Control of Hazardous Energy&#10;NFPA 70E — Electrical Safety&#10;Código ISM — Sección 7">{{ swp.ref_standards if swp else '' }}</textarea>
</div>
<div class="form-group">
<label>Fecha de Vigencia</label>
<input type="date" name="effective_date">
</div>
{% if not swp %}
<div class="form-group full" style="background:rgba(244,162,97,0.08);border:1px solid rgba(244,162,97,0.3);border-radius:8px;padding:12px">
<div style="font-size:12px;color:var(--warning);margin-bottom:4px">📝 Se creará como <strong>Borrador (v1.0)</strong></div>
<div style="font-size:12px;color:var(--gray)">Puedes editarlo libremente mientras sea borrador. Una vez aprobado por el admin, quedará activo y solo se podrá modificar creando una nueva versión.</div>
</div>
{% endif %}
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar Procedimiento</button>
<a href="{{ url_for('ism_index') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
{% extends 'base.html' %}
{% block title %}Editar {{ swp.code }} {{ version.version }}{% endblock %}
{% block page_title %}✏️ Editar {{ swp.code }} — {{ version.version }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">← Cancelar</a>
{% endblock %}
{% block content %}
{% if version.status == 'active' %}
<div class="card mb-4" style="padding:12px 16px;background:rgba(244,162,97,0.08);border:1px solid rgba(244,162,97,0.3)">
<div style="font-size:13px;color:var(--warning)">
⚠️ Esta versión ya está <strong>aprobada y activa</strong>. Los cambios se guardarán pero considera si es mejor crear una Nueva Versión para mantener el historial.
</div>
</div>
{% endif %}
<div class="card" style="max-width:860px">
<form method="POST">
<div class="form-grid">
<div class="form-group">
<label>Motivo del cambio</label>
<input type="text" name="change_reason" value="{{ version.change_reason or '' }}"
placeholder="Ej: Corrección inicial">
</div>
<div class="form-group">
<label>Fecha de Vigencia</label>
<input type="date" name="effective_date" value="{{ version.effective_date or '' }}">
</div>
<div class="form-group">
<label>Resumen de diferencias</label>
<input type="text" name="diff_summary" value="{{ version.diff_summary or '' }}">
</div>
</div>
<hr style="border-color:rgba(255,255,255,0.08);margin:16px 0">
<div class="form-grid">
<div class="form-group full">
<label>Propósito</label>
<textarea name="purpose" rows="2">{{ version.purpose or '' }}</textarea>
</div>
<div class="form-group full">
<label>Alcance</label>
<textarea name="scope" rows="2">{{ version.scope or '' }}</textarea>
</div>
<div class="form-group full">
<label>Riesgos Identificados <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="hazards" rows="4">{% set h=json.loads(version.hazards or '[]') %}{% for i in h %}{{ i }}
{% endfor %}</textarea>
</div>
<div class="form-group full">
<label>EPP Requerido <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="ppe" rows="3">{% set p=json.loads(version.ppe or '[]') %}{% for i in p %}{{ i }}
{% endfor %}</textarea>
</div>
<div class="form-group full">
<label>Herramientas y Materiales <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="tools" rows="3" placeholder="Multímetro&#10;Destornillador aislado&#10;Cinta aislante">{% set to=json.loads(version.tools or '[]') %}{% for i in to %}{{ i }}
{% endfor %}</textarea>
</div>
<div class="form-group full">
<label>Pasos del Procedimiento <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="steps" rows="8">{% set s=json.loads(version.steps or '[]') %}{% for i in s %}{{ i }}
{% endfor %}</textarea>
</div>
<div class="form-group full">
<label>Medidas de Emergencia</label>
<textarea name="emergency" rows="3">{{ version.emergency or '' }}</textarea>
</div>
<div class="form-group full">
<label>Referencias y Normativa <span style="color:var(--gray);font-size:11px">(una por línea)</span></label>
<textarea name="ref_standards" rows="3">{% set r=json.loads(version.ref_standards or '[]') %}{% for i in r %}{{ i }}
{% endfor %}</textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar Cambios</button>
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+82
View File
@@ -0,0 +1,82 @@
{% extends 'base.html' %}
{% block title %}Nueva Versión — {{ swp.code }}{% endblock %}
{% block page_title %}Nueva Versión {{ new_version }} — {{ swp.code }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">← Cancelar</a>
{% endblock %}
{% block content %}
<div class="card mb-4" style="padding:12px 16px;background:rgba(244,162,97,0.08);border:1px solid rgba(244,162,97,0.3)">
<div style="font-size:13px;color:var(--warning)">
⚠️ Estás creando la versión <strong>{{ new_version }}</strong> de <strong>{{ swp.code }}</strong>.
La versión actual quedará como "Supersedida" cuando apruebes esta nueva versión.
</div>
</div>
<div class="card" style="max-width:860px">
<form method="POST">
<div class="form-grid">
<div class="form-group">
<label>Motivo del Cambio *</label>
<input type="text" name="change_reason" required
placeholder="Ej: Actualización requisitos OSHA 2026">
</div>
<div class="form-group">
<label>Resumen de Diferencias</label>
<input type="text" name="diff_summary"
placeholder="Ej: Se agregó paso de verificación LOTO">
</div>
<div class="form-group">
<label>Fecha de Vigencia</label>
<input type="date" name="effective_date">
</div>
</div>
<hr style="border-color:rgba(255,255,255,0.08);margin:16px 0">
<div class="form-grid">
<div class="form-group full">
<label>Propósito</label>
<textarea name="purpose" rows="2">{{ current.purpose if current else '' }}</textarea>
</div>
<div class="form-group full">
<label>Alcance</label>
<textarea name="scope" rows="2">{{ current.scope if current else '' }}</textarea>
</div>
<div class="form-group full">
<label>Riesgos Identificados <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="hazards" rows="4">{% if current %}{% set h=json.loads(current.hazards or '[]') %}{% for i in h %}{{ i }}
{% endfor %}{% endif %}</textarea>
</div>
<div class="form-group full">
<label>EPP Requerido <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="ppe" rows="3">{% if current %}{% set p=json.loads(current.ppe or '[]') %}{% for i in p %}{{ i }}
{% endfor %}{% endif %}</textarea>
</div>
<div class="form-group full">
<label>Herramientas y Materiales <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="tools" rows="3" placeholder="Multímetro&#10;Destornillador aislado&#10;Cinta aislante">{% if current %}{% set to=json.loads(current.tools or '[]') %}{% for i in to %}{{ i }}
{% endfor %}{% endif %}</textarea>
</div>
<div class="form-group full">
<label>Pasos del Procedimiento <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
<textarea name="steps" rows="8">{% if current %}{% set s=json.loads(current.steps or '[]') %}{% for i in s %}{{ i }}
{% endfor %}{% endif %}</textarea>
</div>
<div class="form-group full">
<label>Medidas de Emergencia</label>
<textarea name="emergency" rows="3">{{ current.emergency if current else '' }}</textarea>
</div>
<div class="form-group full">
<label>Referencias y Normativa <span style="color:var(--gray);font-size:11px">(una por línea)</span></label>
<textarea name="ref_standards" rows="3">{% if current %}{% set r=json.loads(current.ref_standards or '[]') %}{% for i in r %}{{ i }}
{% endfor %}{% endif %}</textarea>
</div>
</div>
<div style="background:rgba(0,180,216,0.06);border:1px solid rgba(0,180,216,0.2);border-radius:8px;padding:12px;margin-top:16px;font-size:12px;color:var(--gray)">
️ Esta versión quedará en estado <strong style="color:var(--cyan)">Borrador</strong> hasta que el admin la apruebe desde el detalle del procedimiento.
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar Borrador</button>
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block title %}{% if system %}Editar{% else %}Nuevo{% endif %} Sistema{% endblock %}
{% block page_title %}{% if system %}Editar Sistema{% else %}Nuevo Sistema{% endif %}{% endblock %}
{% block topbar_actions %}<a href="{{ url_for('systems') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
{% block content %}
<div class="card" style="max-width:500px">
{% if system and system.is_default %}
<div class="alert alert-warn mb-4">Los sistemas predefinidos no se pueden editar.</div>
{% else %}
<form method="POST">
<div class="form-group mb-4">
<label>Nombre del Sistema *</label>
<input type="text" name="name" value="{{ system.name if system else '' }}"
required placeholder="Ej: Watermaker, Bow Thruster...">
</div>
<div class="form-group mb-4">
<label>Descripción (opcional)</label>
<textarea name="description">{{ system.description if system else '' }}</textarea>
</div>
<div class="flex gap-3">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
<a href="{{ url_for('systems') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
{% endif %}
</div>
{% endblock %}
+45
View File
@@ -0,0 +1,45 @@
{% extends 'base.html' %}
{% block title %}Sistemas{% endblock %}
{% block page_title %}Sistemas{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('system_new') }}" class="btn btn-primary">+ Nuevo Sistema</a>
{% endblock %}
{% block content %}
<div class="card">
<div class="table-wrap">
<table>
<thead><tr><th>Sistema</th><th>Descripción</th><th>Tipo</th><th></th></tr></thead>
<tbody>
{% for s in systems %}
<tr>
<td><strong>{{ s.name }}</strong></td>
<td class="text-gray">{{ s.description or '—' }}</td>
<td>
{% if s.is_default %}
<span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan)">Predefinido</span>
{% else %}
<span class="badge" style="background:rgba(46,196,182,0.15);color:var(--success)">Personalizado</span>
{% endif %}
</td>
<td>
{% if not s.is_default %}
<a href="{{ url_for('system_edit', sid=s.id) }}" class="btn btn-sm btn-secondary">✏️</a>
<button onclick="deleteSystem({{ s.id }}, this.closest('tr'))" class="btn btn-sm btn-danger">🗑️</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function deleteSystem(id, row) {
if (!confirm('¿Eliminar este sistema?')) return;
fetch(`/systems/${id}/delete`, {method:'DELETE'})
.then(() => row.remove());
}
</script>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends 'base.html' %}
{% block title %}{% if user %}Editar{% else %}Nuevo{% endif %} Usuario{% endblock %}
{% block page_title %}{% if user %}Editar Usuario{% else %}Nuevo Usuario{% endif %}{% endblock %}
{% block topbar_actions %}<a href="{{ url_for('users') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
{% block content %}
<div class="card" style="max-width:560px">
<form method="POST">
<div class="form-grid">
<div class="form-group">
<label>Usuario *</label>
<input type="text" name="username" value="{{ user.username if user else '' }}" required>
</div>
<div class="form-group">
<label>Nombre Completo</label>
<input type="text" name="full_name" value="{{ user.full_name if user else '' }}">
</div>
<div class="form-group full">
<label>Email *</label>
<input type="email" name="email" value="{{ user.email if user else '' }}" required>
</div>
<div class="form-group">
<label>Contraseña {% if user %}(dejar vacío = no cambiar){% endif %}</label>
<input type="password" name="password" {% if not user %}required{% endif %}>
</div>
<div class="form-group">
<label>Rol</label>
<select name="role">
{% if current_user.role == 'superadmin' %}
<option value="superadmin" {% if user and user.role=='superadmin' %}selected{% endif %}>Super Admin</option>
<option value="admin" {% if user and user.role=='admin' %}selected{% endif %}>Admin</option>
{% endif %}
<option value="technician" {% if user and user.role=='technician' %}selected{% endif %}>Técnico</option>
</select>
</div>
{% if current_user.role == 'superadmin' %}
<div class="form-group full">
<label>Compañía (vacío = acceso a todas)</label>
<select name="company_id">
<option value="">-- Todas las compañías --</option>
{% for c in companies %}
<option value="{{ c.id }}" {% if user and user.company_id==c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if user %}
<div class="form-group">
<label>Estado</label>
<select name="is_active">
<option value="1" {% if user.is_active %}selected{% endif %}>Activo</option>
<option value="0" {% if not user.is_active %}selected{% endif %}>Inactivo</option>
</select>
</div>
{% endif %}
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
<a href="{{ url_for('users') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
{% extends 'base.html' %}
{% block title %}Usuarios{% endblock %}
{% block page_title %}Usuarios del Sistema{% endblock %}
{% block topbar_actions %}
{% if current_user.role == 'superadmin' or current_user.role == 'admin' %}
<a href="{{ url_for('user_new') }}" class="btn btn-primary">+ Nuevo Usuario</a>
{% endif %}
{% endblock %}
{% block content %}
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr><th>Usuario</th><th>Nombre</th><th>Email</th><th>Compañía</th><th>Rol</th><th>Estado</th><th>Último Login</th><th></th></tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td><strong>{{ u.username }}</strong></td>
<td>{{ u.full_name or '—' }}</td>
<td class="text-gray">{{ u.email }}</td>
<td>{{ u.company_name or '<span class="text-cyan">Todas</span>'|safe }}</td>
<td>
{% if u.role == 'superadmin' %}
<span class="badge" style="background:rgba(0,180,216,0.2);color:var(--cyan)">Super Admin</span>
{% elif u.role == 'admin' %}
<span class="badge" style="background:rgba(46,196,182,0.2);color:var(--success)">Admin</span>
{% else %}
<span class="badge" style="background:rgba(255,255,255,0.1);color:var(--gray)">Técnico</span>
{% endif %}
</td>
<td>
{% if u.is_active %}
<span class="badge badge-completed">Activo</span>
{% else %}
<span class="badge badge-cancelled">Inactivo</span>
{% endif %}
</td>
<td class="text-gray" style="font-size:12px">{{ u.last_login[:16] if u.last_login else '—' }}</td>
<td>
{% if current_user.role == 'superadmin' or (current_user.role == 'admin' and u.role == 'technician') %}
<a href="{{ url_for('user_edit', uid=u.id) }}" class="btn btn-sm btn-secondary">✏️</a>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-gray" style="text-align:center;padding:30px">Sin usuarios.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends 'base.html' %}
{% block title %}{{ vessel.name }}{% endblock %}
{% block page_title %}{{ vessel.name }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('work_order_new') }}?vessel={{ vessel.id }}" class="btn btn-primary">+ Nueva Orden</a>
<a href="{{ url_for('vessel_edit', vid=vessel.id) }}" class="btn btn-secondary">✏️ Editar</a>
<a href="{{ url_for('vessels') }}" class="btn btn-secondary">← Volver</a>
{% endblock %}
{% block content %}
<div class="grid-2 mb-4">
<div class="card">
<div class="card-header">📋 Información</div>
<table style="font-size:13px">
<tr><td style="color:var(--gray);padding:6px 0;width:140px">Matrícula</td><td>{{ vessel.registration or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Tipo</td><td>{{ vessel.vessel_type or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Marca / Modelo</td><td>{{ vessel.make or '' }} {{ vessel.model or '' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Año</td><td>{{ vessel.year or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Eslora</td><td>{{ vessel.length_ft or '—' }} ft</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Motor</td><td>{{ vessel.engine_type or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Horas Motor</td><td class="text-cyan">{{ vessel.engine_hours or 0 }} h</td></tr>
</table>
</div>
<div class="card">
<div class="card-header">👤 Propietario</div>
<table style="font-size:13px">
<tr><td style="color:var(--gray);padding:6px 0;width:100px">Nombre</td><td>{{ vessel.owner_name or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Teléfono</td><td>{{ vessel.owner_phone or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Email</td><td>{{ vessel.owner_email or '—' }}</td></tr>
</table>
{% if vessel.captain_name or vessel.captain_phone %}
<div style="margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.05)">
<div style="font-size:11px;color:var(--cyan);text-transform:uppercase;letter-spacing:1.5px;font-weight:600;margin-bottom:8px">⚓ Capitán</div>
<table style="font-size:13px">
<tr><td style="color:var(--gray);padding:4px 0;width:100px">Nombre</td><td>{{ vessel.captain_name or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:4px 0">Teléfono</td><td>{{ vessel.captain_phone or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:4px 0">Email</td><td>{{ vessel.captain_email or '—' }}</td></tr>
</table>
</div>
{% endif %}
{% if vessel.notes %}
<div class="mt-4" style="font-size:13px;color:var(--gray);border-top:1px solid rgba(255,255,255,0.05);padding-top:12px">
{{ vessel.notes }}
</div>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-header">🔧 Historial de Órdenes de Trabajo</div>
<div class="table-wrap">
<table>
<thead><tr><th>Orden</th><th>Tipo</th><th>Descripción</th><th>Técnico</th><th>Fecha</th><th>Estado</th><th></th></tr></thead>
<tbody>
{% for o in orders %}
<tr>
<td class="text-cyan">{{ o.order_number }}</td>
<td>{{ o.work_type or '—' }}</td>
<td>{{ o.description[:60] }}{% if o.description|length > 60 %}...{% endif %}</td>
<td>{{ o.technician or '—' }}</td>
<td class="text-gray">{{ o.start_date or o.created_at[:10] }}</td>
<td><span class="badge badge-{{ o.status }}">{{ o.status.replace('_',' ') }}</span></td>
<td><a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-secondary">Ver</a></td>
</tr>
{% else %}
<tr><td colspan="7" class="text-gray" style="text-align:center;padding:20px">Sin órdenes de trabajo.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+114
View File
@@ -0,0 +1,114 @@
{% extends 'base.html' %}
{% block title %}{% if vessel %}Editar{% else %}Nueva{% endif %} Embarcación{% endblock %}
{% block page_title %}{% if vessel %}Editar Embarcación{% else %}Nueva Embarcación{% endif %}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('vessels') }}" class="btn btn-secondary">← Volver</a>
{% endblock %}
{% block content %}
<div class="card" style="max-width:820px">
<form method="POST">
{% if current_user.role in ('superadmin','admin') and companies %}
<div class="section-title">Compañía</div>
<div class="form-grid mb-4">
<div class="form-group full">
<label>Compañía *</label>
<select name="company_id" required>
<option value="">-- Seleccionar --</option>
{% for c in companies %}
<option value="{{ c.id }}" {% if vessel and vessel.company_id==c.id %}selected{% elif current_user.company_id==c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
<div class="section-title">Datos de la Embarcación</div>
<div class="form-grid cols-3">
<div class="form-group full">
<label>Nombre *</label>
<input type="text" name="name" value="{{ vessel.name if vessel else '' }}" required>
</div>
<div class="form-group">
<label>Matrícula</label>
<input type="text" name="registration" value="{{ vessel.registration if vessel else '' }}">
</div>
<div class="form-group">
<label>Tipo</label>
<select name="vessel_type">
<option value="">-- Seleccionar --</option>
{% for t in ['Motor Yacht','Sailing Yacht','Sport Fisherman','Center Console','Catamaran','Trawler','Pontoon','PWC','Commercial','Other'] %}
<option value="{{ t }}" {% if vessel and vessel.vessel_type==t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Año</label>
<input type="number" name="year" value="{{ vessel.year if vessel else '' }}" min="1900" max="2030">
</div>
<div class="form-group">
<label>Marca</label>
<input type="text" name="make" value="{{ vessel.make if vessel else '' }}" placeholder="Azimut, Sunseeker...">
</div>
<div class="form-group">
<label>Modelo</label>
<input type="text" name="model" value="{{ vessel.model if vessel else '' }}" placeholder="80, 68S...">
</div>
<div class="form-group">
<label>Eslora (pies)</label>
<input type="number" step="0.1" name="length_ft" value="{{ vessel.length_ft if vessel else '' }}">
</div>
<div class="form-group">
<label>Bandera</label>
<input type="text" name="flag" value="{{ vessel.flag if vessel else '' }}" placeholder="USA, Panama...">
</div>
<div class="form-group">
<label>Puerto de Matrícula</label>
<input type="text" name="port_of_registry" value="{{ vessel.port_of_registry if vessel else '' }}">
</div>
</div>
<div class="section-title mt-6">Propietario</div>
<div class="form-grid">
<div class="form-group">
<label>Nombre Propietario</label>
<input type="text" name="owner_name" value="{{ vessel.owner_name if vessel else '' }}">
</div>
<div class="form-group">
<label>Teléfono</label>
<input type="tel" name="owner_phone" value="{{ vessel.owner_phone if vessel else '' }}">
</div>
<div class="form-group full">
<label>Email</label>
<input type="email" name="owner_email" value="{{ vessel.owner_email if vessel else '' }}">
</div>
</div>
<div class="section-title mt-6">⚓ Capitán / Contacto Operativo</div>
<div class="form-grid">
<div class="form-group">
<label>Nombre Capitán</label>
<input type="text" name="captain_name" value="{{ vessel.captain_name if vessel else '' }}">
</div>
<div class="form-group">
<label>Teléfono</label>
<input type="tel" name="captain_phone" value="{{ vessel.captain_phone if vessel else '' }}">
</div>
<div class="form-group full">
<label>Email</label>
<input type="email" name="captain_email" value="{{ vessel.captain_email if vessel else '' }}">
</div>
</div>
<div class="form-group full mt-4">
<label>Notas</label>
<textarea name="notes">{{ vessel.notes if vessel else '' }}</textarea>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar</button>
<a href="{{ url_for('vessels') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+333
View File
@@ -0,0 +1,333 @@
{% extends 'base.html' %}
{% block title %}Historial — {{ vessel.name }}{% endblock %}
{% block page_title %}{{ vessel.name }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('work_order_new') }}?vessel={{ vessel.id }}" class="btn btn-primary">+ Nueva Orden</a>
<a href="{{ url_for('vessel_edit', vid=vessel.id) }}" class="btn btn-secondary">✏️ Editar</a>
<a href="{{ url_for('vessels') }}" class="btn btn-secondary">← Volver</a>
{% endblock %}
{% block content %}
<!-- STATS -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px">
<div class="stat-card">
<div class="stat-label">🚢 Tipo</div>
<div style="font-size:15px;font-weight:600;color:var(--white);margin-top:4px">{{ vessel.vessel_type or '—' }}</div>
<div class="stat-sub">{{ vessel.make or '' }} {{ vessel.model or '' }} {{ vessel.year or '' }}</div>
</div>
<div class="stat-card">
<div class="stat-label">⚙️ Horas Motor</div>
<div class="stat-value">{{ vessel.engine_hours or 0 }}</div>
<div class="stat-sub">horas acumuladas</div>
</div>
<div class="stat-card">
<div class="stat-label">📋 Total WOs</div>
<div class="stat-value">{{ orders|length }}</div>
<div class="stat-sub">órdenes registradas</div>
</div>
<div class="stat-card">
<div class="stat-label">💰 Costo Total</div>
<div class="stat-value" style="font-size:22px">${{ "%.0f"|format(total_cost) }}</div>
<div class="stat-sub">histórico acumulado</div>
</div>
</div>
<!-- CONTACTOS -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px">
<div class="card" style="padding:14px 18px">
<div style="font-size:10px;color:var(--gray);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:8px">👤 Propietario</div>
<div style="font-size:14px;font-weight:600">{{ vessel.owner_name or '—' }}</div>
<div style="font-size:12px;color:var(--gray)">{{ vessel.owner_phone or '' }}{% if vessel.owner_email %} · {{ vessel.owner_email }}{% endif %}</div>
</div>
<div class="card" style="padding:14px 18px">
<div style="font-size:10px;color:var(--cyan);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:8px">⚓ Capitán</div>
<div style="font-size:14px;font-weight:600">{{ vessel.captain_name or '—' }}</div>
<div style="font-size:12px;color:var(--gray)">{{ vessel.captain_phone or '' }}{% if vessel.captain_email %} · {{ vessel.captain_email }}{% endif %}</div>
</div>
</div>
<!-- ═══ ÓRDENES DE TRABAJO ═══ -->
<div class="card mb-4">
<div class="card-header flex justify-between">
<span>📋 Órdenes de Trabajo</span>
<a href="{{ url_for('work_order_new') }}?vessel={{ vessel.id }}" class="btn btn-sm btn-primary">+ Nueva Orden</a>
</div>
{% if orders %}
<!-- Búsqueda -->
<div style="margin-bottom:12px;display:flex;gap:10px">
<input type="text" id="histSearch"
placeholder="🔍 Buscar por scope, fecha, técnico..."
style="flex:1;padding:8px 12px;font-size:13px;border-radius:6px;
background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.2);color:var(--white);"
oninput="filterHistory(this.value)">
<select id="statusFilter" onchange="filterHistory(document.getElementById('histSearch').value)"
style="padding:8px 12px;border-radius:6px;background:rgba(255,255,255,0.06);
border:1px solid rgba(0,180,216,0.2);color:var(--white);font-size:13px">
<option value="">Todos</option>
<option value="open">Abiertas</option>
<option value="in_progress">En Progreso</option>
<option value="completed">Completadas</option>
</select>
</div>
<!-- Tabla desktop -->
<div class="table-wrap" id="histTable">
<table>
<thead>
<tr><th>Orden</th><th>Fecha</th><th>Sistema</th><th>Scope</th><th>Técnico</th><th>Horas</th><th>Costo</th><th>Estado</th><th></th></tr>
</thead>
<tbody id="woTableBody">
{% for o in orders %}
<tr class="history-row"
data-search="{{ (o.scope or '' ~ ' ' ~ (o.description or '') ~ ' ' ~ (o.technician or '') ~ ' ' ~ (o.start_date or ''))|lower }}"
data-status="{{ o.status }}">
<td class="text-cyan" style="white-space:nowrap;font-size:12px">{{ o.order_number }}</td>
<td class="text-gray" style="white-space:nowrap">{{ o.start_date or o.created_at[:10] }}</td>
<td class="text-gray" style="font-size:12px">{{ o.system_name or '—' }}</td>
<td style="max-width:280px">
<div style="font-weight:600;font-size:13px">{{ o.scope or '—' }}</div>
{% if o.description and o.scope and o.description != o.scope %}
<div style="font-size:11px;color:var(--gray)">{{ o.description[:80] }}{% if o.description|length > 80 %}...{% endif %}</div>
{% endif %}
</td>
<td style="font-size:12px">{{ o.technician or '—' }}</td>
<td style="font-size:12px">{{ o.labor_hours or 0 }} h</td>
<td class="text-cyan" style="font-size:12px;white-space:nowrap">
${{ "%.0f"|format((o.calc_labor_cost or 0) + (o.total_parts_cost or 0)) }}
</td>
<td><span class="badge badge-{{ o.status }}" style="font-size:10px">{{ o.status.replace('_',' ') }}</span></td>
<td style="white-space:nowrap">
<a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-secondary">Ver</a>
{% if o.status != 'completed' %}
<a href="{{ url_for('work_order_edit', woid=o.id) }}" class="btn btn-sm btn-secondary">✏️</a>
{% endif %}
<a href="{{ url_for('work_order_pdf', woid=o.id) }}" target="_blank" class="btn btn-sm btn-primary">📄</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Tarjetas móvil -->
<div id="histCards">
{% for o in orders %}
<div class="hist-card history-row"
data-search="{{ (o.scope or '' ~ ' ' ~ (o.description or '') ~ ' ' ~ (o.technician or '') ~ ' ' ~ (o.start_date or ''))|lower }}"
data-status="{{ o.status }}"
style="background:var(--navy2);border:1px solid rgba(0,180,216,0.12);border-radius:10px;margin-bottom:10px;overflow:hidden;">
<div style="padding:10px 14px;background:rgba(0,0,0,0.2);border-bottom:1px solid rgba(255,255,255,0.05);display:flex;justify-content:space-between;align-items:center">
<span style="font-size:12px;color:var(--cyan);font-family:monospace;font-weight:600">{{ o.order_number }}</span>
<span class="badge badge-{{ o.status }}" style="font-size:11px">{{ o.status.replace('_',' ') }}</span>
</div>
<div style="padding:10px 14px">
<div style="font-weight:600;font-size:14px;color:var(--white);margin-bottom:4px">{{ o.scope or '—' }}</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px;color:var(--gray);margin-bottom:6px">
{% if o.system_name %}<span>🔩 {{ o.system_name }}</span>{% endif %}
{% if o.technician %}<span>👤 {{ o.technician }}</span>{% endif %}
<span>📅 {{ o.start_date or o.created_at[:10] }}</span>
</div>
<div style="display:flex;gap:16px;font-size:13px">
<span style="color:var(--gray)">{{ o.labor_hours or 0 }} h</span>
<span style="color:var(--cyan);font-weight:600">${{ "%.0f"|format((o.calc_labor_cost or 0) + (o.total_parts_cost or 0)) }}</span>
</div>
</div>
<div style="display:flex;gap:8px;padding:8px 14px;border-top:1px solid rgba(255,255,255,0.05);background:rgba(0,0,0,0.1)">
<a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-primary" style="flex:1;text-align:center">Ver detalle</a>
{% if o.status != 'completed' %}
<a href="{{ url_for('work_order_edit', woid=o.id) }}" class="btn btn-sm btn-secondary">✏️</a>
{% endif %}
<a href="{{ url_for('work_order_pdf', woid=o.id) }}" target="_blank" class="btn btn-sm btn-secondary">📄</a>
</div>
</div>
{% endfor %}
</div>
<div id="noHistResults" style="display:none;text-align:center;padding:20px;color:var(--gray)">
No se encontraron órdenes.
</div>
{% else %}
<div style="text-align:center;padding:40px;color:var(--gray)">
<div style="font-size:36px;margin-bottom:10px">📋</div>
<div style="margin-bottom:14px">Sin órdenes de trabajo para esta embarcación.</div>
<a href="{{ url_for('work_order_new') }}?vessel={{ vessel.id }}" class="btn btn-primary">+ Crear Primera Orden</a>
</div>
{% endif %}
</div>
<!-- EQUIPOS -->
<div class="card mb-4">
<div class="card-header flex justify-between">
<span>⚙️ Equipos Registrados</span>
<a href="{{ url_for('equipment_new', vid=vessel.id) }}" class="btn btn-sm btn-primary">+ Agregar Equipo</a>
</div>
{% if equipment %}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px">
{% for e in equipment %}
<div style="background:rgba(0,0,0,0.2);border:1px solid rgba(0,180,216,0.12);border-radius:8px;padding:12px 14px">
<div style="font-size:13px;font-weight:600;color:var(--white);margin-bottom:3px">{{ e.name }}</div>
<div style="font-size:11px;color:var(--gray);margin-bottom:5px">
{{ e.make or '' }} {{ e.model or '' }}
{% if e.position %}· <span style="color:var(--cyan)">{{ e.position }}</span>{% endif %}
</div>
{% if e.serial_number %}
<div style="font-size:11px;background:rgba(0,180,216,0.08);border:1px solid rgba(0,180,216,0.2);
border-radius:4px;padding:3px 8px;color:var(--cyan);font-family:monospace;margin-bottom:5px">
S/N: {{ e.serial_number }}
</div>
{% endif %}
<div style="font-size:11px;color:var(--gray)">{{ e.engine_hours or 0 }} h</div>
<a href="{{ url_for('equipment_edit', vid=vessel.id, eid=e.id) }}"
class="btn btn-sm btn-secondary" style="margin-top:8px;font-size:11px">✏️</a>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-gray" style="font-size:13px">Sin equipos. Agrega motores, generadores, etc.</p>
{% endif %}
</div>
<!-- DOCUMENTOS -->
<div class="card mb-4">
<div class="card-header flex justify-between">
<span>📁 Documentos y Manuales</span>
<button onclick="document.getElementById('docModal').style.display='flex'" class="btn btn-sm btn-primary">+ Adjuntar</button>
</div>
{% if documents %}
<div class="table-wrap">
<table>
<thead><tr><th>Título</th><th>Tipo</th><th>Equipo</th><th>Tamaño</th><th>Fecha</th><th></th></tr></thead>
<tbody>
{% for d in documents %}
<tr>
<td>
<span style="font-size:15px;margin-right:6px">
{% if d.filename.endswith('.pdf') %}📄
{% elif d.filename.endswith(('.doc','docx')) %}📝
{% elif d.filename.endswith(('.xls','xlsx')) %}📊
{% elif d.filename.endswith(('.jpg','jpeg','png','gif')) %}🖼️
{% else %}📎{% endif %}
</span>
<strong>{{ d.title }}</strong>
{% if d.description %}<br><span class="text-gray" style="font-size:11px">{{ d.description }}</span>{% endif %}
</td>
<td><span class="badge badge-open" style="font-size:10px">{{ d.doc_type }}</span></td>
<td class="text-gray">{{ d.equipment_name or '—' }}</td>
<td class="text-gray" style="font-size:12px">{% if d.file_size %}{{ "%.1f"|format(d.file_size/1024) }} KB{% else %}—{% endif %}</td>
<td class="text-gray" style="font-size:12px">{{ d.created_at[:10] }}</td>
<td class="flex gap-2">
<a href="{{ url_for('document_download', doc_id=d.id) }}" class="btn btn-sm btn-primary">⬇️</a>
<button onclick="deleteDoc({{ d.id }}, this.closest('tr'))" class="btn btn-sm btn-danger">🗑️</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray" style="font-size:13px">Sin documentos adjuntos.</p>
{% endif %}
</div>
<!-- MODAL DOCUMENTO -->
<div id="docModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:1000;align-items:center;justify-content:center">
<div class="card" style="width:480px;max-width:95vw">
<div class="card-header">📁 Adjuntar Documento</div>
<div class="form-group mb-3">
<label>Título *</label>
<input type="text" id="docTitle" placeholder="Ej: Manual MTU 12V2000">
</div>
<div class="form-grid mb-3">
<div class="form-group">
<label>Tipo</label>
<select id="docType">
<option value="manual">Manual</option>
<option value="certificate">Certificado</option>
<option value="warranty">Garantía</option>
<option value="inspection">Inspección</option>
<option value="drawing">Plano/Diagrama</option>
<option value="other">Otro</option>
</select>
</div>
<div class="form-group">
<label>Equipo (opcional)</label>
<select id="docEquipment">
<option value="">— General —</option>
{% for e in equipment %}
<option value="{{ e.id }}">{{ e.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-group mb-3">
<label>Descripción (opcional)</label>
<input type="text" id="docDesc">
</div>
<div class="form-group mb-3">
<label>Archivo</label>
<input type="file" id="docFile" accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.png,.jpg,.jpeg" style="padding:8px">
</div>
<div class="flex gap-3">
<button onclick="uploadDoc()" class="btn btn-primary">📤 Subir</button>
<button onclick="document.getElementById('docModal').style.display='none'" class="btn btn-secondary">Cancelar</button>
</div>
<div id="docStatus" style="margin-top:10px;font-size:13px"></div>
</div>
</div>
{% endblock %}
{% block head %}
<style>
@media (max-width:768px) {
#histTable { display:none; }
#histCards { display:block; }
}
@media (min-width:769px) {
#histTable { display:block; }
#histCards { display:none; }
}
</style>
{% endblock %}
{% block scripts %}
<script>
const VID = {{ vessel.id }};
function filterHistory(q) {
q = (q || '').toLowerCase().trim();
const status = document.getElementById('statusFilter').value;
const rows = document.querySelectorAll('.history-row');
let visible = 0;
rows.forEach(r => {
const show = (!q || r.dataset.search.includes(q)) && (!status || r.dataset.status === status);
r.style.display = show ? '' : 'none';
if (show) visible++;
});
document.getElementById('noHistResults').style.display = visible === 0 ? 'block' : 'none';
}
function uploadDoc() {
const file = document.getElementById('docFile').files[0];
const title = document.getElementById('docTitle').value.trim();
if (!file) { alert('Selecciona un archivo'); return; }
if (!title) { alert('Ingresa un título'); return; }
const fd = new FormData();
fd.append('file', file);
fd.append('title', title);
fd.append('doc_type', document.getElementById('docType').value);
fd.append('equipment_id', document.getElementById('docEquipment').value);
fd.append('description', document.getElementById('docDesc').value);
document.getElementById('docStatus').textContent = 'Subiendo...';
fetch(`/vessels/${VID}/documents/upload`, { method:'POST', body: fd })
.then(r => r.json()).then(d => {
if (d.ok) location.reload();
else document.getElementById('docStatus').textContent = 'Error: ' + d.error;
});
}
function deleteDoc(id, row) {
if (!confirm('¿Eliminar este documento?')) return;
fetch(`/documents/${id}/delete`, {method:'DELETE'})
.then(() => row.remove());
}
</script>
{% endblock %}
+119
View File
@@ -0,0 +1,119 @@
{% extends 'base.html' %}
{% block title %}Embarcaciones{% endblock %}
{% block page_title %}Embarcaciones{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('vessel_new') }}" class="btn btn-primary">+ Nueva Embarcación</a>
{% endblock %}
{% block head %}
<style>
.vessel-cards { display:none; }
.vessel-table { display:block; }
@media (max-width:768px) {
.vessel-table { display:none; }
.vessel-cards { display:grid; grid-template-columns:1fr; gap:10px; }
}
.vcard {
background:var(--navy2);border:1px solid rgba(0,180,216,0.12);
border-radius:10px;overflow:hidden;
}
.vcard-header {
padding:12px 14px;background:rgba(0,0,0,0.25);
border-bottom:1px solid rgba(255,255,255,0.05);
display:flex;justify-content:space-between;align-items:center;
}
.vcard-name { font-size:16px;font-weight:600;color:var(--white); }
.vcard-type { font-size:12px;color:var(--cyan); }
.vcard-body { padding:12px 14px; }
.vcard-row { display:flex;gap:16px;flex-wrap:wrap;margin-bottom:8px;font-size:13px; }
.vcard-row span { color:var(--gray); }
.vcard-row strong { color:var(--white); }
.vcard-actions {
display:flex;gap:8px;padding:10px 14px;
border-top:1px solid rgba(255,255,255,0.05);
background:rgba(0,0,0,0.1);
}
.vcard-actions a { flex:1;text-align:center; }
</style>
{% endblock %}
{% block content %}
<div style="margin-bottom:14px">
<input type="text" id="vesselSearch"
placeholder="🔍 Buscar embarcación..."
oninput="filterVessels(this.value)"
style="width:100%;max-width:400px;padding:8px 12px;border-radius:6px;
background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.25);
color:var(--white);font-size:13px">
</div>
<!-- TABLA DESKTOP -->
<div class="vessel-table card">
<div class="table-wrap">
<table>
<thead><tr><th>Nombre</th><th>Tipo</th><th>Marca/Modelo</th><th>Año</th><th>Propietario</th><th>Capitán</th><th>WOs</th><th></th></tr></thead>
<tbody>
{% for v in vessels %}
<tr class="vessel-row" data-search="{{ (v.name ~ ' ' ~ (v.owner_name or '') ~ ' ' ~ (v.captain_name or '') ~ ' ' ~ (v.vessel_type or ''))|lower }}">
<td><strong>{{ v.name }}</strong></td>
<td>{{ v.vessel_type or '—' }}</td>
<td class="text-gray">{{ v.make or '' }} {{ v.model or '' }}</td>
<td>{{ v.year or '—' }}</td>
<td>{{ v.owner_name or '—' }}</td>
<td>{{ v.captain_name or '—' }}</td>
<td><span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan)">{{ v.wo_count or 0 }}</span></td>
<td class="flex gap-2">
<a href="{{ url_for('vessel_history', vid=v.id) }}" class="btn btn-sm btn-primary">Historial</a>
<a href="{{ url_for('vessel_edit', vid=v.id) }}" class="btn btn-sm btn-secondary">✏️</a>
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-gray" style="text-align:center;padding:30px">Sin embarcaciones.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TARJETAS MÓVIL -->
<div class="vessel-cards" id="vesselCards">
{% for v in vessels %}
<div class="vcard vessel-row" data-search="{{ (v.name ~ ' ' ~ (v.owner_name or '') ~ ' ' ~ (v.captain_name or '') ~ ' ' ~ (v.vessel_type or ''))|lower }}">
<div class="vcard-header">
<div>
<div class="vcard-name">🚢 {{ v.name }}</div>
<div class="vcard-type">{{ v.vessel_type or '—' }} {% if v.year %}· {{ v.year }}{% endif %}</div>
</div>
<span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan);font-size:13px">
{{ v.wo_count or 0 }} WOs
</span>
</div>
<div class="vcard-body">
<div class="vcard-row">
{% if v.make or v.model %}<span>Marca/Modelo:</span><strong>{{ v.make or '' }} {{ v.model or '' }}</strong>{% endif %}
</div>
<div class="vcard-row">
{% if v.owner_name %}<span>Propietario:</span><strong>{{ v.owner_name }}</strong>{% endif %}
{% if v.captain_name %}<span>Capitán:</span><strong>{{ v.captain_name }}</strong>{% endif %}
</div>
{% if v.engine_hours %}<div style="font-size:12px;color:var(--gray)">⚙️ {{ v.engine_hours }} h motor</div>{% endif %}
</div>
<div class="vcard-actions">
<a href="{{ url_for('vessel_history', vid=v.id) }}" class="btn btn-sm btn-primary">📋 Historial</a>
<a href="{{ url_for('work_order_new') }}?vessel={{ v.id }}" class="btn btn-sm btn-secondary">+ WO</a>
<a href="{{ url_for('vessel_edit', vid=v.id) }}" class="btn btn-sm btn-secondary">✏️</a>
</div>
</div>
{% else %}
<div style="text-align:center;padding:40px;color:var(--gray)">Sin embarcaciones registradas.</div>
{% endfor %}
</div>
{% endblock %}
{% block scripts %}
<script>
function filterVessels(q) {
q = q.toLowerCase().trim();
document.querySelectorAll('.vessel-row').forEach(r => {
r.style.display = (!q || r.dataset.search.includes(q)) ? '' : 'none';
});
}
</script>
{% endblock %}
+999
View File
@@ -0,0 +1,999 @@
{% extends 'base.html' %}
{% block title %}{{ order.order_number }}{% endblock %}
{% block page_title %}{{ order.order_number }}{% endblock %}
{% block topbar_actions %}
<div class="topbar-actions-desktop">
{% if order.status != 'completed' %}
<a href="{{ url_for('work_order_edit', woid=order.id) }}" class="btn btn-secondary btn-sm">✏️ Editar</a>
<button onclick="setStatus('in_progress')" class="btn btn-warning btn-sm">▶ En Progreso</button>
<button onclick="setStatus('completed')" class="btn btn-success btn-sm">✅ Completar</button>
<button onclick="deleteThisWO()" class="btn btn-danger btn-sm">🗑️</button>
{% else %}
<button onclick="confirmReopen()" class="btn btn-warning btn-sm">🔓 Reabrir WO</button>
{% endif %}
<button onclick="openAssignModal()" class="btn btn-secondary btn-sm">👤 Asignar</button>
<a href="{{ url_for('work_order_pdf', woid=order.id) }}?lang=es" target="_blank" class="btn btn-primary btn-sm">📄 ES</a>
<a href="{{ url_for('work_order_pdf', woid=order.id) }}?lang=en" target="_blank" class="btn btn-secondary btn-sm">📄 EN</a>
<button onclick="openShareModal()" class="btn btn-secondary btn-sm">📤 Enviar</button>
<a href="{{ url_for('work_orders') }}" class="btn btn-secondary btn-sm">← Volver</a>
</div>
{% endblock %}
{% block content %}
<!-- BARRA DE ACCIONES MÓVIL -->
<style>
.mobile-action-bar { display:none; }
.topbar-actions-desktop { display:flex; gap:8px; flex-wrap:wrap; }
@media (max-width:768px) {
.topbar-actions-desktop { display:none !important; }
.mobile-action-bar {
display:flex;position:fixed;bottom:0;left:0;right:0;
background:var(--navy2);border-top:2px solid rgba(0,180,216,0.4);
padding:8px 10px;gap:6px;z-index:950;
}
.mobile-action-bar .mab-btn {
flex:1;text-align:center;font-size:11px;padding:8px 4px;
border-radius:8px;border:none;cursor:pointer;min-width:0;
display:flex;flex-direction:column;align-items:center;gap:2px;
font-weight:600;
}
.mobile-action-bar .mab-btn .icon { font-size:18px;line-height:1; }
.mobile-action-bar .mab-btn .label { font-size:10px;line-height:1; }
.content { padding-bottom:80px !important; }
}
</style>
<div class="mobile-action-bar">
{% if order.status != 'completed' %}
<button onclick="setStatus('in_progress')" class="mab-btn btn-warning"
style="background:rgba(244,162,97,0.2);color:var(--warning)">
<span class="icon"></span><span class="label">Progreso</span>
</button>
<button onclick="setStatus('completed')" class="mab-btn btn-success"
style="background:rgba(46,196,182,0.2);color:var(--success)">
<span class="icon"></span><span class="label">Completar</span>
</button>
<a href="{{ url_for('work_order_edit', woid=order.id) }}" class="mab-btn"
style="background:rgba(255,255,255,0.08);color:var(--white);text-decoration:none">
<span class="icon">✏️</span><span class="label">Editar</span>
</a>
{% else %}
<button onclick="confirmReopen()" class="mab-btn"
style="background:rgba(244,162,97,0.2);color:var(--warning)">
<span class="icon">🔓</span><span class="label">Reabrir</span>
</button>
{% endif %}
<button onclick="openShareModal()" class="mab-btn"
style="background:rgba(255,255,255,0.08);color:var(--white)">
<span class="icon">📤</span><span class="label">Enviar</span>
</button>
<a href="{{ url_for('work_order_pdf', woid=order.id) }}?lang=es" target="_blank"
class="mab-btn" style="background:rgba(0,180,216,0.2);color:var(--cyan);text-decoration:none">
<span class="icon">📄</span><span class="label">PDF</span>
</a>
<a href="{{ url_for('work_orders') }}" class="mab-btn"
style="background:rgba(255,255,255,0.08);color:var(--white);text-decoration:none">
<span class="icon"></span><span class="label">Volver</span>
</a>
</div>
<div class="grid-2 mb-4">
<div class="card">
<div class="card-header">📋 Detalles</div>
<table style="font-size:13px">
<tr><td style="color:var(--gray);padding:6px 0;width:140px">Embarcación</td><td><a href="{{ url_for('vessel_history', vid=order.vessel_id) }}" class="text-cyan">{{ order.vessel_name }}</a></td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Sistema</td><td><span class="badge badge-open">{{ order.system_name or '—' }}</span></td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Scope</td><td><strong>{{ order.scope or '—' }}</strong></td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Tipo</td><td>{{ order.work_type or '—' }}</td></tr>
<tr>
<td style="color:var(--gray);padding:6px 0">Facturación</td>
<td>
{% if order.billing_type == 'lump_sum' %}
<span class="badge" style="background:rgba(244,162,97,0.15);color:var(--warning)">💰 A todo costo</span>
{% elif order.billing_type == 'labor_only' %}
<span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan)">🔧 Solo M.O.</span>
{% else %}
<span class="badge" style="background:rgba(46,196,182,0.15);color:var(--success)">📋 M.O. + Materiales</span>
{% endif %}
</td>
</tr>
<tr><td style="color:var(--gray);padding:6px 0">Técnico</td><td>{{ order.technician or '—' }}</td></tr>
{% if order.assigned_to %}
<tr>
<td style="color:var(--gray);padding:6px 0">Asignado a</td>
<td>
<span style="color:var(--cyan);font-size:12px">📨 {{ order.assigned_to }}</span>
{% if order.assigned_by %}<br><span style="font-size:11px;color:var(--gray)">por {{ order.assigned_by }}</span>{% endif %}
</td>
</tr>
{% endif %}
<tr><td style="color:var(--gray);padding:6px 0">Fecha inicio</td><td>{{ order.start_date or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Fecha fin</td><td>{{ order.end_date or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">H. Motor inicio</td><td>{{ order.engine_hours_start or '—' }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Estado</td><td><span class="badge badge-{{ order.status }}">{{ order.status.replace('_',' ') }}</span></td></tr>
</table>
<div style="margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.05);font-size:13px">
<div style="color:var(--gray);font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px">Descripción</div>
{{ order.description }}
</div>
</div>
<div class="card">
<div class="card-header">💰 Costos</div>
<table style="font-size:13px">
<tr><td style="color:var(--gray);padding:6px 0;width:160px">Horas Trabajadas</td><td>{{ order.labor_hours or 0 }} h</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Tarifa M.O.</td><td>${{ order.labor_rate or 0 }}/h</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Costo M.O.</td><td>${{ "%.2f"|format(order.total_labor_cost or 0) }}</td></tr>
<tr><td style="color:var(--gray);padding:6px 0">Costo Repuestos</td><td>${{ "%.2f"|format(order.total_parts_cost or 0) }}</td></tr>
<tr style="font-weight:600"><td style="color:var(--cyan);padding:8px 0">TOTAL</td><td class="text-cyan">${{ "%.2f"|format((order.total_labor_cost or 0) + (order.total_parts_cost or 0)) }}</td></tr>
</table>
<div class="mt-4" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<label style="font-size:12px;color:var(--gray)">Horas trabajadas:</label>
<input type="number" id="labor_hours" step="0.01" value="{{ order.labor_hours or 0 }}" style="width:80px;padding:6px">
<label style="font-size:12px;color:var(--gray)">Tarifa $/h:</label>
<input type="number" id="labor_rate" step="0.01" value="{{ order.labor_rate or 0 }}" style="width:80px;padding:6px">
<button onclick="updateHours()" class="btn btn-sm btn-primary">💾 Guardar Horas</button>
<span id="hoursStatus" style="font-size:12px;color:var(--success)"></span>
</div>
</div>
</div>
<!-- EQUIPOS TRABAJADOS EN ESTE WO -->
<div class="card mb-4">
<div class="card-header flex justify-between">
<span>⚙️ Equipos Trabajados</span>
{% if order.status != 'completed' %}
<button onclick="openEquipModal()" class="btn btn-sm btn-primary">+ Agregar Equipo</button>
{% endif %}
</div>
{% if wo_equipment %}
<div class="table-wrap">
<table>
<thead><tr><th>Equipo</th><th>S/N</th><th>Trabajo Realizado</th><th>Notas</th><th>Horas</th><th>Costo M.O.</th>{% if order.status != 'completed' %}<th></th>{% endif %}</tr></thead>
<tbody>
{% for e in wo_equipment %}
<tr>
<td><strong>{{ e.equip_name or '—' }}</strong><br><span class="text-gray" style="font-size:11px">{{ e.make or '' }} {{ e.model or '' }}</span></td>
<td class="text-gray" style="font-family:monospace;font-size:11px">{{ e.serial_number or '—' }}</td>
<td>{{ e.description or '—' }}</td>
<td class="text-gray" style="font-size:11px">{{ e.notes or '—' }}</td>
<td>{{ e.labor_hours or 0 }} h</td>
<td class="text-cyan">${{ "%.2f"|format((e.labor_hours or 0) * (e.labor_rate or 0)) }}</td>
{% if order.status != 'completed' %}
<td class="flex gap-2">
<button onclick="editWoEquip({{ e.id }}, '{{ e.equip_name or '' }}', {{ e.labor_hours or 0 }}, {{ e.labor_rate or 0 }}, '{{ e.description|replace("'","\\'")|replace('"','\\"') or '' }}', '{{ e.notes|replace("'","\\'")|replace('"','\\"') or '' }}')" class="btn btn-sm btn-secondary">✏️</button>
<button onclick="removeWoEquip({{ e.id }}, this.closest('tr'))" class="btn btn-sm btn-danger">🗑️</button>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr style="background:rgba(0,180,216,0.06);font-weight:600">
<td colspan="4" style="padding:10px 14px;color:var(--gray)">TOTAL MANO DE OBRA</td>
<td style="padding:10px 14px;color:var(--cyan)">{{ wo_equipment|sum(attribute='labor_hours') or 0 }} h</td>
<td style="padding:10px 14px;color:var(--cyan)">${{ "%.2f"|format(wo_equipment|sum(attribute='labor_cost') or 0) }}</td>
{% if order.status != 'completed' %}<td></td>{% endif %}
</tr>
</tfoot>
</table>
</div>
{% else %}
<p class="text-gray" style="font-size:13px">Sin equipos agregados.</p>
{% endif %}
</div>
<!-- MODAL AGREGAR/EDITAR EQUIPO WO -->
<div id="woEquipModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:1000;align-items:center;justify-content:center">
<div class="card" style="width:500px;max-width:95vw;position:relative">
<button onclick="closeEquipModal()"
style="position:absolute;top:10px;right:12px;background:none;border:none;
color:var(--gray);font-size:20px;cursor:pointer">×</button>
<div class="card-header" id="equipModalTitle">⚙️ Agregar Equipo a esta Orden</div>
<input type="hidden" id="woEquipEditId" value="">
<div class="form-group mb-3">
<label>Equipo de la Embarcación</label>
<select id="woEquipSelect">
<option value="">— Sin equipo registrado (manual) —</option>
{% for e in vessel_equipment %}
<option value="{{ e.id }}">{{ e.name }} {% if e.serial_number %}· S/N: {{ e.serial_number }}{% endif %}</option>
{% endfor %}
</select>
</div>
<div class="form-group mb-3">
<label>Trabajo Realizado en este Equipo *</label>
<textarea id="woEquipDesc" rows="3" placeholder="Ej: Cambio de filtros de aceite y combustible..."></textarea>
</div>
<div class="form-grid mb-3">
<div class="form-group">
<label>Horas Trabajadas</label>
<input type="number" id="woEquipHours" step="0.01" value="0" min="0">
</div>
<div class="form-group">
<label>Tarifa $/h</label>
<input type="number" id="woEquipRate" step="0.01" value="{{ order.labor_rate or 0 }}" min="0">
</div>
</div>
<div class="form-group mb-3">
<label>Notas adicionales</label>
<input type="text" id="woEquipNotes" placeholder="Observaciones, proximo servicio, etc.">
</div>
<div class="flex gap-3">
<button onclick="saveWoEquip()" class="btn btn-primary" id="btnSaveEquip">+ Agregar</button>
<button onclick="closeEquipModal()" class="btn btn-secondary">Cancelar</button>
</div>
<div id="woEquipStatus" style="margin-top:10px;font-size:13px"></div>
</div>
</div>
<!-- CAUSA TÉCNICA Y REPARACIONES -->
<div class="card mb-4">
<div class="card-header">⚠️ Causa Técnica y Reparaciones</div>
<div class="form-grid">
<div class="form-group full">
<label>Causa técnica de la falla</label>
<textarea id="root_cause" rows="3" placeholder="Describe la causa raíz del problema...">{{ order.root_cause or '' }}</textarea>
</div>
<div class="form-group full">
<label>Reparaciones realizadas</label>
<textarea id="repairs_done" rows="3" placeholder="Describe detalladamente las reparaciones efectuadas...">{{ order.repairs_done or '' }}</textarea>
</div>
</div>
<button onclick="saveTechFields()" class="btn btn-secondary btn-sm mt-4">💾 Guardar</button>
<span id="saveStatus" style="font-size:12px;color:var(--success);margin-left:10px"></span>
</div>
<!-- FOTOS -->
<div class="card mb-4">
<div class="card-header flex justify-between">
<span>📸 Evidencia Fotográfica</span>
{% if order.status != 'cancelled' %}
<button onclick="document.getElementById('photoModal').style.display='flex'" class="btn btn-sm btn-primary">+ Agregar Foto</button>
{% endif %}
</div>
{% set before_photos = photos|selectattr('photo_type','eq','before')|list %}
{% set after_photos = photos|selectattr('photo_type','eq','after')|list %}
{% if before_photos %}
<div style="margin-bottom:16px">
<div style="font-size:12px;color:var(--warn);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:10px;font-weight:600">⬅ Antes</div>
<div class="photo-grid">
{% for p in before_photos %}
<div class="photo-card before">
<img src="/static/uploads/photos/{{ p.filename }}" onclick="openPhoto(this.src)" style="cursor:pointer">
<div class="photo-label" style="display:flex;align-items:center;gap:4px">
<span style="font-size:11px;color:var(--gray)">Antes</span>
<input type="text" value="{{ p.caption or '' }}"
onchange="updateCaption({{ p.id }}, this.value)"
placeholder="Agregar descripción..."
style="flex:1;font-size:11px;background:transparent;border:none;
border-bottom:1px dashed rgba(255,255,255,0.2);color:var(--white);
padding:2px 4px;outline:none;min-width:0">
</div>
<button class="photo-del" onclick="deletePhoto({{ p.id }}, this.closest('.photo-card'))">×</button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if after_photos %}
<div>
<div style="font-size:12px;color:var(--success);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:10px;font-weight:600">➡ Después</div>
<div class="photo-grid">
{% for p in after_photos %}
<div class="photo-card after">
<img src="/static/uploads/photos/{{ p.filename }}" onclick="openPhoto(this.src)" style="cursor:pointer">
<div class="photo-label" style="display:flex;align-items:center;gap:4px">
<span style="font-size:11px;color:var(--gray)">Después</span>
<input type="text" value="{{ p.caption or '' }}"
onchange="updateCaption({{ p.id }}, this.value)"
placeholder="Agregar descripción..."
style="flex:1;font-size:11px;background:transparent;border:none;
border-bottom:1px dashed rgba(255,255,255,0.2);color:var(--white);
padding:2px 4px;outline:none;min-width:0">
</div>
<button class="photo-del" onclick="deletePhoto({{ p.id }}, this.closest('.photo-card'))">×</button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if not photos %}
<p class="text-gray" style="font-size:13px">Sin fotos aún. Agrega evidencia fotográfica del trabajo.</p>
{% endif %}
</div>
<!-- REPUESTOS USADOS -->
<div class="card">
<div class="card-header flex justify-between">
<span>🔩 Repuestos Utilizados</span>
{% if order.status != 'cancelled' %}
<button onclick="document.getElementById('partModal').style.display='flex'" class="btn btn-sm btn-primary">+ Agregar Repuesto</button>
{% endif %}
</div>
{% if parts_used %}
<div class="table-wrap">
<table>
<thead><tr><th>Repuesto</th><th>Descripción</th><th>Cantidad</th><th>Precio Unit.</th><th>Total</th></tr></thead>
<tbody>
{% for p in parts_used %}
<tr>
<td>{{ p.part_name or '—' }}{% if p.part_number %} <span class="text-gray">({{ p.part_number }})</span>{% endif %}</td>
<td class="text-gray">{{ p.description or '' }}</td>
<td>{{ p.quantity }}</td>
<td>${{ "%.2f"|format(p.unit_cost) }}</td>
<td class="text-cyan">${{ "%.2f"|format(p.total_cost) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray" style="font-size:13px">Sin repuestos registrados.</p>
{% endif %}
</div>
<!-- MODAL FOTO -->
<div id="photoModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:1000;align-items:center;justify-content:center">
<div class="card" style="width:420px;max-width:95vw">
<div class="card-header">📸 Agregar Foto</div>
<div class="form-group mb-4">
<label>Tipo</label>
<select id="photoType">
<option value="before">Antes</option>
<option value="after">Después</option>
</select>
</div>
<div class="form-group mb-4">
<label>Foto (cámara o galería)</label>
<input type="file" id="photoFile" accept="image/*" capture="environment" style="padding:8px">
</div>
<div class="form-group mb-4">
<label>Descripción (opcional)</label>
<input type="text" id="photoCaption" placeholder="Ej: Válvula hidráulica antes de cambio">
</div>
<div class="flex gap-3">
<button onclick="uploadPhoto()" class="btn btn-primary">📤 Subir</button>
<button onclick="document.getElementById('photoModal').style.display='none'" class="btn btn-secondary">Cancelar</button>
</div>
<div id="uploadStatus" style="margin-top:10px;font-size:13px"></div>
</div>
</div>
<!-- MODAL REPUESTO -->
<div id="partModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:1000;align-items:center;justify-content:center">
<div class="card" style="width:460px;max-width:95vw">
<div class="card-header">🔩 Agregar Repuesto</div>
<div class="form-group mb-4">
<label>Repuesto del Inventario</label>
<select id="partSelect" onchange="fillPartInfo()">
<option value="">-- Sin inventario (manual) --</option>
{% for p in available_parts %}
<option value="{{ p.id }}" data-price="{{ p.sale_price }}" data-stock="{{ p.quantity }}">
{{ p.name }} {% if p.part_number %}({{ p.part_number }}){% endif %} — Stock: {{ p.quantity }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group mb-4">
<label>Descripción (si es manual)</label>
<input type="text" id="partDesc" placeholder="Descripción del material">
</div>
<div class="form-grid mb-4">
<div class="form-group">
<label>Cantidad</label>
<input type="number" id="partQty" value="1" min="0.01" step="0.01">
</div>
<div class="form-group">
<label>Precio Unitario ($)</label>
<input type="number" id="partCost" value="0" step="0.01">
</div>
</div>
<div class="flex gap-3">
<button onclick="addPart()" class="btn btn-primary"> Agregar</button>
<button onclick="document.getElementById('partModal').style.display='none'" class="btn btn-secondary">Cancelar</button>
</div>
</div>
</div>
<!-- EMAIL LOG -->
{% if email_log %}
<div class="card mb-4">
<div class="card-header">📧 Historial de Envíos</div>
<div class="table-wrap">
<table>
<thead><tr><th>Fecha</th><th>Destinatario</th><th>Idioma</th><th>Estado</th><th>Enviado por</th><th>PDF</th></tr></thead>
<tbody>
{% for e in email_log %}
<tr>
<td class="text-gray" style="font-size:12px;white-space:nowrap">{{ e.sent_at[:16] }}</td>
<td>
<div style="font-weight:600;font-size:13px">{{ e.to_name or '—' }}</div>
<div style="font-size:11px;color:var(--gray)">{{ e.to_email }}</div>
</td>
<td><span class="badge badge-open" style="font-size:10px">{{ e.lang.upper() }}</span></td>
<td>
{% if e.status == 'sent' %}
<span class="badge badge-completed">✅ Enviado</span>
{% else %}
<span class="badge badge-danger" style="cursor:help"
title="{{ e.error_msg or 'Error desconocido' }}">❌ Fallido</span>
{% if e.error_msg %}
<div style="font-size:10px;color:var(--danger);max-width:200px;margin-top:2px">
{{ e.error_msg[:80] }}{% if e.error_msg|length > 80 %}...{% endif %}
</div>
{% endif %}
{% endif %}
</td>
<td class="text-gray" style="font-size:12px">{{ e.sent_by or '—' }}</td>
<td>
{% if e.pdf_filename %}
<a href="/static/uploads/pdfs/{{ e.pdf_filename }}" target="_blank" class="btn btn-sm btn-secondary">📄</a>
{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- FIRMAS -->
<div class="card mt-4">
<div class="card-header">✍️ Firmas</div>
<div class="grid-2">
<!-- Firma Técnico -->
<div>
<div style="font-size:11px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">
Técnico: {{ order.technician or '—' }}
</div>
{% if order.signature_tech %}
<img src="/static/uploads/signatures/{{ order.signature_tech }}"
style="background:white;border-radius:8px;border:1px solid rgba(0,180,216,0.2);max-width:100%;height:120px;object-fit:contain;display:block">
<button onclick="clearSignature('tech')" class="btn btn-sm btn-secondary" style="margin-top:8px">🗑 Borrar</button>
{% else %}
<canvas id="sigTech" width="340" height="120"
style="background:white;border-radius:8px;border:2px solid rgba(0,180,216,0.3);
touch-action:none;cursor:crosshair;display:block;max-width:100%"></canvas>
<div class="flex gap-2" style="margin-top:8px">
<button onclick="saveSignature('tech')" class="btn btn-sm btn-primary">💾 Guardar</button>
<button onclick="clearCanvas('sigTech')" class="btn btn-sm btn-secondary">🗑 Limpiar</button>
</div>
{% endif %}
</div>
<!-- Firma Cliente/Capitán -->
<div>
<div style="font-size:11px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">
Capitán / Cliente
</div>
{% if order.signature_client %}
<img src="/static/uploads/signatures/{{ order.signature_client }}"
style="background:white;border-radius:8px;border:1px solid rgba(0,180,216,0.2);max-width:100%;height:120px;object-fit:contain;display:block">
<button onclick="clearSignature('client')" class="btn btn-sm btn-secondary" style="margin-top:8px">🗑 Borrar</button>
{% else %}
<canvas id="sigClient" width="340" height="120"
style="background:white;border-radius:8px;border:2px solid rgba(0,180,216,0.3);
touch-action:none;cursor:crosshair;display:block;max-width:100%"></canvas>
<div class="flex gap-2" style="margin-top:8px">
<button onclick="saveSignature('client')" class="btn btn-sm btn-primary">💾 Guardar</button>
<button onclick="clearCanvas('sigClient')" class="btn btn-sm btn-secondary">🗑 Limpiar</button>
</div>
{% endif %}
</div>
</div>
</div>
<!-- SHARE MODAL -->
<div id="shareModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:1000;align-items:center;justify-content:center">
<div class="card" style="width:480px;max-width:95vw">
<div class="card-header">📤 Enviar Reporte</div>
<!-- Tabs -->
<div style="display:flex;gap:4px;margin-bottom:16px;background:rgba(0,0,0,0.2);border-radius:8px;padding:4px">
<button onclick="showTab('email')" id="tabEmail" class="btn btn-sm btn-primary" style="flex:1">✉️ Email</button>
<button onclick="showTab('share')" id="tabShare" class="btn btn-sm btn-secondary" style="flex:1">📱 WhatsApp / SMS</button>
</div>
<!-- EMAIL TAB -->
<div id="tabEmailContent">
<div class="form-group mb-3">
<label>Para (nombre)</label>
<input type="text" id="sendToName" value="{{ vessel_captain_name or vessel_owner_name or '' }}"
placeholder="Nombre del destinatario">
</div>
<div class="form-group mb-3">
<label>Email *</label>
<input type="email" id="sendToEmail"
value="{{ vessel_captain_email or vessel_owner_email or '' }}"
placeholder="email@ejemplo.com">
{% if vessel_captain_email or vessel_owner_email %}
<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap">
{% if vessel_captain_email %}
<button onclick="document.getElementById('sendToEmail').value='{{ vessel_captain_email }}';document.getElementById('sendToName').value='{{ vessel_captain_name }}'"
class="btn btn-sm btn-secondary" style="font-size:11px">⚓ {{ vessel_captain_name or 'Capitán' }}</button>
{% endif %}
{% if vessel_owner_email %}
<button onclick="document.getElementById('sendToEmail').value='{{ vessel_owner_email }}';document.getElementById('sendToName').value='{{ vessel_owner_name }}'"
class="btn btn-sm btn-secondary" style="font-size:11px">👤 {{ vessel_owner_name or 'Propietario' }}</button>
{% endif %}
</div>
{% endif %}
</div>
<div class="form-group mb-3">
<label>Idioma del Reporte</label>
<select id="sendLang" style="width:100%;padding:8px;border-radius:6px;background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.2);color:var(--white)">
<option value="es">Español</option>
<option value="en">English</option>
</select>
</div>
<div class="form-group mb-4">
<label>Asunto</label>
<input type="text" id="sendSubject"
value="Reporte de Mantenimiento — {{ order.order_number }}">
</div>
<div class="flex gap-3">
<button onclick="sendEmail()" class="btn btn-primary">📧 Enviar Email</button>
<button onclick="closeShareModal()" class="btn btn-secondary">Cancelar</button>
</div>
<div id="emailStatus" style="margin-top:10px;font-size:13px"></div>
</div>
<!-- SHARE TAB -->
<div id="tabShareContent" style="display:none">
<p style="font-size:13px;color:var(--gray);margin-bottom:16px">
Primero guardamos el PDF en el servidor para generar un link compartible.
</p>
<div id="shareLinks" style="display:none">
<div class="form-group mb-3">
<label>Link del PDF</label>
<input type="text" id="pdfLink" readonly
style="font-size:12px;cursor:pointer"
onclick="this.select();document.execCommand('copy')">
<div style="font-size:11px;color:var(--gray);margin-top:3px">Toca para copiar</div>
</div>
<div class="flex gap-3" style="flex-wrap:wrap">
<a id="waLink" href="#" target="_blank"
style="background:#25D366;color:white;padding:10px 20px;border-radius:8px;
text-decoration:none;font-weight:600;font-size:14px;display:flex;align-items:center;gap:8px">
<span style="font-size:20px">💬</span> WhatsApp
</a>
<a id="smsLink" href="#"
style="background:#0a84ff;color:white;padding:10px 20px;border-radius:8px;
text-decoration:none;font-weight:600;font-size:14px;display:flex;align-items:center;gap:8px">
<span style="font-size:20px">💬</span> SMS
</a>
</div>
</div>
<div id="shareLoading" style="font-size:13px;color:var(--gray)"></div>
<div class="flex gap-3 mt-4">
<button onclick="generateShareLinks()" class="btn btn-primary">🔗 Generar Links</button>
<button onclick="closeShareModal()" class="btn btn-secondary">Cancelar</button>
</div>
</div>
</div>
</div>
<!-- LIGHTBOX -->
<div id="lightbox" onclick="this.style.display='none'" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.95);z-index:2000;align-items:center;justify-content:center;cursor:zoom-out">
<img id="lightboxImg" style="max-width:95vw;max-height:95vh;border-radius:8px">
</div>
{% endblock %}
{% block scripts %}
<script>
const WO_ID = {{ order.id }};
function saveTechFields() {
fetch(`/work-orders/${WO_ID}/update-fields`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
root_cause: document.getElementById('root_cause').value,
repairs_done: document.getElementById('repairs_done').value
})
}).then(() => {
document.getElementById('saveStatus').textContent = '✓ Guardado';
setTimeout(() => document.getElementById('saveStatus').textContent = '', 2000);
});
}
function deleteThisWO() {
if (!confirm('¿Eliminar esta orden de trabajo? Esta acción no se puede deshacer.')) return;
fetch('/work-orders/' + WO_ID + '/delete', {method:'DELETE'})
.then(r => r.json())
.then(d => {
if (d.ok) window.location.href = '/work-orders';
else alert('Error: ' + d.error);
});
}
function updateHours() {
const h = parseFloat(document.getElementById('labor_hours').value) || 0;
const r = parseFloat(document.getElementById('labor_rate').value) || 0;
const st = document.getElementById('hoursStatus');
st.textContent = 'Guardando...';
st.style.color = 'var(--gray)';
fetch(`/api/update-labor/${WO_ID}`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({labor_hours: h, labor_rate: r})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
st.textContent = 'Guardado';
st.style.color = 'var(--success)';
setTimeout(() => location.reload(), 600);
} else {
st.textContent = 'Error';
st.style.color = 'var(--danger)';
}
})
.catch(e => { st.textContent = 'Error: ' + e; st.style.color = 'var(--danger)'; });
}
function uploadPhoto() {
const file = document.getElementById('photoFile').files[0];
if (!file) { alert('Selecciona una foto'); return; }
const fd = new FormData();
fd.append('photo', file);
fd.append('photo_type', document.getElementById('photoType').value);
fd.append('caption', document.getElementById('photoCaption').value);
document.getElementById('uploadStatus').textContent = 'Subiendo...';
fetch(`/work-orders/${WO_ID}/upload-photo`, { method:'POST', body: fd })
.then(r => r.json()).then(d => {
if (d.ok) location.reload();
else document.getElementById('uploadStatus').textContent = 'Error: ' + d.error;
});
}
function deletePhoto(id, el) {
if (!confirm('¿Eliminar esta foto?')) return;
fetch(`/api/delete-photo/${id}`, {method:'DELETE'})
.then(() => el.remove());
}
function updateCaption(id, caption) {
fetch(`/api/update-photo-caption/${id}`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({caption})
});
}
function fillPartInfo() {
const sel = document.getElementById('partSelect');
const opt = sel.options[sel.selectedIndex];
if (opt.value) {
document.getElementById('partCost').value = opt.dataset.price || 0;
}
}
function addPart() {
const data = {
part_id: document.getElementById('partSelect').value || null,
description: document.getElementById('partDesc').value,
quantity: document.getElementById('partQty').value,
unit_cost: document.getElementById('partCost').value
};
fetch(`/work-orders/${WO_ID}/add-part`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(data)
}).then(() => location.reload());
}
function openEquipModal() {
document.getElementById('woEquipEditId').value = '';
document.getElementById('woEquipSelect').value = '';
document.getElementById('woEquipDesc').value = '';
document.getElementById('woEquipHours').value = '0';
document.getElementById('woEquipNotes').value = '';
document.getElementById('equipModalTitle').textContent = '⚙️ Agregar Equipo a esta Orden';
document.getElementById('btnSaveEquip').textContent = '+ Agregar';
document.getElementById('woEquipStatus').textContent = '';
document.getElementById('woEquipModal').style.display = 'flex';
}
function closeEquipModal() {
document.getElementById('woEquipModal').style.display = 'none';
}
function editWoEquip(id, name, hours, rate, desc, notes) {
document.getElementById('woEquipEditId').value = id;
document.getElementById('woEquipDesc').value = desc;
document.getElementById('woEquipHours').value = hours;
document.getElementById('woEquipRate').value = rate;
document.getElementById('woEquipNotes').value = notes;
document.getElementById('equipModalTitle').textContent = '✏️ Editar Equipo: ' + name;
document.getElementById('btnSaveEquip').textContent = '💾 Guardar';
document.getElementById('woEquipStatus').textContent = '';
document.getElementById('woEquipModal').style.display = 'flex';
}
function saveWoEquip() {
const editId = document.getElementById('woEquipEditId').value;
const desc = document.getElementById('woEquipDesc').value.trim();
const statusEl = document.getElementById('woEquipStatus');
if (!desc) { alert('Describe el trabajo realizado'); return; }
const payload = {
equipment_id: document.getElementById('woEquipSelect').value || null,
description: desc,
notes: document.getElementById('woEquipNotes').value,
labor_hours: parseFloat(document.getElementById('woEquipHours').value) || 0,
labor_rate: parseFloat(document.getElementById('woEquipRate').value) || 0
};
statusEl.textContent = 'Guardando...';
statusEl.style.color = 'var(--gray)';
const url = editId
? '/work-orders/' + WO_ID + '/update-equipment/' + editId
: '/work-orders/' + WO_ID + '/add-equipment';
fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
})
.then(r => r.json())
.then(d => {
if (d.ok) { statusEl.textContent = '✓'; setTimeout(() => location.reload(), 400); }
else { statusEl.textContent = 'Error: ' + (d.error || 'desconocido'); statusEl.style.color = 'var(--danger)'; }
})
.catch(e => { statusEl.textContent = 'Error: ' + e; statusEl.style.color = 'var(--danger)'; });
}
function removeWoEquip(id, row) {
if (!confirm('Quitar este equipo de la orden?')) return;
fetch(`/work-orders/${WO_ID}/remove-equipment/${id}`, {method:'DELETE'})
.then(r => r.json())
.then(d => { if (d.ok) row.remove(); })
.catch(e => console.error(e));
}
// ── SHARE & EMAIL ─────────────────────────────────────────────────────────────
function openShareModal() {
document.getElementById('shareModal').style.display = 'flex';
showTab('email');
}
function closeShareModal() {
document.getElementById('shareModal').style.display = 'none';
}
function showTab(tab) {
document.getElementById('tabEmailContent').style.display = tab==='email' ? '' : 'none';
document.getElementById('tabShareContent').style.display = tab==='share' ? '' : 'none';
document.getElementById('tabEmail').className = 'btn btn-sm ' + (tab==='email' ? 'btn-primary' : 'btn-secondary');
document.getElementById('tabShare').className = 'btn btn-sm ' + (tab==='share' ? 'btn-primary' : 'btn-secondary');
document.getElementById('tabEmail').style.flex = '1';
document.getElementById('tabShare').style.flex = '1';
}
function sendEmail() {
const to = document.getElementById('sendToEmail').value.trim();
const name = document.getElementById('sendToName').value.trim();
const subj = document.getElementById('sendSubject').value.trim();
const lang = document.getElementById('sendLang').value;
const st = document.getElementById('emailStatus');
if (!to) { st.textContent = 'Ingresa un email.'; st.style.color='var(--danger)'; return; }
st.textContent = 'Generando PDF y enviando...'; st.style.color = 'var(--gray)';
fetch(`/work-orders/${WO_ID}/send`, {
method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded'},
body: new URLSearchParams({to_email:to, to_name:name, subject:subj, lang:lang})
}).then(r=>r.json()).then(d=>{
if (d.ok) {
st.textContent = '✅ Email enviado correctamente.';
st.style.color = 'var(--success)';
} else {
st.textContent = '❌ ' + (d.error || 'Error desconocido');
st.style.color = 'var(--danger)';
}
});
}
function generateShareLinks() {
const loading = document.getElementById('shareLoading');
loading.textContent = 'Guardando PDF...';
// First save PDF
fetch(`/work-orders/${WO_ID}/save-pdf`, {method:'POST'})
.then(r=>r.json()).then(saved => {
loading.textContent = 'Generando links...';
return fetch(`/work-orders/${WO_ID}/share`);
})
.then(r=>r.json()).then(d=>{
if (!d.ok) { loading.textContent = 'Error generando links.'; return; }
document.getElementById('pdfLink').value = d.pdf_url;
document.getElementById('waLink').href = d.wa_link;
document.getElementById('smsLink').href = d.sms_link;
document.getElementById('shareLinks').style.display = '';
loading.textContent = '';
});
}
openPhoto = function(src) {
document.getElementById('lightboxImg').src = src;
document.getElementById('lightbox').style.display = 'flex';
}
function initSignaturePad(canvasId) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
// Scale for retina
const rect = canvas.getBoundingClientRect();
let drawing = false;
let lastX = 0, lastY = 0;
function getPos(e) {
const r = canvas.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
return [(src.clientX - r.left) * (canvas.width / r.width),
(src.clientY - r.top) * (canvas.height / r.height)];
}
function start(e) { e.preventDefault(); drawing = true; [lastX, lastY] = getPos(e); }
function draw(e) {
if (!drawing) return; e.preventDefault();
const [x, y] = getPos(e);
ctx.beginPath(); ctx.moveTo(lastX, lastY); ctx.lineTo(x, y);
ctx.strokeStyle = '#0a1628'; ctx.lineWidth = 2.5;
ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke();
[lastX, lastY] = [x, y];
}
function stop() { drawing = false; }
canvas.addEventListener('mousedown', start);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stop);
canvas.addEventListener('mouseleave', stop);
canvas.addEventListener('touchstart', start, {passive:false});
canvas.addEventListener('touchmove', draw, {passive:false});
canvas.addEventListener('touchend', stop);
}
function clearCanvas(canvasId) {
const canvas = document.getElementById(canvasId);
if (canvas) canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
}
function saveSignature(who) {
const canvasId = who === 'tech' ? 'sigTech' : 'sigClient';
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const dataUrl = canvas.toDataURL('image/png');
fetch(`/work-orders/${WO_ID}/save-signature`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({who, dataUrl})
}).then(r => r.json()).then(d => {
if (d.ok) location.reload();
else alert('Error al guardar firma');
});
}
function clearSignature(who) {
if (!confirm('¿Borrar esta firma?')) return;
fetch(`/work-orders/${WO_ID}/clear-signature`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({who})
}).then(() => location.reload());
}
initSignaturePad('sigTech');
initSignaturePad('sigClient');
function openPhoto(src) {
document.getElementById('lightboxImg').src = src;
document.getElementById('lightbox').style.display = 'flex';
}
function addEmail(email) {
const input = document.getElementById('assignEmails');
const current = input.value.trim();
if (current && !current.includes(email)) {
input.value = current + '; ' + email;
} else if (!current) {
input.value = email;
}
}
// ── ASSIGN MODAL ─────────────────────────────────────────────────────────────
function openAssignModal() {
document.getElementById('assignModal').style.display = 'flex';
}
function closeAssignModal() {
document.getElementById('assignModal').style.display = 'none';
}
function sendAssignment() {
const emails = document.getElementById('assignEmails').value.trim();
const message = document.getElementById('assignMessage').value.trim();
if (!emails) { alert('Ingresa al menos un email'); return; }
const btn = document.getElementById('assignBtn');
btn.disabled = true; btn.textContent = 'Enviando...';
fetch('/work-orders/{{ order.id }}/assign', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({to_emails: emails, message: message})
})
.then(r => r.json())
.then(d => {
btn.disabled = false; btn.textContent = '📨 Asignar y Enviar';
if (d.ok) {
closeAssignModal();
alert('✅ WO asignada y enviada con PDF adjunto.');
location.reload();
} else {
alert('Error al enviar: ' + (d.error || 'desconocido'));
}
});
}
// Auto-notify on status change
function setStatus(newStatus) {
fetch(`/work-orders/${WO_ID}/update-status`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({status: newStatus})
}).then(() => {
{% if order.assigned_to %}
fetch('/work-orders/{{ order.id }}/notify-status', {method:'POST'});
{% endif %}
location.reload();
});
}
function confirmReopen() {
if (!confirm('¿Reabrir esta orden de trabajo? El estado volverá a "En Progreso" y podrás editarla nuevamente.')) return;
setStatus('in_progress');
}
</script>
<!-- ASSIGN MODAL -->
<div id="assignModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);
z-index:1000;align-items:center;justify-content:center">
<div style="background:var(--navy2);border:1px solid rgba(0,180,216,0.3);border-radius:12px;
padding:24px;width:100%;max-width:500px;margin:20px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0;color:var(--white)">👤 Asignar Orden de Trabajo</h3>
<button onclick="closeAssignModal()" style="background:none;border:none;color:var(--gray);font-size:20px;cursor:pointer"></button>
</div>
<div style="margin-bottom:8px;font-size:12px;color:var(--cyan)">{{ order.order_number }} — {{ order.vessel_name }}</div>
{% if order.assigned_to %}
<div style="background:rgba(0,180,216,0.08);border:1px solid rgba(0,180,216,0.2);border-radius:6px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:var(--gray)">
Actualmente asignado a: <strong style="color:var(--cyan)">{{ order.assigned_to }}</strong>
</div>
{% endif %}
<div class="form-group" style="margin-bottom:12px">
<label>Emails (separados por coma o punto y coma)</label>
<input type="text" id="assignEmails"
placeholder="tecnico@empresa.com; supervisor@empresa.com"
style="width:100%;padding:8px 12px;border-radius:6px;
background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.25);
color:var(--white);font-size:13px">
{% if users %}
<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap">
{% for u in users %}
<span onclick="addEmail('{{ u.email }}')"
style="background:rgba(0,180,216,0.1);border:1px solid rgba(0,180,216,0.2);
color:var(--cyan);padding:3px 8px;border-radius:12px;font-size:11px;
cursor:pointer" title="{{ u.full_name }}">
+ {{ u.full_name or u.username }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-group" style="margin-bottom:16px">
<label>Mensaje adicional (opcional)</label>
<textarea id="assignMessage" rows="3"
style="width:100%;padding:8px 12px;border-radius:6px;
background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.25);
color:var(--white);font-size:13px"
placeholder="Instrucciones especiales, prioridad, materiales necesarios..."></textarea>
</div>
<div style="font-size:11px;color:var(--gray);margin-bottom:12px">
📎 Se enviará el PDF de la WO adjunto al email
</div>
<div style="display:flex;gap:10px">
<button id="assignBtn" onclick="sendAssignment()" class="btn btn-primary" style="flex:1">📨 Asignar y Enviar</button>
<button onclick="closeAssignModal()" class="btn btn-secondary">Cancelar</button>
</div>
</div>
</div>
{% endblock %}
+78
View File
@@ -0,0 +1,78 @@
{% extends 'base.html' %}
{% block title %}Editar {{ order.order_number }}{% endblock %}
{% block page_title %}✏️ Editar {{ order.order_number }} — {{ order.vessel_name }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('work_order_detail', woid=order.id) }}" class="btn btn-secondary">← Cancelar</a>
{% endblock %}
{% block content %}
<div class="card" style="max-width:820px">
<form method="POST">
<div class="form-grid">
<div class="form-group">
<label>Sistema Principal</label>
<select name="system_id">
<option value="">-- Sin sistema --</option>
{% for s in systems %}
<option value="{{ s.id }}" {% if order.system_id==s.id %}selected{% endif %}>{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Tipo de Facturación *</label>
<select name="billing_type" required>
<option value="labor_materials" {% if order.billing_type=='labor_materials' or not order.billing_type %}selected{% endif %}>
Mano de obra + Materiales (discriminado)
</option>
<option value="lump_sum" {% if order.billing_type=='lump_sum' %}selected{% endif %}>
A todo costo (precio fijo todo incluido)
</option>
<option value="labor_only" {% if order.billing_type=='labor_only' %}selected{% endif %}>
Solo mano de obra
</option>
</select>
</div>
<div class="form-group">
<label>Tipo de Trabajo</label>
<select name="work_type">
<option value="">-- Seleccionar --</option>
{% for wt in ['Preventive','Corrective','Inspection','Installation','Other'] %}
<option value="{{ wt }}" {% if order.work_type==wt %}selected{% endif %}>{{ wt }}</option>
{% endfor %}
</select>
</div>
<div class="form-group full">
<label>Scope — Resumen del Trabajo *</label>
<input type="text" name="scope" value="{{ order.scope or '' }}" required>
</div>
<div class="form-group">
<label>Técnico</label>
<input type="text" name="technician" value="{{ order.technician or '' }}">
</div>
<div class="form-group">
<label>Fecha Inicio</label>
<input type="date" name="start_date" value="{{ order.start_date or '' }}">
</div>
<div class="form-group">
<label>Horas Motor (inicio)</label>
<input type="number" step="0.1" name="engine_hours_start" value="{{ order.engine_hours_start or '' }}">
</div>
<div class="form-group">
<label>Tarifa M.O. ($/h)</label>
<input type="number" step="0.01" name="labor_rate" value="{{ order.labor_rate or 0 }}">
</div>
<div class="form-group full">
<label>Descripción Detallada</label>
<textarea name="description" rows="4">{{ order.description or '' }}</textarea>
</div>
<div class="form-group full">
<label>Notas Internas</label>
<textarea name="notes" rows="2">{{ order.notes or '' }}</textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Guardar Cambios</button>
<a href="{{ url_for('work_order_detail', woid=order.id) }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
+133
View File
@@ -0,0 +1,133 @@
{% extends 'base.html' %}
{% block title %}Nueva Orden de Trabajo{% endblock %}
{% block page_title %}Nueva Orden de Trabajo{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('work_orders') }}" class="btn btn-secondary">← Volver</a>
{% endblock %}
{% block content %}
<div class="card" style="max-width:820px">
<form method="POST">
<div class="form-grid">
<div class="form-group">
<label>Sistema Principal *</label>
<select name="system_id" id="systemSelect" required>
<option value="">-- Seleccionar Sistema --</option>
</select>
</div>
<div class="form-group">
<label style="color:var(--cyan)">🚢 Embarcación *</label>
<select name="vessel_id" id="vesselSelect" required onchange="loadEquipment(this.value)"
style="border-color:rgba(0,180,216,0.4);font-weight:600">
<option value="">— Seleccionar Embarcación —</option>
{% for v in vessels %}
<option value="{{ v.id }}" {% if preselect_vessel and preselect_vessel==v.id|string %}selected{% endif %}>{{ v.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Equipo Específico (opcional)</label>
<select name="equipment_id" id="equipmentSelect">
<option value="">— General / Sin equipo específico —</option>
{% for e in equipment_list %}
<option value="{{ e.id }}">{{ e.name }} {% if e.serial_number %}· S/N: {{ e.serial_number }}{% endif %}</option>
{% endfor %}
</select>
</div>
<div class="form-group full">
<label>Scope — Resumen del Trabajo *</label>
<input type="text" name="scope" required
placeholder="Ej: Diagnóstico y cambio de cargador de baterías estribor">
</div>
<div class="form-group">
<label>Tipo de Facturación *</label>
<select name="billing_type" required>
<option value="labor_materials" {% if not order or order.billing_type=='labor_materials' %}selected{% endif %}>
Mano de obra + Materiales (discriminado)
</option>
<option value="lump_sum" {% if order and order.billing_type=='lump_sum' %}selected{% endif %}>
A todo costo (precio fijo todo incluido)
</option>
<option value="labor_only" {% if order and order.billing_type=='labor_only' %}selected{% endif %}>
Solo mano de obra
</option>
</select>
</div>
<div class="form-group">
<label>Tipo de Trabajo</label>
<select name="work_type">
<option value="">-- Seleccionar --</option>
<option value="Preventive">Preventivo</option>
<option value="Corrective">Correctivo</option>
<option value="Inspection">Inspección</option>
<option value="Installation">Instalación</option>
<option value="Other">Otro</option>
</select>
</div>
<div class="form-group">
<label>Técnico</label>
<input type="text" name="technician">
</div>
<div class="form-group">
<label>Fecha Inicio</label>
<input type="date" name="start_date" id="startDate">
</div>
<div class="form-group">
<label>Horas Motor (inicio)</label>
<input type="number" step="0.1" name="engine_hours_start">
</div>
<div class="form-group">
<label>Tarifa Mano de Obra ($/h)</label>
<input type="number" step="0.01" name="labor_rate" value="0">
</div>
<div class="form-group full">
<label>Descripción Detallada</label>
<textarea name="description" rows="4"
placeholder="Describe en detalle el trabajo a realizar..."></textarea>
</div>
<div class="form-group full">
<label>Notas Internas</label>
<textarea name="notes" rows="2"></textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">💾 Crear Orden</button>
<a href="{{ url_for('work_orders') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('startDate').value = new Date().toISOString().split('T')[0];
// Load systems
fetch('/api/systems').then(r=>r.json()).then(data => {
const sel = document.getElementById('systemSelect');
data.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name;
sel.appendChild(opt);
});
});
function loadEquipment(vesselId) {
const sel = document.getElementById('equipmentSelect');
sel.innerHTML = '<option value="">— General / Sin equipo específico —</option>';
if (!vesselId) return;
fetch(`/api/vessel-equipment/${vesselId}`)
.then(r => r.json())
.then(data => {
data.forEach(e => {
const opt = document.createElement('option');
opt.value = e.id;
opt.textContent = e.name + (e.serial_number ? ` · S/N: ${e.serial_number}` : '');
sel.appendChild(opt);
});
});
}
{% if preselect_vessel %}
loadEquipment('{{ preselect_vessel }}');
{% endif %}
</script>
{% endblock %}
+185
View File
@@ -0,0 +1,185 @@
{% extends 'base.html' %}
{% block title %}Órdenes de Trabajo{% endblock %}
{% block page_title %}Órdenes de Trabajo{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('work_order_new') }}" class="btn btn-primary">+ Nueva Orden</a>
{% endblock %}
{% block head %}
<style>
/* ── Filtros ── */
.wo-filters {
display:flex;gap:8px;align-items:center;margin-bottom:14px;flex-wrap:wrap;
}
.wo-filters input {
flex:1;min-width:180px;padding:7px 12px;border-radius:6px;
background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.25);
color:var(--white);font-size:13px;
}
/* ── Tabla (desktop) ── */
.wo-table-wrap { display:block; }
.wo-cards-wrap { display:none; }
/* ── Tarjetas (móvil) ── */
@media (max-width: 768px) {
.wo-table-wrap { display:none; }
.wo-cards-wrap { display:block; }
}
.wo-card {
background:var(--navy2);
border:1px solid rgba(0,180,216,0.12);
border-radius:10px;
margin-bottom:10px;
overflow:hidden;
transition:border-color 0.2s;
}
.wo-card:active { border-color:rgba(0,180,216,0.4); }
.wo-card-header {
display:flex;justify-content:space-between;align-items:center;
padding:10px 14px;
background:rgba(0,0,0,0.2);
border-bottom:1px solid rgba(255,255,255,0.05);
}
.wo-card-num { font-size:12px;color:var(--cyan);font-weight:600;font-family:monospace; }
.wo-card-body { padding:10px 14px; }
.wo-card-vessel {
font-size:15px;font-weight:600;color:var(--white);margin-bottom:4px;
}
.wo-card-scope {
font-size:13px;color:var(--gray);margin-bottom:6px;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
}
.wo-card-meta {
display:flex;gap:10px;flex-wrap:wrap;font-size:12px;color:var(--gray);margin-bottom:10px;
}
.wo-card-meta span { display:flex;align-items:center;gap:4px; }
.wo-card-actions {
display:flex;gap:8px;padding:8px 14px;
border-top:1px solid rgba(255,255,255,0.05);
background:rgba(0,0,0,0.1);
}
.wo-card-actions a, .wo-card-actions button {
flex:1;text-align:center;font-size:13px;padding:8px 4px;
}
</style>
{% endblock %}
{% block content %}
<div class="wo-filters">
<a href="{{ url_for('work_orders') }}" class="btn btn-sm {% if not status_filter %}btn-primary{% else %}btn-secondary{% endif %}">Todas</a>
<a href="{{ url_for('work_orders', status='open') }}" class="btn btn-sm {% if status_filter=='open' %}btn-primary{% else %}btn-secondary{% endif %}">Abiertas</a>
<a href="{{ url_for('work_orders', status='in_progress') }}" class="btn btn-sm {% if status_filter=='in_progress' %}btn-warning{% else %}btn-secondary{% endif %}">En Progreso</a>
<a href="{{ url_for('work_orders', status='completed') }}" class="btn btn-sm {% if status_filter=='completed' %}btn-success{% else %}btn-secondary{% endif %}">Completadas</a>
<input type="text" id="woSearch" placeholder="🔍 Buscar embarcación, scope, técnico..."
oninput="filterWO(this.value)">
</div>
<!-- TABLA DESKTOP -->
<div class="wo-table-wrap card">
<div class="table-wrap">
<table>
<thead>
<tr><th>Orden</th><th>🚢 Embarcación</th><th>Tipo</th><th>Scope</th><th>Técnico</th><th>Fecha</th><th>Estado</th><th></th></tr>
</thead>
<tbody>
{% for o in orders %}
<tr class="wo-row" data-search="{{ (o.vessel_name ~ ' ' ~ (o.scope or '') ~ ' ' ~ (o.technician or '') ~ ' ' ~ o.order_number)|lower }}">
<td class="text-cyan" style="white-space:nowrap;font-family:monospace;font-size:12px">{{ o.order_number }}</td>
<td><a href="{{ url_for('vessel_history', vid=o.vessel_id) }}" class="text-cyan" style="font-weight:600">{{ o.vessel_name }}</a></td>
<td>{{ o.work_type or '—' }}</td>
<td style="max-width:240px">{{ o.scope or (o.description[:60] if o.description else '—') }}</td>
<td>{{ o.technician or '—' }}</td>
<td class="text-gray" style="white-space:nowrap">{{ o.start_date or o.created_at[:10] }}</td>
<td><span class="badge badge-{{ o.status }}">{{ o.status.replace('_',' ') }}</span></td>
<td class="flex gap-2" style="white-space:nowrap">
<a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-secondary">Ver</a>
{% if o.status != 'completed' %}
<a href="{{ url_for('work_order_edit', woid=o.id) }}" class="btn btn-sm btn-secondary">✏️</a>
<button onclick="deleteWO({{ o.id }})" class="btn btn-sm btn-danger">🗑️</button>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-gray" style="text-align:center;padding:30px">No hay órdenes.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div id="noWOResultsTable" style="display:none;text-align:center;padding:20px;color:var(--gray)">
No se encontraron órdenes.
</div>
</div>
<!-- TARJETAS MÓVIL -->
<div class="wo-cards-wrap">
{% for o in orders %}
<div class="wo-card" id="card-{{ o.id }}"
data-search="{{ (o.vessel_name ~ ' ' ~ (o.scope or '') ~ ' ' ~ (o.technician or '') ~ ' ' ~ o.order_number)|lower }}">
<div class="wo-card-header">
<span class="wo-card-num">{{ o.order_number }}</span>
<span class="badge badge-{{ o.status }}">{{ o.status.replace('_',' ') }}</span>
</div>
<div class="wo-card-body">
<div class="wo-card-vessel">
<a href="{{ url_for('vessel_history', vid=o.vessel_id) }}" style="color:var(--cyan)">
🚢 {{ o.vessel_name }}
</a>
</div>
<div class="wo-card-scope">{{ o.scope or (o.description[:100] if o.description else 'Sin descripción') }}</div>
<div class="wo-card-meta">
{% if o.work_type %}<span>🔧 {{ o.work_type }}</span>{% endif %}
{% if o.technician %}<span>👤 {{ o.technician }}</span>{% endif %}
<span>📅 {{ o.start_date or o.created_at[:10] }}</span>
{% if o.billing_type == 'lump_sum' %}<span>💰 Todo costo</span>
{% elif o.billing_type == 'labor_only' %}<span>🔧 Solo M.O.</span>
{% else %}<span>📋 M.O.+Mat.</span>{% endif %}
</div>
</div>
<div class="wo-card-actions">
<a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-primary">Ver detalle</a>
{% if o.status != 'completed' %}
<a href="{{ url_for('work_order_edit', woid=o.id) }}" class="btn btn-sm btn-secondary">✏️ Editar</a>
<button onclick="deleteWO({{ o.id }})" class="btn btn-sm btn-danger">🗑️</button>
{% endif %}
</div>
</div>
{% else %}
<div style="text-align:center;padding:40px;color:var(--gray)">No hay órdenes.</div>
{% endfor %}
<div id="noWOResultsCards" style="display:none;text-align:center;padding:20px;color:var(--gray)">
No se encontraron órdenes.
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function filterWO(q) {
q = q.toLowerCase().trim();
let visibleT = 0, visibleC = 0;
document.querySelectorAll('.wo-row').forEach(r => {
const show = !q || r.dataset.search.includes(q);
r.style.display = show ? '' : 'none';
if (show) visibleT++;
});
document.querySelectorAll('.wo-card').forEach(c => {
const show = !q || c.dataset.search.includes(q);
c.style.display = show ? '' : 'none';
if (show) visibleC++;
});
document.getElementById('noWOResultsTable').style.display = visibleT === 0 ? 'block' : 'none';
document.getElementById('noWOResultsCards').style.display = visibleC === 0 ? 'block' : 'none';
}
function deleteWO(id) {
if (!confirm('¿Eliminar esta orden? Esta acción no se puede deshacer.')) return;
fetch('/work-orders/' + id + '/delete', {method:'DELETE'})
.then(r => r.json())
.then(d => {
if (d.ok) location.reload();
else alert('Error: ' + d.error);
});
}
</script>
{% endblock %}